Skip to content

feat(platform): D2: hold and scope artifacts by group - #552

Open
dannash100 wants to merge 55 commits into
epic/deployment-artefactsfrom
feat/group-scoped-artifacts
Open

dannash100 wants to merge 55 commits into
epic/deployment-artefactsfrom
feat/group-scoped-artifacts

Conversation

@dannash100

@dannash100 dannash100 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Some artifacts are derived from one group's data and are wrong for anyone else, but every artifact is fleet-wide and every read is unauthenticated.

  • Artifacts gain a group. Canopy holds a scoped one's bytes in a bucket of its own, verifies the digest on arrival and on serve, and offers it to that group alone.
  • download_url stays a required string. Nullable would break the published client, so a held artifact is offered Canopy's own download endpoint.
  • Missing artifacts now 404, not 500: an artifact you aren't offered must answer identically to one that never existed.
  • Needs CANOPY_ARTIFACT_BUCKET on both pods before deploy, or uploads and held downloads fail.

🦸 Review Hero

  • Run Review Hero

Comment thread migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql
Comment thread private-web/src/routes/VersionDetail.tsx Outdated
Comment thread crates/database/src/artifacts.rs Outdated
Comment thread crates/private-server/src/fns/versions.rs Outdated
Comment thread private-web/src/routes/VersionDetail.tsx
Comment thread crates/database/src/artifacts.rs
Comment thread crates/database/src/artifacts.rs Outdated
Comment thread crates/private-server/src/fns/versions.rs Outdated
Comment thread crates/private-server/src/fns/versions.rs Outdated
Comment thread crates/public-server/src/artifacts.rs
@review-hero

review-hero Bot commented Sep 9, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary
9 agents reviewed this PR | 1 critical | 10 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 8 below threshold

Below consensus threshold (8 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/database/src/artifacts.rs:117 Security suggestion location() is the new gate on what counts as a place an artifact rests, but it only rejects blank strings — any scheme or host passes. download_artifact then fetches that URL server-side with r...
crates/database/src/artifacts.rs:314 Security nitpick Artifact::content_for is pub and takes only an artifact id — it performs no scope check, so it will happily return another group's held bytes to any caller. Today the only call site (`download_...
crates/database/src/artifacts.rs:342 Bugs & Correctness suggestion register stores the caller-supplied digest verbatim alongside content without checking that it describes those bytes — resting() only tests that both are present. Since download_artifact ...
crates/private-server/src/fns/versions.rs:719 Bugs & Correctness nitpick The digest comparison claimed != digest is byte-exact, while digest_of always emits lowercase hex. A client that sends an otherwise-correct digest in uppercase hex (or with a SHA256: prefix) ...
crates/public-server/src/artifacts.rs:94 Security suggestion caller_scope is now an authorisation decision — it decides which group's held bytes a credential is served — but Machine::get_by_device_id (crates/database/src/machines.rs:322) has no `deleted_...
crates/public-server/src/artifacts.rs:245 Security nitpick The digest query parameter is stored verbatim after only a blank check, yet for an unscoped artifact it is the integrity control the fetching client checks the downloaded bytes against. `?digest=...
crates/public-server/src/versions.rs:530 Security suggestion GET /versions/{v}/artifacts and /versions/{v}/artifacts/{id}/download now return different bodies depending on the caller's client certificate (unscoped set vs. the caller's group's artifact, a...
private-web/src/routes/VersionDetail.tsx:665 Security nitpick crypto.subtle is only defined in a secure context (HTTPS or localhost). If the private SPA is ever reached over plain HTTP on a tailnet hostname/IP — which the dev flow already does at `127.0.0...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql:46`: `CREATE UNIQUE INDEX artifacts_identity ... NULLS NOT DISTINCT` will abort the migration on any database that already holds duplicate range artifacts. The migration's own comment states the reason: the old `artifacts_type_platform_version_id` constraint gave range rows no uniqueness at all (`version_id` is NULL for every one), and the pre-change public `create` handler did an unconditional `insert_into(artifacts)` for the range branch — so every re-registration of e.g. `POST /artifacts/2.60.x/installer/windows` appended another row with the identical (artifact_type, platform, NULL, '2.60.x', NULL) tuple. Read-time dedup in `get_for_version` hid them, so duplicates are likely present in production. Under `NULLS NOT DISTINCT` those rows are now equal and index creation fails, leaving the deploy stuck half-migrated. Add a dedup step before the index, e.g. `DELETE FROM artifacts a USING artifacts b WHERE a.artifact_type = b.artifact_type AND a.platform = b.platform AND a.version_id IS NOT DISTINCT FROM b.version_id AND a.version_range_pattern IS NOT DISTINCT FROM b.version_range_pattern AND a.group_id IS NOT DISTINCT FROM b.group_id AND (a.updated_at, a.id) < (b.updated_at, b.id);` keeping the newest of each identity.

-------

`private-web/src/routes/VersionDetail.tsx:697`: A failure in `readFile` is swallowed with no feedback at all. `submit`'s `catch { /* surfaced via action.error */ }` relies on `action.call` having run, but `await readFile(file)` happens first — if it throws, `action.call` is never reached, `action.error` stays null, `action.pending` never flips, and the Create button simply does nothing when clicked. `file.arrayBuffer()` rejects for real reasons (the file was moved or truncated after selection → `NotReadableError`), and `crypto.subtle` is `undefined` outside a secure context, which would make group-scoped registration silently impossible wherever the private server is reached over plain HTTP on a non-localhost host. Catch the read separately and set a visible error, e.g. `setFileError("Could not read the file")` in a `catch` around `readFile`.

-------

`crates/database/src/artifacts.rs:118`: `location()` validates the trimmed value but persists the untrimmed one, so a URL with surrounding whitespace (a trailing newline is easy to get from `curl -d @file` or a shell heredoc on the releaser path, which likewise only checks `url.trim().is_empty()` before passing `Some(url)` through) is stored verbatim as `" https://…\n"`. That string is then handed to devices in the public listing and, in the SPA, fails `artifact.download_url?.startsWith("https://")` in `VersionDetail.tsx`, so the row renders as plain text rather than a link. Return the trimmed string: `url.map(|url| url.trim().to_owned()).filter(|url| !url.is_empty())`.

-------

`crates/private-server/src/fns/versions.rs:736`: A digest recorded against a location is never checked for shape — the `(None, None)` branch stores whatever `args.digest` contained after trimming, and the releaser path (`crates/public-server/src/artifacts.rs`, `named.digest`) does the same, so `digest=sha256:abcd` or `digest=notadigest` is accepted and published to every device. Whoever fetches the artifact then either fails the fetch on a digest that cannot possibly match, or (worse) can't parse it and skips verification silently. Since the held-bytes path already normalises to `algorithm:hex`, validate the same shape here (known algorithm prefix, hex body of the right length) and refuse anything else as a client mistake rather than storing an unusable value.

-------

`private-web/src/routes/VersionDetail.tsx:673`: `readFile` holds the artifact in memory four times over on the main thread: the `ArrayBuffer`, the `chunks` array of latin-1 strings, the joined binary string (UTF-16, so ~2x the file), and the base64 output (~1.33x). For a file at the 32 MiB limit that is well over 100 MB of transient allocation plus a synchronous `String.fromCharCode`/`join`/`btoa` pass that blocks the tab before the request is even sent. Encoding chunk-by-chunk through `btoa` on 3-byte-aligned slices (so the pieces concatenate correctly) avoids the joined binary string entirely, and dropping the reference to `chunks`/`buffer` before building the request body lets the largest copies be collected.

-------

`crates/public-server/src/versions.rs:683`: Held artifacts are fully buffered and re-hashed on every download: `content_for` loads the whole `bytea` into a `Vec<u8>` and `digest_of(&held.bytes)` runs SHA-256 over up to 32 MiB per request, before `Body::from` hands it out. On an internet-exposed endpoint that any machine credential can hit, N concurrent downloads of a 32 MiB reporting schema means N × 32 MiB resident plus N full hash passes — a handful of clients pulling their schema at once (e.g. after a fleet-wide upgrade) is enough to pressure the server, and nothing rate-limits or caches it. Consider verifying the digest once at registration/first read and recording a verified marker (or streaming the bytea via a Postgres large-object/chunked read and hashing incrementally as it streams out) rather than materialising and re-hashing the whole artifact on each fetch.

-------

`crates/database/src/artifacts.rs:530`: `overridden_range` is called once per matching artifact and, for each call, iterates every range artifact in the table and calls `node_semver::Range::parse` on its pattern. That is O(matching × ranges) range parses per operator listing — the same handful of patterns get re-parsed dozens of times. Parse each range once before the `map` (e.g. build a `Vec<(&Self, Range)>` from `ranges`, skipping unparseable patterns) and have `overridden_range` take that pre-parsed slice; the semantics are unchanged and the parse count drops to the number of range rows.

-------

`crates/database/src/artifacts.rs:489`: The override check loads every range artifact in the whole table (`filter(version_range_pattern.is_not_null())`) with no version, type, platform, or group predicate, so this query grows without bound as artifacts accumulate across versions and it runs on every operator listing and every `create_artifact` read-back. Narrowing it to the type/platform pairs actually present in `matching_artifacts` (or to the group ids in play) would keep the row count proportional to what the answer can depend on rather than to the lifetime size of the table.

-------

`crates/private-server/src/fns/versions.rs:692`: The size check happens after decoding: `BASE64_STANDARD.decode(encoded)` allocates the full decoded buffer first, so at peak the request holds the ~43 MB base64 string (from the raised body limit), the decoded `Vec<u8>` up to ~33 MB, and the hash input simultaneously — ~80 MB per in-flight create, with no cap on concurrent admin uploads. Check `encoded.len()` against the base64-inflated limit (`MAX_HELD_ARTIFACT_BYTES / 3 * 4 + 4`) before decoding so an over-limit upload is refused without ever being materialised; keep the post-decode check as the exact bound.

-------

`crates/private-server/src/fns/versions.rs:776`: The read-back re-runs the entire operator listing (`artifacts_of` → `get_for_version_all_matches_with_metadata`, which loads every range artifact in the table, resolves per-group scope sets, and computes `overridden_range` for every matching artifact) purely to `find` the single row just registered, and then discards the rest. On a version with many artifacts every registration pays the full listing cost. Since the caller almost always re-fetches the listing after a create anyway, consider computing just this row's `is_exact`/`has_range_override`/`is_used_in_public_api` from the already-loaded match set, or having `artifacts_of` return the vector to the handler so the response reuses one pass instead of implying a second.

-------

`crates/public-server/src/artifacts.rs:64`: The download URL handed to fleet machines for Canopy-held bytes is built from `public_base_url(&headers)`, which falls back to the request's `X-Forwarded-Proto` + `Host` headers whenever `PUBLIC_URL` is unset. `/versions/{v}/artifacts` is an unauthenticated, cacheable GET, so a spoofed `Host` (or a cache/CDN keyed without `Host`) turns the offered `download_url` into an attacker-chosen origin that machines then fetch schemas from. This was previously confined to RSS links; it now drives artifact fetches. Either require `PUBLIC_URL` for this synthesis (error/log rather than reflect the header), or emit a relative path (`/versions/{v}/artifacts/{id}/download`) so the location can never be steered by a request header.

Comment thread crates/database/src/artifacts.rs
Comment thread crates/public-server/src/artifacts.rs
Comment thread crates/database/src/artifacts.rs Outdated
Comment thread crates/database/src/artifacts.rs Outdated
Comment thread crates/public-server/src/versions.rs Outdated
Comment thread crates/database/src/artifacts.rs Outdated
Comment thread crates/public-server/src/versions.rs Outdated
Comment thread crates/private-server/src/fns/versions.rs Outdated
Comment thread crates/private-server/src/fns/versions.rs
Comment thread crates/public-server/src/versions.rs
@review-hero

review-hero Bot commented Sep 9, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary
12 agents reviewed this PR | 1 critical | 19 suggestions | 2 nitpicks | Filtering: consensus 3 voters, 9 below threshold

Below consensus threshold (9 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/commons-servers/src/device_auth/mod.rs:100 Bugs & Correctness suggestion unplaceable deliberately excludes AuthTailnetDirectoryUnavailable and AuthTailnetNodeNotPermitted, but on the private server's /public mount every caller comes in over the tailnet, so those...
crates/database/src/artifacts.rs:387 Security suggestion register is an upsert whose .set(...) writes digest.eq(&input.digest) unconditionally, so a re-registration that omits the digest silently clears one that was already recorded. On the public ...
crates/private-server/src/fns/versions.rs:753 Performance suggestion upload_artifact takes a connection out of the pool as its first act, then does the size check, SRI parse and a SHA-256 over up to 32 MiB before it issues any query. Every concurrent upload theref...
crates/public-server/src/artifacts.rs:61 Bugs & Correctness suggestion The synthesized download URL for a held artifact is built from public_base_url() (PUBLIC_URL, else scheme+Host) and an absolute /versions/... path, which ignores where these routes are actually...
crates/public-server/src/artifacts.rs:164 Security suggestion Registration now validates that download_url is non-blank but still accepts any scheme, and the public artifact pages render it into contexts where the scheme matters. `templates/artifacts.html.t...
crates/public-server/src/artifacts.rs:209 Bugs & Correctness suggestion An unparseable version/range in the path still answers AppError::custom (500) while the neighbouring caller-controlled mistakes introduced here answer 400 (blank body → BadRequest, unparseable ...
crates/public-server/src/versions.rs:700 Security suggestion Group-scoped responses are now identity-varying but carry no cache directives. /versions/{v}/artifacts and /versions/{v}/artifacts/{id}/download return different bodies for the same URL dependi...
migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql:46 Bugs & Correctness critical CREATE UNIQUE INDEX artifacts_identity ... NULLS NOT DISTINCT will abort on existing data. The migration's own comment notes range artifacts had no uniqueness before (the dropped constraint was...
private-web/src/routes/VersionDetail.tsx:714 Bugs & Correctness suggestion submit's catch { /* surfaced via action.error */ } assumes every throw comes from the api hook, but await digestOf(file) (line 694) runs before upload.call and can throw on its own: `crypto...

Nitpicks

File Line Agent Comment
crates/database/src/artifacts.rs 14 Design & Architecture This adds a second Scope enum to the database crate alongside issues::Scope (Server/Group/Global), so database::Scope-shaped code now means two different things depending on the import, and Group(Uuid) appears in both with different semantics (a filing target vs. a read visibility...
crates/database/src/artifacts.rs 477 Design & Architecture get_for_version_all_matches_with_metadata still returns Vec<(Self, bool, bool, bool)>, and this PR makes it the sole metadata path (the deduplicated sibling was deleted) while giving the third flag a materially richer meaning — "offered in at least one resolved scope" rather than "in the dedu...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/database/src/artifacts.rs:444`: `Artifact::update` rewrites `download_url` but leaves `digest` untouched, so an unscoped artifact keeps the digest recorded for the *previous* location. `create_artifact` now records a digest, and `list_artifacts` serves it as `sha256-…` for callers to check the bytes they fetched against — so after an operator edits the URL to point at a new build, every device that honours the digest rejects a file that is actually correct (and one that keeps the digest is verifying new bytes against an old hash). `register`'s upsert gets this right by always setting `digest.eq(&input.digest)`; `update` should either clear `digest` when the location changes or accept a new one alongside the URL.

-------

`crates/public-server/src/artifacts.rs:220`: The blank-body check trims (`url.trim().is_empty()`) but the value stored is the untrimmed `url`, and `location()` in the database crate does the same (`url.filter(|url| !url.trim().is_empty())`). A releaser posting the URL as a plain-text body — `curl --data-binary @url.txt`, or any shell that appends a newline — registers `"https://…/x.exe\n"`. That passes the emptiness check, is handed straight to callers as `download_url`, and makes `reqwest::Client::get(&download_url)` on the proxy path fail to parse. Store `url.trim()` (and have `location` return the trimmed string) so the value that survives the check is the value that gets recorded.

-------

`crates/database/src/artifacts.rs:482`: `Artifact::update` hand-rolls the same "where does this artifact rest" rule that `NewArtifact::resting` already encodes, and answers it differently: `resting` returns `BadRequest` for a group-scoped artifact given a URL / an unscoped one given none, while `update` returns `Conflict` for the identical two conditions (and the tests pin 409). One invariant with two owners and two status codes means a UI has to handle both shapes for the same operator mistake, and the next change to the rule has to be made twice. Extract the shape check into one function over `(group_id, download_url)` returning a single error kind and call it from both paths. Separately, `update` now does a SELECT for `group_id` and then an UPDATE as two non-atomic round-trips; folding the guard into the UPDATE's WHERE (or one statement with a returning clause) keeps it a single decision.

-------

`crates/database/src/artifacts.rs:339`: `content_for(db, artifact_id)` is `pub` and takes only an id, so the group boundary it is part of lives entirely in the caller: `download_artifact` happens to resolve a `Scope` first, and any future caller that doesn't will read any group's bytes by id with no compile-time hint that it must. The module already has `Scope` and `Scope::sees`; make the scope part of the signature (`content_for(db, id, scope)`, filtering `group_id IS NULL OR group_id = $scope`) so the boundary is enforced by the type rather than by remembering to call two functions in order. That also collapses the current two-query pattern (resolve the whole offered set, then re-read the same row for its bytes) into one lookup.

-------

`crates/public-server/src/versions.rs:680`: `download_artifact` uses the *deduplicated* `get_for_version` set as its lookup-and-authorisation check, which conflates two different questions: "which artifact is the most specific offer" and "may this caller fetch this artifact". The consequence is that a URL Canopy itself handed out stops working as soon as a more specific artifact of the same type+platform is registered — the previously-offered row is still visible to that scope but is no longer in the deduplicated set, so it 404s. Look the artifact up by id and gate it on `scope.sees(artifact.group_id)` (plus version match); that gives the same indistinguishable-404 behaviour the comment is after, without making fetchability depend on specificity ranking.

-------

`crates/private-server/src/fns/versions.rs:805`: `registered()` echoes a just-created artifact by re-running the whole fleet-wide listing — `get_for_version_all_matches_with_metadata` (which itself loads every range artifact in the table and recomputes the offered set per group) plus `ServerGroup::names_by_id` for every group — and then discards all but one row. It also ends in `AppError::custom("the artifact just registered is not listed")`, a 500 for a state that cannot occur (the row was just written with this `version_id`). Both create paths already hold the inserted row and know its group; building the `ArtifactData` from it plus a single group-name lookup would be a fraction of the work and would drop the impossible-case error arm. If the derived `has_range_override` / `is_used_in_public_api` flags are the reason for the round-trip, say so and compute just those, rather than materialising the full listing.

-------

`private-web/src/api.ts:239`: `useApiUpload` is a line-for-line copy of `useApiAction` (same pending/error state, same try/catch, same `canopy-data-changed` dispatch, same `reset`) with only the inner transport call differing — the `answered` extraction just above shows the right instinct, but it stopped at the fetch layer. Factor the hook body into one helper parameterised by the request function (e.g. `useApiCall(fn)` returning `{call, pending, error, reset}`) and have both exports wrap it, so the event-dispatch and error-normalisation behaviour can't drift between the two.

-------

`private-web/src/routes/VersionDetail.tsx:661`: The 32 MiB cap now exists in three unrelated places: `MAX_HELD_ARTIFACT_BYTES` in `fns/versions.rs`, `MAX_HELD_ARTIFACT_BYTES` here, and literals in the Rust and Playwright tests — and the SPA's copy also duplicates the server's wording in `OVER_LIMIT_MESSAGE` so that the client-side and server-side refusals read identically by coincidence. Since the wire types are already generated from the Rust spec, the cap is a natural thing to expose (a constant on the upload endpoint's schema, or a small `limits` server fn) rather than to keep in sync by hand; otherwise raising the limit server-side silently leaves the UI refusing valid files.

-------

`crates/commons-errors/src/lib.rs:397`: `every_slug_has_a_heading_to_land_on` tests a real invariant, but it does so by `include_str!`ing its own source and string-splitting on `"slug = match self {"` / `"unreachable!()"`, with `assert!(slugs.len() > 30)` as a canary. Any reformatting or restructuring of that match — the sort of thing rustfmt or a refactor does incidentally — turns a passing invariant into a confusing parse failure, and the canary threshold has to be maintained by hand as variants are added. Iterating the variants directly (a `const ALL: &[AppError]`, or `strum`/a small macro that already generates the slug arm) would check the same property without the source-scraping. Also `a_client_mistake_is_not_a_fault` covers `BadRequest`/`Conflict`, which this PR doesn't touch — fine to keep, but it belongs with the slug work rather than the artifact change.

-------

`crates/database/src/artifacts.rs:14`: This adds a second `Scope` enum to the `database` crate alongside `issues::Scope` (`Server`/`Group`/`Global`), so `database::Scope`-shaped code now means two different things depending on the import, and `Group(Uuid)` appears in both with different semantics (a filing target vs. a read visibility filter). If they really are distinct concepts, a name that says so — `Visibility`, `ArtifactAudience` — removes the ambiguity at every call site for free; if the group/global halves are the same idea, reusing the existing enum avoids a parallel `sees`/`for_caller` vocabulary growing next to `from_columns`/`to_columns`.

-------

`crates/database/src/artifacts.rs:215`: Visibility is now enforced twice for every read: `get_for_version_all_matches` narrows the SQL by scope (`group_id IS NULL OR group_id = caller`), and then `offered` filters the same rows again with `scope.sees(...)`. Since the query already restricts, the `.filter(|a| scope.sees(a.group_id))` line can never drop a row — it's dead in all three variants, but it also makes the visibility rule live in two places, so a future change to one has to be mirrored in the other or they diverge silently. Pick one home: keep the rule in SQL and have `offered` only do the per-(type, platform) dedup, or drop the SQL narrowing and let `sees` be the single gate (at the cost of loading extra rows). `sees` is genuinely needed for the fleet-union loop in the metadata function, so it should stay public to that call site only.

-------

`crates/database/src/artifacts.rs:495`: The "most specific per (artifact_type, platform) within a scope" rule is now implemented twice: once in `Artifact::offered`, and once inline in `get_for_version_all_matches_with_metadata`'s per-scope loop over `public_api_ids`. They are the same algorithm with different bookkeeping (owned tuple vs `&str` tuple, `Vec` vs `HashSet` of ids), which is exactly the kind of duplication that drifts — a change to specificity or dedup keying will be applied to one and not the other, and the operator view will then disagree with what the public path actually serves. Extract the dedup into one helper (e.g. `fn offered_ids(rows: &[Self], scope: Scope) -> HashSet<Uuid>`) and have `offered` and the metadata loop both call it.

-------

`crates/public-server/src/artifacts.rs:160`: The registration handler re-validates two things the layer below already owns: the blank-URL check duplicates `location()`/`NewArtifact::resting` (down to producing the same "an artifact needs a download URL" message), and the blank-then-`parse_sri` digest normalisation is copied verbatim from `private-server`'s `create_artifact`. Three copies of "a blank string is not a value" is where the messages and the error classes start to diverge. Give the digest normalisation one home (e.g. `artifacts::parse_sri_opt(Option<&str>) -> Result<Option<Vec<u8>>>`) and let `resting()` be the single authority on the resting-place rule so the handler just forwards its refusal.

-------

`crates/database/src/artifacts.rs:477`: `get_for_version_all_matches_with_metadata` still returns `Vec<(Self, bool, bool, bool)>`, and this PR makes it the sole metadata path (the deduplicated sibling was deleted) while giving the third flag a materially richer meaning — "offered in at least one resolved scope" rather than "in the deduplicated public set". Three positional bools at a crate boundary is where the caller has to consult the definition to know which is which, and the private-server mapping closure already spells them out by name to compensate. A small named struct (`ArtifactListing { artifact, is_exact, has_range_override, is_offered }`) would make the call sites self-describing and make it impossible to transpose two of them.

-------

`crates/database/src/artifacts.rs:21`: `Scope::Fleet` isn't a scope — it's "all scopes at once", and the code has to work around that. `sees()` returns `true` unconditionally for it, and `get_for_version_all_matches_with_metadata` has to reconstruct the real per-group scopes out of `Fleet` (lines 490-505) before it can say what is offered to whom. Because the operator-only mode shares one type with caller resolution, `Artifact::get_for_version(db, id, Scope::Fleet)` type-checks and silently deduplicates across groups, handing a caller every group's artifacts — the one outcome the whole feature exists to prevent, with nothing but reviewer attention stopping it. Consider splitting the operator view out (e.g. a caller-facing `Scope { Unscoped, Group(id) }` plus a separate `all_matches_for_operator()` entry point that takes no scope), so the unrestricted mode cannot be passed to a caller-facing read at all. Also worth renaming: `database::issues::Scope` already exists with a different meaning, so `use database::…::Scope` is now ambiguous at a glance.

-------

`crates/database/src/artifacts.rs:153`: The "where an artifact rests" invariant is now written out four times with three different error semantics: `NewArtifact::resting()` (400 BadRequest), `Artifact::update()`'s hand-rolled read-then-match (409 Conflict), the public-server handler's own blank-body check (`crates/public-server/src/artifacts.rs:162`, which `resting()`/`location()` would already refuse), and the SQL CHECK constraint. Two problems follow: the same rule answers 400 on create and 409 on edit, so no client can handle it uniformly; and the public-server pre-check is duplicated logic that will drift from `location()`'s trimming rules. Suggest one place owns the shape check — have `update` route through the same predicate as `resting()` and return the same error variant, and drop the handler-level blank-URL check in favour of the refusal `register` already produces.

-------

`crates/public-server/src/versions.rs:98`: `version_named` introduces a second, different meaning for `/versions/{version}` and applies it to only half the endpoints: `list_artifacts` and `download_artifact` now answer an exact version for itself (ignoring known issues), while `view_artifacts` (line 429) and `view_mobile_install` (line 570) still resolve the same path segment through `latest_matching_ready`. So `/versions/2.60.0` (HTML) and `/versions/2.60.0/artifacts` (JSON) can now disagree about which version they are describing, and the HTML page's synthesised download links point at whichever version *it* resolved. Either route both through `version_named`, or make the divergence explicit in the path/handler names so the two rules aren't silently attached to one URL shape. This is also a public behaviour change independent of group scoping — worth calling out separately from the D2 work.

-------

`crates/database/src/artifacts.rs:519`: `get_for_version_all_matches_with_metadata` loads *every* range artifact in the table (`version_range_pattern IS NOT NULL`, unfiltered by version or scope) and then `overridden_range` re-parses each of those patterns with `node_semver::Range::parse` once per matching artifact — an O(matching × all-ranges) parse loop. The extra query is also redundant: every range that could satisfy this version and be visible to this scope is already in `matching_artifacts` (the scope filter can only drop other groups' ranges, which `overridden_range` excludes anyway). This runs on every operator listing and, via `registered()`, on every `create_artifact`/`upload_artifact` response. Suggest dropping the query and passing the range subset of `matching_artifacts`, and pre-parsing each distinct pattern once into a `Vec<(pattern, Range)>` before the loop. (Related: `Version::get_by_id` is fetched here and again inside `get_for_version_all_matches`, one query more than needed.)

-------

`crates/public-server/src/versions.rs:685`: Every download of a held artifact loads the whole blob into a `Vec<u8>` (up to the 32 MiB registration cap) and re-runs SHA-256 over it inline on the async worker before any byte is written to the socket. Hashing 32 MiB is tens of milliseconds of CPU with no yield point, and the full body is resident per in-flight request, so a fleet-wide rollout where many machines pull the same reporting schema at once turns into `concurrency × 32 MiB` of heap plus a serialised hashing cost on the runtime threads — and the client waits for the hash before it sees the first byte. Consider verifying on `tokio::task::spawn_blocking` (or, better, hashing incrementally while streaming the body out and aborting the stream on mismatch), and add a concurrency cap on this route so the memory is bounded independently of request rate.

-------

`crates/private-server/src/fns/versions.rs:791`: `body.to_vec()` copies the entire upload a second time: axum has already buffered it as `Bytes` (up to `MAX_UPLOAD_ARTIFACT_BODY_BYTES` ≈ 32 MiB), `digest_of(&body)` holds it, and the copy plus diesel's bind buffer means peak RSS per upload is a multiple of the file size. There is also no concurrency limit on the route, so K simultaneous 32 MiB uploads scale linearly with no ceiling. At minimum avoid the extra copy (e.g. hand the `Bytes` through and only materialise the `Vec` at the bind site, or check `body.len()` and reject before any copy — the size check already runs first, so the copy is the only avoidable allocation left), and consider a `ConcurrencyLimitLayer` alongside the `DefaultBodyLimit` so the byte budget is bounded rather than per-request.

-------

`crates/private-server/src/fns/versions.rs:746`: `upload_artifact` is cross-site forgeable in a way the rest of the private API is not. Every other write goes through `callApi`, which sends `content-type: application/json` — a non-safelisted type that forces a CORS preflight and so blocks cross-origin POSTs. This endpoint takes all of its parameters in the query string and a raw byte body, and accepts any `Content-Type` (`Bytes` doesn't check one; `uploadApi` passes `body.type` straight through). An attacker page can therefore POST a `Blob` typed `text/plain` to `/api/versions/upload_artifact?...` from an operator's browser with no preflight, and the tailnet identity headers the proxy injects supply the admin credential — writing group-scoped bytes that Canopy then serves to that group's machines. The response isn't readable, but the write lands. Guard it server-side: reject requests whose `Content-Type` is CORS-safelisted (`text/plain`, `application/x-www-form-urlencoded`, `multipart/form-data`), or require a custom header the SPA sets (e.g. `x-canopy-fetch: 1`), which forces a preflight for any cross-origin caller.

-------

`crates/public-server/src/versions.rs:726`: The unscoped-artifact path still proxies an arbitrary stored URL with no scheme or destination restriction, and this change adds validation next to it (blank-URL refusal, digest parsing) without closing it. `download_url` is free text from `create_artifact` / the releaser `POST /artifacts/...` body, and `reqwest`'s default client follows up to 10 redirects, so a registration of `http://169.254.169.254/latest/meta-data/iam/security-credentials/` — or any internal address reachable from the public server — turns this endpoint into a request forwarder that streams the response body back to an *unauthenticated* caller (the route takes `Option<AuthDevice>`). Restrict the fetch to `https` (the SPA already assumes it: it only renders `download_url` as a link when it starts with `https://`), reject private/link-local resolution, and set `redirect::Policy::none()` or a policy that re-checks each hop.


ALTER TABLE artifacts DROP CONSTRAINT artifacts_type_platform_version_id;

CREATE UNIQUE INDEX artifacts_identity

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bugs & Correctness] critical

CREATE UNIQUE INDEX artifacts_identity ... NULLS NOT DISTINCT is created with no dedup step, but the schema it replaces permitted exactly the duplicates it now forbids. The old constraint was UNIQUE (artifact_type, platform, version_id), and the range-registration path in public-server::artifacts::create was a plain INSERT with version_id = NULL — so every repeat registration of a range artifact (e.g. a releaser publishing 2.60.x/installer/windows twice) inserted another row, and the migration's own comment acknowledges range rows had "no uniqueness at all". On any database that has ever taken two registrations of the same range/type/platform, this CREATE UNIQUE INDEX fails and the whole migration (and deploy) aborts, with only a hand-fix on the box as recourse. Add a dedup before the index, keeping the newest row per identity, e.g. DELETE FROM artifacts a USING artifacts b WHERE a.artifact_type = b.artifact_type AND a.platform = b.platform AND a.version_id IS NOT DISTINCT FROM b.version_id AND a.version_range_pattern IS NOT DISTINCT FROM b.version_range_pattern AND a.group_id IS NOT DISTINCT FROM b.group_id AND (a.created_at, a.id) < (b.created_at, b.id); (or a row_number() variant), and consider a test that seeds duplicate range rows before running up.sql.

// Try to parse as a specific version first
if let Ok(semver) = SemverVersion::parse(&version) {
// It's a specific version (e.g., "1.0.5")
let (version_id, version_range_pattern) = if let Ok(semver) = SemverVersion::parse(&version) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bugs & Correctness] suggestion

The draft version is created before the body is validated as a location. resting() (inside ArtifactRow::register, line 199) is what refuses a blank/whitespace body, but by then the exact-version branch has already inserted a draft versions row for a version that got no artifact. So POST /artifacts/9.9.9/installer/windows with an empty body answers 400 while leaving a stray draft 9.9.9 in the version list, and repeating the mistake for other versions litters it further. The existing test doesn't catch this because it seeds 2.60.0 first. Validate the body up front (the same location()-style trim/emptiness check, alongside the parse_sri_opt call above) so the refusal happens before anything is written.

artifact_type: type,
platform,
group_id: groupId,
digest: await digestOf(file),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bugs & Correctness] suggestion

await digestOf(file) runs inside the try whose catch is empty with the comment /* surfaced via action.error */ (line 714) — but if digestOf throws, no API hook was ever called, so upload.error is null and pending never set. The operator clicks Create and absolutely nothing happens: no error, no spinner, no row. This is reachable in practice: File.arrayBuffer() rejects when the picked file has since been moved or truncated on disk, and crypto.subtle is undefined outside a secure context, so an operator reaching the SPA over plain http:// on a tailnet host gets a silent TypeError (the e2e suite runs on http://localhost, which is a secure context, so it can't catch this). Catch the digest failure separately and surface it, e.g. set fileError to the thrown message before the upload call.

)));
}

let mut conn = state.db.get().await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Performance] suggestion

upload_artifact takes a pool connection before it does any of the expensive/rejectable work: the size check (767), the SRI parse, and digest_of(&body) (775) — a SHA-256 over up to 32 MiB — all run while holding it. The write pool defaults to max_open = 5 (crates/database/src/lib.rs:115), so a handful of concurrent uploads can pin the whole pool for tens of milliseconds each of pure CPU, and an oversized upload burns a connection only to be refused. Hash and validate first, then state.db.get() immediately before Artifact::register.

}

let claimed = parse_sri(&named.digest)?;
let digest = digest_of(&body);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Performance] suggestion

digest_of(&body) hashes up to 32 MiB inline on the async worker, with no await in it — exactly the case the download path deliberately moves off the runtime (crates/public-server/src/versions.rs:689, "tens of milliseconds with no await in it"). Uploads should use the same tokio::task::spawn_blocking treatment, otherwise a few concurrent uploads stall unrelated requests on the same worker threads.

Comment thread crates/public-server/src/versions.rs Outdated
// the same type and platform is registered. An artifact this caller may not
// see is missing in exactly the way one that never existed is.
// spec: ART#who-is-offered-a-group-scoped-artifact
let artifacts = ArtifactRow::get_for_version_all_matches(&mut db, version.id, scope).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Performance] suggestion

download_artifact issues two queries to fetch one artifact. get_for_version_all_matches selects version_id = X OR version_range_pattern IS NOT NULL, so it pulls every range artifact in the table regardless of version, node_semver-parses each pattern in Rust, sorts the whole set by specificity, and all of that just to confirm one UUID is visible — then content_for re-queries the same row by id for the bytes. This is on the hot download path that every fleet machine hits, and the first query's cost grows with the total number of range artifacts ever registered, not with the version. A single filter(id.eq(artifact_uuid)) query selecting the scope predicate plus (version_id, version_range_pattern, content, content_type, digest), with the range check applied to that one row, would be one round trip and O(1) rows scanned.

let all_artifacts: Vec<Self> = table.select(Self::as_select()).load(db).await?;
let public_api_ids: std::collections::HashSet<Uuid> = scopes
.into_iter()
.flat_map(|scope| Self::offered_ids(&matching_artifacts, scope))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Performance] suggestion

For Scope::Fleet the offered-id computation is O(artifacts × distinct groups): offered_ids walks the entire matching_artifacts slice and allocates a fresh HashSet for every scope, and the scope list is one entry per distinct group present. Since each group contributing a scope also contributes at least one artifact, this is quadratic in the number of group-scoped artifacts on a version — a fleet with a few hundred groups each holding a reporting schema turns an operator listing into hundreds of full passes. A single pass over the sorted set inserting into a HashSet<(&str, &str, Option<Uuid>)> keyed by (type, platform, owning-scope) gives the same answer in linear time, since the sort already puts each scope's most specific artifact first.

version_range_pattern,
group_id,
))
.do_update()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] suggestion

register's upsert changes the trust properties of the public releaser endpoint. Before this change the unique constraint on (artifact_type, platform, version_id) made re-registering an existing exact artifact a hard error; now do_update overwrites download_url, digest, device_id and run_id of whatever row is already there. So any device holding a releaser credential can silently repoint the download URL of an already-published release artifact registered by a different device, and — because digest.eq(&input.digest) is unconditional — a registration that simply omits ?digest= clears a previously recorded digest, turning off verification for every client that would have checked it, with no error and no trace (the original registrant's device_id is overwritten too). Note the private Artifact::update path deliberately only drops the digest when the location actually moved; the register path has no equivalent guard. If replacement is intended, consider at minimum keeping the recorded digest when the incoming registration names no digest and the URL is unchanged, and recording that a replacement happened so the provenance of the original registration is not lost.

/// Base URL for absolute links Canopy emits about itself. Prefers the
/// configured `PUBLIC_URL`; otherwise reconstructs the origin from the
/// request's forwarded scheme and `Host` header so local and test runs still
/// emit well-formed links.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] suggestion

public_base_url falls back to the unvalidated Host and x-forwarded-proto request headers when PUBLIC_URL is unset. That fallback used to feed only RSS <link> elements (feed_base_url); this change makes it build the download_url that fleet machines actually fetch artifacts from (Artifact::offered). A poisoned Host — via an upstream cache, or any proxy that forwards the client's value — turns the listing into a set of attacker-hosted download locations, and held artifacts carry no digest in the public response for the client to check against. Since these links point at Canopy itself, prefer emitting a path-relative URL, or require PUBLIC_URL (or an allowlist of known hosts) for the artifact URLs rather than reconstructing the origin from request headers.

@review-hero

review-hero Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 5)
9 agents reviewed this PR | 1 critical | 8 suggestions | 1 nitpick | Filtering: consensus 3 voters, 4 below threshold

Below consensus threshold (4 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/database/src/artifacts.rs:189 Bugs & Correctness suggestion resting() checks that a group-scoped registration carries content and a digest, but never that the digest actually describes the content — the (true, false) arm only tests is_none() and the...
crates/database/src/artifacts.rs:479 Bugs & Correctness suggestion The same operator mistake gets two different statuses depending on which endpoint it arrives at. resting() (line 179) answers "an artifact needs a download URL or a group" with `AppError::BadRequ...
crates/private-server/src/fns/versions.rs:803 Performance nitpick Vec::from(body) on an axum Bytes that was aggregated from multiple body chunks cannot reclaim the allocation, so this copies the full artifact (up to 32 MiB) a second time on top of the buffer ...
crates/public-server/src/versions.rs:685 Performance suggestion The held-artifact download keeps its pool connection (db, taken at 667) alive across content_for, the blocking re-hash, and the whole 32 MiB response construction, while the bytes are buffered ...

Nitpicks

File Line Agent Comment
crates/private-server/src/fns/versions.rs 484 Performance ServerGroup::names_by_id loads every group in the fleet, archived included, on every artifact listing, purely to resolve the handful of group_ids actually present. Worse, registered() re-runs the whole of artifacts_of — full match set, metadata, and the complete group-name map — after eac...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`migrations/2026-09-06-211612-0000_group_scoped_artifacts/up.sql:46`: `CREATE UNIQUE INDEX artifacts_identity ... NULLS NOT DISTINCT` is created with no dedup step, but the schema it replaces permitted exactly the duplicates it now forbids. The old constraint was `UNIQUE (artifact_type, platform, version_id)`, and the range-registration path in `public-server::artifacts::create` was a plain `INSERT` with `version_id = NULL` — so every repeat registration of a range artifact (e.g. a releaser publishing `2.60.x`/`installer`/`windows` twice) inserted another row, and the migration's own comment acknowledges range rows had "no uniqueness at all". On any database that has ever taken two registrations of the same range/type/platform, this `CREATE UNIQUE INDEX` fails and the whole migration (and deploy) aborts, with only a hand-fix on the box as recourse. Add a dedup before the index, keeping the newest row per identity, e.g. `DELETE FROM artifacts a USING artifacts b WHERE a.artifact_type = b.artifact_type AND a.platform = b.platform AND a.version_id IS NOT DISTINCT FROM b.version_id AND a.version_range_pattern IS NOT DISTINCT FROM b.version_range_pattern AND a.group_id IS NOT DISTINCT FROM b.group_id AND (a.created_at, a.id) < (b.created_at, b.id);` (or a `row_number()` variant), and consider a test that seeds duplicate range rows before running `up.sql`.

-------

`crates/public-server/src/artifacts.rs:165`: The draft version is created before the body is validated as a location. `resting()` (inside `ArtifactRow::register`, line 199) is what refuses a blank/whitespace body, but by then the exact-version branch has already inserted a draft `versions` row for a version that got no artifact. So `POST /artifacts/9.9.9/installer/windows` with an empty body answers 400 while leaving a stray draft 9.9.9 in the version list, and repeating the mistake for other versions litters it further. The existing test doesn't catch this because it seeds 2.60.0 first. Validate the body up front (the same `location()`-style trim/emptiness check, alongside the `parse_sri_opt` call above) so the refusal happens before anything is written.

-------

`private-web/src/routes/VersionDetail.tsx:694`: `await digestOf(file)` runs inside the `try` whose `catch` is empty with the comment `/* surfaced via action.error */` (line 714) — but if `digestOf` throws, no API hook was ever called, so `upload.error` is null and `pending` never set. The operator clicks Create and absolutely nothing happens: no error, no spinner, no row. This is reachable in practice: `File.arrayBuffer()` rejects when the picked file has since been moved or truncated on disk, and `crypto.subtle` is `undefined` outside a secure context, so an operator reaching the SPA over plain `http://` on a tailnet host gets a silent `TypeError` (the e2e suite runs on `http://localhost`, which *is* a secure context, so it can't catch this). Catch the digest failure separately and surface it, e.g. set `fileError` to the thrown message before the upload call.

-------

`crates/private-server/src/fns/versions.rs:765`: `upload_artifact` takes a pool connection before it does any of the expensive/rejectable work: the size check (767), the SRI parse, and `digest_of(&body)` (775) — a SHA-256 over up to 32 MiB — all run while holding it. The write pool defaults to `max_open = 5` (`crates/database/src/lib.rs:115`), so a handful of concurrent uploads can pin the whole pool for tens of milliseconds each of pure CPU, and an oversized upload burns a connection only to be refused. Hash and validate first, then `state.db.get()` immediately before `Artifact::register`.

-------

`crates/private-server/src/fns/versions.rs:775`: `digest_of(&body)` hashes up to 32 MiB inline on the async worker, with no await in it — exactly the case the download path deliberately moves off the runtime (`crates/public-server/src/versions.rs:689`, "tens of milliseconds with no await in it"). Uploads should use the same `tokio::task::spawn_blocking` treatment, otherwise a few concurrent uploads stall unrelated requests on the same worker threads.

-------

`crates/public-server/src/versions.rs:679`: `download_artifact` issues two queries to fetch one artifact. `get_for_version_all_matches` selects `version_id = X OR version_range_pattern IS NOT NULL`, so it pulls *every* range artifact in the table regardless of version, `node_semver`-parses each pattern in Rust, sorts the whole set by specificity, and all of that just to confirm one UUID is visible — then `content_for` re-queries the same row by id for the bytes. This is on the hot download path that every fleet machine hits, and the first query's cost grows with the total number of range artifacts ever registered, not with the version. A single `filter(id.eq(artifact_uuid))` query selecting the scope predicate plus `(version_id, version_range_pattern, content, content_type, digest)`, with the range check applied to that one row, would be one round trip and O(1) rows scanned.

-------

`crates/database/src/artifacts.rs:559`: For `Scope::Fleet` the offered-id computation is O(artifacts × distinct groups): `offered_ids` walks the entire `matching_artifacts` slice and allocates a fresh `HashSet` for every scope, and the scope list is one entry per distinct group present. Since each group contributing a scope also contributes at least one artifact, this is quadratic in the number of group-scoped artifacts on a version — a fleet with a few hundred groups each holding a reporting schema turns an operator listing into hundreds of full passes. A single pass over the sorted set inserting into a `HashSet<(&str, &str, Option<Uuid>)>` keyed by (type, platform, owning-scope) gives the same answer in linear time, since the sort already puts each scope's most specific artifact first.

-------

`crates/private-server/src/fns/versions.rs:484`: `ServerGroup::names_by_id` loads every group in the fleet, archived included, on every artifact listing, purely to resolve the handful of `group_id`s actually present. Worse, `registered()` re-runs the whole of `artifacts_of` — full match set, metadata, and the complete group-name map — after each `create_artifact`/`upload_artifact` just to echo back the single row that was written. Filtering the name lookup to the ids in the result set (or resolving just the one group on the registration path) avoids scanning the groups table twice per write.

-------

`crates/database/src/artifacts.rs:426`: `register`'s upsert changes the trust properties of the public releaser endpoint. Before this change the unique constraint on `(artifact_type, platform, version_id)` made re-registering an existing exact artifact a hard error; now `do_update` overwrites `download_url`, `digest`, `device_id` and `run_id` of whatever row is already there. So any device holding a releaser credential can silently repoint the download URL of an already-published release artifact registered by a different device, and — because `digest.eq(&input.digest)` is unconditional — a registration that simply omits `?digest=` clears a previously recorded digest, turning off verification for every client that would have checked it, with no error and no trace (the original registrant's `device_id` is overwritten too). Note the private `Artifact::update` path deliberately only drops the digest when the location actually moved; the register path has no equivalent guard. If replacement is intended, consider at minimum keeping the recorded digest when the incoming registration names no digest and the URL is unchanged, and recording that a replacement happened so the provenance of the original registration is not lost.

-------

`crates/public-server/src/versions.rs:199`: `public_base_url` falls back to the unvalidated `Host` and `x-forwarded-proto` request headers when `PUBLIC_URL` is unset. That fallback used to feed only RSS `<link>` elements (`feed_base_url`); this change makes it build the `download_url` that fleet machines actually fetch artifacts from (`Artifact::offered`). A poisoned `Host` — via an upstream cache, or any proxy that forwards the client's value — turns the listing into a set of attacker-hosted download locations, and held artifacts carry no digest in the public response for the client to check against. Since these links point at Canopy itself, prefer emitting a path-relative URL, or require `PUBLIC_URL` (or an allowlist of known hosts) for the artifact URLs rather than reconstructing the origin from request headers.

@dannash100
dannash100 requested a review from passcod September 14, 2026 00:26
Comment thread .workhorse/specs/platform/artifacts.md Outdated
Comment thread .workhorse/specs/platform/artifacts.md Outdated
Comment thread ERRORS.md Outdated
Comment thread ERRORS.md Outdated
Comment thread crates/commons-servers/src/device_auth/mod.rs Outdated
Comment thread crates/database/src/artifacts.rs Outdated
Comment thread crates/database/src/artifacts.rs
Comment thread crates/database/src/artifacts.rs Outdated
Comment thread crates/public-server/Cargo.toml
Comment thread crates/public-server/src/artifacts.rs
@dannash100

Copy link
Copy Markdown
Contributor Author

🤖 Follow-up: moving held artifact content out of Postgres and into an S3 bucket, per the note on content BYTEA. Fine at reporting-schema sizes, but it is the same shape that left Tamanu with hundreds of GB in the database, so it wants a card rather than being left to grow. Not in this PR.

@dannash100
dannash100 requested a review from passcod September 15, 2026 01:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants