mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-28 21:15:33 +00:00
9dbc53b6705d3aff4dd0bc34a433fc6c9c4ae4a7
550
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9dbc53b670 |
appview: stop serving service tokens past their expiry, and challenge the client when the hold rejects one
Seen in production on 2026-09-11: three cold pulls of a 22-layer image failed with BLOB_UNKNOWN for layers that exist. The hold had answered 403 "service token authentication failed: token has expired", and the same blobs served fine a minute later. Three things lined up. The registry middleware's validation cache kept a fetched service token for a flat 45 seconds regardless of its real remaining life, so a token fetched with 12 seconds left was still handed to the hold half a minute after it died. The registry JWT is stamped from the auth cache's expiry, which trailed the real exp by only 10 seconds, while distribution accepts a JWT for 60 seconds past its exp, so a client could hold an accepted JWT for most of a minute after the credential behind it was gone. And the hold's 403 was flattened to BLOB_UNKNOWN, so the client failed instead of re-authenticating. Now the validation cache bounds an entry by the token's exp minus a shared ServiceTokenSafetyMargin of 60 seconds, the same margin the auth cache and the JWT stamp use, chosen to equal distribution's leeway so the last instant a JWT is accepted is the service token's real exp. A PDS that grants less than the margin gets half its remaining life instead of an already-past deadline. When the hold rejects the service token as expired or missing, the appview drops both cached copies and returns a 401 challenge so Docker and crane re-run the token dance and retry; a genuine permission denial stays a 403, and a hold that is down still maps to blob unknown. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EvFJr4Dwz8p2NDAeXmgmBt |
||
|
|
2a94f924af |
appview: memoize blob presigns within a request
A blob GET cost three hold calls, not two. distribution's blob handler calls Stat then ServeBlob, and its notifications listener (installed unconditionally, endpoints or not) calls Stat a third time after ServeBlob to build the pull event. Each Stat presigned method=HEAD and ServeBlob presigned the request method, so a p90 pull of 22 layers was 66 hold getBlob calls, 44 of them HEAD presigns whose URL was discarded. ProxyBlobStore is built once per request (the sync.Once in RoutingRepository.Blobs), so a per-instance memo keyed by digest+method is request-scoped. Stat reads the request method from the context and presigns for GET or HEAD accordingly (anything else, including the push existence check and manifest verification, still presigns HEAD, which the hold's read path requires). ServeBlob and the listener's second Stat then hit the memo. S3 signs the HTTP verb, so the key includes the method: a HEAD URL cannot serve a GET. Only successful presigns are cached, so error semantics are unchanged. The missing-size fallback (older hold) now probes with a HEAD-signed URL rather than GETting the blob body when Stat presigned for GET. Measured with TestBenchRealImages: p90 pull drops from 67 hold calls to 23, one per blob, and wall time at PDS 50ms / hold 5ms / S3 20ms drops 23%. Push counts are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WTdBxLFU5TpwmqVdVsN1wq |
||
|
|
bf4e63e810 |
test: production-shaped push/pull benchmark with per-backend request counts
TestBenchRealImages pushes and pulls three images whose layer sizes are
copied from real manifests in the production appview database (the median,
p75 and p90 images by layer count) and reports, per operation, wall time and
the number of requests to the registry, the fake PDS, the hold and S3, broken
down by endpoint. Skipped unless BENCH_PROFILES is set, so the integration
target does not run it. BENCH_LAT_{PDS,HOLD,S3} inject per-request latency,
which is what makes byte-path changes visible in-process; request counts are
the reliable signal either way.
internal/reqcount counts and delays requests through a handler wrapper and a
client-side RoundTripper. testharness.WithBackendTap wraps the PDS and S3
handlers and puts a counting reverse proxy in front of the hold;
testpds.WithMiddleware is the hook that makes the PDS side possible.
The bench showed a pull costs three hold calls per blob, not two: distribution
installs its notifications listener unconditionally and it re-Stats every blob
after ServeBlob to build the pull event. The backlog's presign memoization
item is rewritten with the measured numbers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTdBxLFU5TpwmqVdVsN1wq
|
||
|
|
65db7945b2 |
appview: drop the testmode fall-back-to-default-hold probe
The registry middleware used to GET the user's hold's /.well-known/did.json on every registry request in testmode builds, and fall back to the appview default hold if it did not answer. The fallback only ever changed anything when the user's chosen hold differed from the default AND was down; in local development and in the integration harness the two are the same hold, so the probe's answer was discarded every time. In the production-shaped benchmark it was 94 of 150 hold requests per p90 push and 23 of 90 per pull, hiding the real hold traffic behind a testmode artifact. Remove isHoldReachable, the fallbackUnreachable field, and the probe branch. An empty choice still means the default hold. Testmode and production now take the same path here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WTdBxLFU5TpwmqVdVsN1wq |
||
|
|
dcee8f6a62 |
deps: upgrade every module; gate loopback OAuth tests on testmode
go get -u across the root, scanner, and deploy modules, then tidy. The credential helpers pin atcr.io v0.1.4 for standalone go install and are left alone (go work sync tried to strip that pin; reverted). Direct upgrades in the root: indigo 20260901 to 20260903, aws-sdk-go-v2 core 1.45.1 to 1.47.0 with config, credentials, and s3 alongside, x/crypto 0.55 to 0.57, x/net, x/sync, x/sys, x/image, klauspost/compress 1.20, go-containerregistry 0.22.1, goldmark 1.8.6, regclient 0.11.6 (pinned only by the integration-tagged package, so the bulk upgrade skipped it). Scanner and deploy had no direct updates; their indirect sets moved. The indigo delta is a hardening series: identity.DefaultDirectory and oauth.NewClientApp now carry an SSRF-guarded transport that refuses loopback and private ranges, did:web and well-known bodies are size capped, all auth-server endpoints must be HTTPS URLs, and MST decoding validates PrefixLen on untrusted nodes. Production is unaffected. The testmode seam in pkg/atproto absorbs the rest: a probe confirmed an untagged build now refuses 127.0.0.1 with indigo's unsafe-address error and a tagged build dials through. Two OAuth tests drove the real client against httptest servers on loopback and failed untagged after the bump; three siblings in the same fixtures passed only because the refused dial happened to satisfy a "transient error" assertion. All five, with their fixtures and fake stores, move under //go:build testmode in sibling files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ |
||
|
|
0080957a21 |
remove the runtime test_mode switch; the testmode build tag is the only one
server.test_mode survived the build-tag refactor only to feed five behavioral branches: the registry's fall-back to the default hold when the user's hold is unreachable, backfill warning suppression for external holds, the appview listener close on shutdown, the hold's relay-crawl skip, and the hold's appview-issuer tolerance. Every one of them is a "this is a local development build" decision, which is what the tag already says, and local development has to build with the tag or nothing resolves. So they read atproto.TestModeBuild now, and the flag, SetTestMode, IsTestMode, the middleware option, the backfill constructor parameter, the never-read field on RemoteHoldAuthorizer, the example and template YAML lines, and the docker-compose env vars are gone. The registry keeps the fallback as a field seeded from the constant so the production-path tests can pin it off under the tag. The 24 SetTestMode calls in tests were dead already: stripping them and running the affected packages tagged changed nothing. Tests that resolve a loopback did:web used to t.Fatal naming the tag, which left a bare `go test ./...` permanently red in five packages. They now live under `//go:build testmode`: whole-file constraints where every test needs it, and sibling *_testmode_test.go files holding the moved tests plus their fixtures where a file mixed. The harness carries the constraint too, with its package doc in an untagged doc.go so the package still exists without it. An untagged run compiles those tests out and passes; make test keeps the tag and runs everything. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ |
||
|
|
d8643ee03a |
config: describe what test_mode still controls
The comments on server.test_mode predated the testmode build tag and still claimed the flag allows HTTP DID resolution (appview) or changes OAuth redirects (hold). Neither is true: DID resolution is decided at build time now, and the hold's OAuth redirect never read the flag. Say what remains behind the runtime switch on each side and point at the build tag for the rest. Only the comment lines in the example YAMLs are updated; the examples carry hand-edited values and are not regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ |
||
|
|
a01b08b924 |
atproto: gate local indigo behavior behind a testmode build tag
indigo's identity directory refuses HTTP and IP-hosted did:web, and its OAuth client is growing an SSRF-guarded transport that refuses loopback and private addresses. Local development and the test suites need both, and the workarounds were scattered: two did:web fallbacks in the resolver, a hand-rolled appview key fetch on the hold, and the OAuth client left on indigo's defaults so any test driving it against an httptest server depended on the transport staying permissive. Move every departure from indigo's defaults into one file pair in pkg/atproto: indigo_prod.go (!testmode) returns indigo's directory and OAuth client unchanged; indigo_local.go (testmode) wraps the directory so a did:web naming an IP, localhost, or a host with a port resolves over plain HTTP, and gives the OAuth client plain HTTP clients. All six identity and OAuth constructor call sites go through NewDirectory and NewOAuthClientApp. The resolver fallbacks, DIDWebToURL, and the hold's scheme-guessing key fetch are gone; the hold resolves the appview key through the directory, preferring #appview, and purges and retries once on a signature failure so a re-keyed appview is not masked by the 24-hour cache. There is no runtime switch for this: a production binary cannot be configured to resolve local DIDs. The runtime test_mode flag still gates the remaining behavioral branches only. Tests, the harness, make dev, Air, Dockerfile.dev, and docker-compose build with the tag; fixtures that need loopback did:web fail fast naming it. Test hold servers now serve a did.json via pkg/testpds so they resolve as real holds under the tag. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ |
||
|
|
7b67b3b383 |
appview: mark repo cards translate="no"
Repository cards hold user-written handles, names, descriptions and image references. Browser translators (Chrome, Edge, Safari, and Firefox once a translation is accepted) honor the translate attribute on elements below the root, so the card contents stay as written while the rest of the page translates. This does not suppress the browser's translate offer, which is a page-level decision driven by language detection. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011tf8SRvJiWHWkVcfbPUnDC |
||
|
|
16375ce302 |
appview: name uploads with a UUID, not the nanosecond clock
ProxyBlobStore.Create named every writer fmt.Sprintf("upload-%d",
time.Now().UnixNano()) and used that as its key in globalUploads. The
nanosecond clock is not a unique source: Go's wall clock is coarser than
the spacing between goroutines, so two uploads opened at the same instant
read the same value. On this box, two goroutines released together
collided about 18% of the time (tsc clocksource).
That is reachable on every multi blob push, because Docker and crane POST
the config blob and several layers concurrently. When it happened the
second writer silently replaced the first in the map, both clients' PATCHes
resumed the same writer, and distribution rejected the second with a 416
"upload resumed at wrong offset: N != 0", which the client surfaces as
RANGE_INVALID: invalid content range. It showed up in 3 of 8 benchmark runs
with an instrumented Create: identical IDs, startedAt values 10 to 60ns
apart. The line predates the recent upload path work.
Writer IDs now come from newWriterID(), a package level func var returning
"upload-" + uuid.NewString(). google/uuid is already a direct dependency of
the module, so no new one is added, and the UUID's hex and hyphens keep the
ID URL safe: it travels in the upload URL and inside distribution's _state
token. The "upload-" prefix is kept because the existing ID test asserts it.
Create now also refuses to overwrite an occupied key, under globalUploadsMu,
rather than evicting the sitting writer. With a UUID a hit cannot be chance,
so failing loudly beats stranding a client that is midway through a layer.
The mock hold server's test upload ID moves to a UUID for the same reason.
Tests: TestCreate_ConcurrentIDsAreUnique releases three Creates from a
shared barrier over 300 rounds and asserts every ID is distinct and every
Create lands its own entry in globalUploads. That one does not reliably
fail against the old code, since the mock path desynchronises the
goroutines, so TestCreate_RefusesDuplicateID covers the guard directly by
overriding newWriterID to hand back an ID that is already taken, and
asserts Create errors and leaves the original writer in the map. Confirmed
it fails with the guard removed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvFJr4Dwz8p2NDAeXmgmBt
|
||
|
|
831b7757bf |
appview: never block a writer on budget for its second buffer
Since |
||
|
|
56bde61555 |
appview: keep one part upload in flight while the next buffer fills
A large layer went up strictly one step at a time: fill 16MB from the client, stop reading, fetch a part URL and PUT the part to S3, reset, resume reading. While the part was in flight Docker sat on a full TCP window; while the buffer filled S3 sat idle. Wall clock was receive time plus send time. The writer now hands a full buffer to a goroutine that does the hold call and the PUT, and keeps filling a second buffer from the client. When that one fills it waits for the previous part, takes its buffer back, and hands the new one off. At most one part is in flight, so part numbers and ETags stay ordered, and a blob that never fills a buffer never allocates the second one. No network runs under the writer lock on the happy path. Peak memory for a large upload is now two buffers, 32MB. Both are charged to the process budget through the existing accounting, the second as it grows, and the budget floor rises to match so a large upload can never be refused outright. The 512MB default holds sixteen. A failed part records a sticky error, closes the writer, and aborts the multipart from the goroutine that still holds the upload ID; the next Write, hand-off, or Commit reports the cause. Commit verifies the digest first, then waits for the flight, sends the final part, and completes. Cancel waits for the flight, bounded, before aborting so the abort cannot overtake a PUT that has not yet been issued its upload ID. The sweeper refuses to reap a writer with a part in flight, since last activity is only stamped when a part lands. Tests observe the overlap directly: the fake S3 blocks the first PUT and the second buffer's writes are asserted to return before it is released, while the third buffer's writes block. Also covered: the one-part-late error, Commit waiting, Cancel during flight, peak budget, the sweeper, and concurrent Cancel and Write under the race detector. The integration suite passed with a 72MB layer pushed through the pipeline by three clients. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5 |
||
|
|
7045e84c00 |
appview: read the sailor profile from the local users row, not the PDS, per request
Hold discovery in the registry middleware called getRecord on the repository owner's PDS for every request under /v2/: every HEAD, POST, PATCH, PUT and GET. A 10-layer push was 40 or more PDS round trips, and it was the last per-request network call on the push path that had nothing to do with moving bytes. Only two profile fields are used there: the default hold and the auto-remove-untagged flag. The users row already caches the default hold, written by the Jetstream processor on every profile event and prefilled by the backfill, and the auth gate already reads it from there. This makes the row a faithful copy of what the registry needs and switches the middleware to it. The auto-remove flag gets a nullable users column. NULL means the value has never been learned; the processor writes 0 or 1 on every profile event and never NULL. On a request whose row is missing or still NULL, the middleware does one live fetch, uses it, and writes both fields back, including a 0 for a user with no profile at all, so the fallback runs at most once per user. A failed fetch writes nothing and uses the appview default for that request, so a network error is never cached. That single mechanism covers the minutes after a deploy while the startup backfill fills the column, a brand-new user, and a user the backfill has not reached. The processor also stops returning early on an empty default hold, which left a user who removed their custom hold pushing to it forever. Empty is now written through and means the appview default, matching what the auth gate already reads. Tests count PDS requests with a test server: a populated row makes none, a NULL row makes exactly one and then none, a missing profile is cached as known, and a failed fetch degrades without writing. The migration was applied to a fresh database and to one built from the previous schema. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5 |
||
|
|
47a107058d |
appview: bound upload buffer memory, reap abandoned uploads, and pin the flush boundary
Each in-flight blob upload buffers up to 16MB, Docker pushes five layers at once per client, and nothing bounded the total. Writers also lived in the package-level map forever: a client that died mid-push left its writer, its buffer, and any hold-side S3 multipart session behind with no expiry. A process-wide budget (golang.org/x/sync semaphore, default 512MB, server.upload_buffer_budget_mb) now caps memory held in upload buffers. A writer charges its buffer's projected backing capacity before growing, so a config blob costs kilobytes and a full writer costs exactly one buffer, and releases once, on Commit, Cancel, or reap. A write that needs budget waits on the request's context with a five minute cap, outside the writer's lock so Cancel and the sweeper cannot queue behind it; that wait is backpressure on the client. The budget is clamped to at least one buffer so a single upload can never deadlock. A sweeper started with the other appview workers reaps writers idle past server.upload_idle_timeout (default 1h), aborting the hold-side multipart on a detached context and releasing the budget. It measures inactivity, not age, so a slow push is never reaped, and it skips a writer whose lock is held so it cannot race a live part upload. Write also gains a fix the budget made visible. It appended a whole chunk and checked afterwards, so the last chunk before a flush could land a few bytes past 16MB, which did not fit the backing array; bytes.Buffer doubled it to 32MB and Reset kept that for the rest of the upload. Only chunk sizes that tile 16MB exactly avoided it, and the network read loop promises no such thing. Every large layer could hold 32MB while the budget charged 16. Write now fills to exactly the threshold, flushes, and continues with the remainder, so capacity is pinned at 16MB for any chunk size, every part is exactly one buffer, and a single oversized Write streams through as parts instead of buffering whole. The test streams 24KB chunks across the boundary and fails against the old code with cap 33554432. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5 |
||
|
|
f4343d7956 |
appview: upload small blobs with one presigned PUT, and verify every digest
Every blob went through the multipart machinery: an S3 multipart started on Docker's initial POST, a hold round trip per part, and a complete on the hold that finished the multipart, HEADed the temp object, copied it to its final key, and deleted the temp. For a 2KB config blob that was three hold calls and six S3 operations. On production data 86% of distinct layers and every config blob fit in a 16MB buffer, and 49% of image manifests have no layer larger than that. The writer now buffers up to 16MB (also the multipart part size) and makes no hold call until it has to. A blob that never overflows the buffer is written at Commit with a single presigned PUT to its final key, via the hold's existing method=PUT presign; the multipart only starts on the first flush. The hold's completeUpload does nothing the direct path skips: quota, layer records, stats and scan dispatch all hang off notifyManifest, which is unchanged. The buffer starts empty and grows on demand, with the doubling capped so capacity never overshoots 16MB: a config blob costs kilobytes, and only layers that approach the threshold fill it. Bytes are hashed as they arrive. Commit compares the computed sha256 to the digest the client claimed before any network call, and returns DIGEST_INVALID on mismatch, aborting a multipart if one was started. Previously nothing verified the content, so a pusher could store wrong bytes under a digest in the shared content-addressed space. Tests observe request counts on a fake hold and fake S3 rather than return values. The growth test streams in 24KB chunks because power-of-two chunks land on 16MB by luck and hid an earlier weaker guard. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5 |
||
|
|
034ea5988b |
hold: report blob size on read presigns so the appview can skip its S3 HEAD
distribution calls Stat before every blob GET and HEAD. The appview's Stat asked the hold for a presigned HEAD URL and then HEADed S3 with it purely to read Content-Length for the descriptor: two round trips to learn one number. The hold's getBlob response for OCI digests on GET and HEAD now carries "size". It comes from the records index when a layer record exists (a SQLite lookup on a new digest index, no network) and from a HeadObject otherwise, which is where config blobs land. If storage says the object does not exist the hold answers 404 instead of signing a URL that can only fail. The PUT and ATProto CID paths are untouched. The appview builds the descriptor from the reported size and makes no S3 request. When the field is absent it HEADs the presigned URL as before, so a new appview works against a hold that has not been upgraded, and an old appview ignores the extra field. A hold 404 maps to ErrBlobUnknown. Tests prove the index answered by leaving the mock bucket empty and counting zero HeadObject calls, prove the fallback with exactly one, and count requests reaching the fake S3 origin on the appview side rather than trusting the returned size. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5 |
||
|
|
61a934debb |
hold: look crew members up by rkey instead of walking the collection
ValidateBlobWriteAccess, ValidateBlobReadAccess, ValidateOwnerOrCrewAdmin and getCrewTier each listed every crew record to find one member: open a carstore session, walk the MST, CBOR-decode each record, compare DIDs. That ran on every multipart call from the appview, including the part URL request for every 10MB, and on every getBlob presign, so on a hold with hundreds of crew each part cost hundreds of decodes. lookupCrewMember tries the deterministic rkey first (one record read) and only falls back to the walk on a not-found miss. The fallback is required: records created before the hash-rkey scheme sit at a TID rkey, and the boot-time migration that rekeyed them only existed between |
||
|
|
9228579b07 |
appview: share one HTTP transport across blob proxy requests
NewProxyBlobStore built a fresh http.Client and http.Transport on every
call, and it is called once per registry request because the routing
repository is created per request. So no connection to the hold or to a
presigned S3 URL was ever reused: every XRPC call and every part upload
paid a TCP and TLS handshake, HTTP/2 was never negotiated, the idle pool
settings were dead config, and each discarded transport kept its idle
sockets open for the full 90s timeout.
One package-level transport and client now back every store. Settings
are unchanged; ForceAttemptHTTP2 is explicit to document intent. The
load balancer negotiates h2 on the frontend (
|
||
|
|
788b9e1053 |
hold/admin: bound the crew tab's identity lookups, and drop backend HTTP/2
Two corrections to the HTTP/2 change, both found by running it. UpCloud refuses http2_enabled on a backend that is not TLS: invalid_params_properties.http2_enabled='Tls must be enabled to enable HTTP2.' This load balancer reaches both origins over the private network in cleartext, so the option is not available here at all, and asking for it failed the whole provision run with a 400 after the frontend had already been changed. Backend HTTP/2 is removed rather than made conditional: it would need TLS terminated at the origins, which is a much larger change than it earns. The frontend is the leg that mattered, and it is enabled and confirmed live — all three domains now negotiate h2 by ALPN. The second correction is more interesting: enabling HTTP/2 did not fix the symptom it was aimed at, and briefly made it look worse. With the browser no longer rationing itself to ~6 connections, every row of the crew tab now reaches the server at once, and the 504s went from a flat 10s to a climbing ladder: [HTTP/2 504 10026ms] ... [HTTP/2 504 15230ms] ... [HTTP/2 504 25976ms] HTTP/1.1 had been hiding a server-side limit by throttling the client. The queue moved; it did not disappear. What is actually slow is resolveHandle, which calls live identity resolution per row with no bound of its own, so a DID whose resolution hangs — a did:web on a host that stopped answering, a PDS that accepts and then stalls — holds its request open until the proxy gives up. The crew tab issues one request per member, so a hold with hundreds of crew gets hundreds of chances to hit one. resolveHandle now bounds each lookup at 2s. A handle is decoration on a row whose DID is already rendered beside it, so waiting seconds for one and failing the row when it does not arrive trades something load-bearing for something cosmetic. Two seconds is far above a warm cached lookup and far below the proxy's 10s cut, so a slow DID costs its own row a handle and costs the page nothing. The quota lookup on the same handler was the other suspect and was measured out: records(collection, did) is indexed and the aggregation returns immediately on the production database. This does not remove the N+1 — the tab still issues one request per member, and a batch endpoint is the real fix. It removes the failure. Verified: the guard is covered by a test using a directory that hangs until its context is cancelled, asserting both that the call returns near the bound and that it did not return suspiciously early. make lint 0 issues, make test green across 44 packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AV6Mk2AgghFsNo4HWQEaBV |
||
|
|
8d7ccd7cb7 |
apply go fix modernizations across the workspace
`go fix` carries the modernize analyzers now, and the tree had drifted behind
them. This is the mechanical result, reviewed rather than trusted: the tool is
capable of rewriting code into something that no longer tests or does what it
did, so every non-test change was read individually and the concurrency-bearing
packages were re-run under -race.
Production code, four changes, all semantics-preserving:
- leases/manager.go: wg.Add(1) + go + defer wg.Done() becomes wg.Go. The
comment above that function turns on Add happening before the goroutine
starts, so that a Wait cannot return before the worker has run. wg.Go does
the Add synchronously on the calling goroutine, so the invariant it
describes still holds.
- auth/token/handler.go: strings.Fields -> strings.FieldsSeq, same splitting,
iterated rather than allocated.
- hold/gc/gc.go: a hand-written map copy -> maps.Copy.
- hold/pds/scan_broadcaster.go: three-clause loop -> range over int.
The rest are tests. The one worth naming is carstore_contention_test.go, where a
careless rewrite could have quietly stopped exercising contention: go fix
converted the reader and side-table goroutines to loopWG.Go but correctly
declined to touch the writer loop, which passes its index as a parameter. The
writer/reader/side-table shape and the stop channel are unchanged, so the test
still contends over the same carstore transactions.
Verified: go build for hold and appview, `make lint` 0 issues, the deploy and
credential-helper modules 0 issues, `make test` green across all 43 packages,
and -race green on leases, hold/pds, hold/gc and auth/token. The scanner module's
two lint findings are unchanged from HEAD and are in files go fix never touched.
Kept separate from the HTTP/2 commit so that one stays readable, and so this can
be reverted on its own if a modernization turns out to matter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg
|
||
|
|
416ba4a2eb |
serve HTTP/2, and reach it through the load balancer
The admin crew tab renders one row per crew member and gives each row its own hx-get, so opening it on a hold with 551 crew issues 551 requests. Over HTTP/1.1 a browser runs at most ~6 per origin, so they queue six at a time and every other request to the same host queues behind them — which is why loading the relay page stalls while the crew rows are still resolving, and why the rows that lose the race come back as "Server error" toasts. The 504 behind that toast is the load balancer's, not the hold's: the hold logs those requests as 200. HTTP/2 multiplexes them over one connection and the queue disappears. It does not make the slow rows fast — that is a separate fix to the per-row identity lookup — but it stops one slow surface from blocking the rest of the panel. Two halves, because neither works alone. The load balancer terminates TLS and speaks cleartext to the origin, so ALPN never runs on the backend leg and net/http can only answer HTTP/1.1 there. Both servers now wrap their handler in h2c. The wrapper is opt-in per connection: it upgrades only for a client sending the h2c preface or "Upgrade: h2c", and passes everything else through untouched, so an HTTP/1.1 WebSocket upgrade is unaffected. Verified both directions against this wiring — HTTP/1.1 for a plain client, HTTP/2.0 with --http2-prior-knowledge. The frontend's http2_enabled was never set, so it sat at the UpCloud default of off. That is the half the browser actually sees. timeout_client is now stated explicitly at its current 10s rather than left implicit: it is the boundary that produces the 504s above, so it belongs somewhere visible. It is deliberately unchanged — raising it without fixing the slow lookup would only make a stalled row stall longer. The hold's *backend* stays on HTTP/1.1. It serves subscribeRepos over WebSocket to external relays and to the scanner, and WebSocket over HTTP/2 needs the RFC 8441 Extended CONNECT that Go's http2 server does not implement for Upgrade:. Routing that backend over h2 would break the firehose. The appview accepts no inbound WebSocket and has no such constraint. Both origins carry h2c regardless, so enabling it for the hold later is a config change, not a code change. createLoadBalancer only runs when there is no LB yet, so properties set there would reach a new deployment and never an existing one. ensureLBHTTP2 reconciles them onto an LB that already exists, following ensureLBForwardedHeaders: read what is there, change only what differs, report what it did, and no-op on a second run. Backend modifies carry the existing health check back, since Properties replaces the object wholesale. Also gofmt: provision.go was not gofmt-clean at HEAD, unrelated to this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg |
||
|
|
96b48f4b3d |
hold: gate presigned PUT behind write access, and validate blob identifiers
com.atproto.sync.getBlob took its S3 operation straight from ?method= with no allowlist and passed it to GetPresignedURL, whose switch mints a presigned PUT. The ATProto sub-handler is public per the ATProto spec, and the OCI one gates on ValidateBlobReadAccess, which passes anonymous callers on a public hold by design. So an anonymous caller could obtain a fifteen-minute presigned PUT into the hold's own bucket, over the avatar, the OG-card thumbnails, stored SBOMs and vulnerability reports, or the OCI layer space. Holds sharing a bucket widened it. The defect is that a read check was gating a write. PUT now requires captain or crew with blob:write via the existing ValidateBlobWriteAccess, rather than a new parallel gate. Reads are untouched: ATProto GET and HEAD stay public, OCI GET and HEAD keep the read check. An unknown method is a 400 rather than falling through to a 500 from the presign switch. The identifiers reached object storage keys unvalidated, which the tests showed was not theoretical: cid=../../../../etc/passwd presigned a key containing that path, and an OCI digest of sha256:aa/bb presigned across a shard boundary. A digest is now sha256 plus exactly 64 lowercase hex, shaped after the scanner's ParseDigest, and a CID must decode and round-trip to its canonical encoding. Validation runs before the auth gate, so a malformed identifier is a 400 even for an authorised caller. Verified before changing anything that no legitimate caller reaches PUT presigning. Pushes upload through the multipart endpoints, which live inside requireBlobWriteAccess and use PresignUploadPart; every getBlob caller in the appview, the scanner and the handlers sends GET, HEAD or nothing, and the distribution route can only produce GET or HEAD. One existing test asserted that an unauthenticated PUT returns a presigned URL. It was pinning the vulnerability as intended behaviour and is replaced by coverage of both the refused and the authorised cases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF |
||
|
|
c44a874090 |
hold: set busy_timeout on every pooled connection, not just the first
A PRAGMA is per-connection, and database/sql opens one connection per concurrent caller. hold_db.go and scan_broadcaster.go each set busy_timeout = 5000 with a one-shot query against the pool, which configures whichever connection happened to serve it and leaves every other writer at zero. Probing the live pools showed exactly that: conn 0 at 5000, conns 1 and 2 at 0. That is the database is locked on COMMIT, and the carstore had no busy_timeout and no WAL on any connection at all. Opening a database now goes through a connector that runs the PRAGMA on every connection the pool creates, ported from the appview, which had already solved this. Applied to the carstore, the shared hold DB, the records index, the event broadcaster and the scan broadcaster. In-memory databases are left unwrapped, since libsql gives each connection its own, and the embedded replica path still skips PRAGMAs because a remote rejects them. WAL is not a new risk: production was already WAL, because journal mode is a persistent property of the file and OpenHoldDB has been setting it. This makes the carstore's own opener agree rather than leaving standalone and test databases in rollback-journal mode. A database that refuses WAL logs a warning instead of failing boot, so a network mount cannot stop the hold starting. NewHoldPDS's file mode opened the same file a second time for the records index; it now shares the carstore's pool. Two pools on one file buy nothing, since SQLite's write lock is per database, so the second only adds contenders. Production already shared a pool and was unaffected by that half. Note for anyone reading OpenHoldDB: its foreign_keys pragma has the same one-connection scope, but libsql reports foreign_keys on for every fresh connection anyway, so it is decorative rather than broken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF |
||
|
|
3ceedc8bb5 |
hold/gc: time an orphaned record from when it was seen, not when it was written
The aux-record sweep decided whether a record was old enough to collect from a timestamp inside the record body, which for scan records is scannedAt. Every rescan rewrites that, so the grace clock reset continuously and a hold with a rescan_interval shorter than the grace period could never collect an orphaned scan record at all. Grace now measures how long a slot has been continuously observed orphaned across analysis passes. The key deliberately excludes the CID so a rescan rewriting the record in place does not restart the clock, and the check runs last, after the manifest, reachability and co-ownership tests, so a record judged live never accrues orphan age. A pass that errors partway commits nothing rather than resetting the clock on slots it never reached. The clock is in memory, so a restart forgets every observation and each surviving orphan starts its grace again. That delays collection and can never advance it, which is the safe direction, but it does mean a hold restarting more often than the grace period will not collect aux orphans. Persisting it wants a table in pkg/hold/db and is left for later. Applied to both aux collections rather than only to scan records. For image configs the new rule is strictly more conservative, since a record cannot be observed before it is written, and a per-collection table of which timestamps are safe to trust is a thing to maintain and to get quietly wrong. One widening beyond the reported bug, called out rather than left to be found: a record whose body timestamp is unparseable used to be kept forever, because the zero time read as in-grace. It is now collected on the normal schedule, having cleared every other guard plus a full grace period. Also corrects the comment claiming these records hold no blob references. Scan records carry sbomBlob and vulnReportBlob; they live under a prefix the blob sweep never walks, so deleting the record frees nothing, and a future sweep for that space must read those fields before the record goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF |
||
|
|
61deadc124 |
hold: reuse stored scan blobs when a rescan finds nothing new
The stale-scan loop rescans an image on a schedule and handleResult uploaded both artifacts every time, so each pass wrote a fresh SBOM blob and orphaned the one before it. Nothing reclaims those, because GC walks only the OCI blob prefix and scan artifacts live under /repos. Keep the stored blobs when the record already on file was written by this same scanner version and carries a vulnerability-report reference equal to the one this report would be stored under, then keep each blob individually and only if it is still in S3. The report reference alone is not quite enough evidence on its own: the report lists matched packages, not every package, so an SBOM could gain a package with no known CVEs and leave the report byte-identical. Content is pinned by the manifest digest, so that can only happen across a scanner or Syft change, which is what the version clause closes. It is inert until that constant moves, and one comparison is cheaper than remembering to add it at the moment it first matters. Every failure path declines to reuse, which costs an upload and never costs correctness, including the reuse check's own timeout, which is carved out of the upload budget rather than given its own so a slow read cannot eat the deadline it stands in for. A record whose earlier upload failed heals on the next rescan, keeping the report and rewriting only the missing SBOM. scannedAt still advances. runStalePass selects on it, so freezing it would make every deduplicated record permanently stale and rescanned forever, which costs far more than the bytes saved. A scanner running with vulnerability scanning off sends no report, so there is no stable digest to compare and its SBOM still churns. Closing that means either parsing SPDX in the hold to normalise a timestamp and a UUID, or a lexicon change; a test pins the gap rather than leaving it to be rediscovered. Note for anyone extending GC to /repos later: a live SBOM object used to be rewritten on every rescan and so was always young. It now keeps its original mtime for the life of the content, so an age-based rule there would delete referenced blobs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF |
||
|
|
1853c0c1d3 |
hold/scanner: detect dead connections and get storage off the reader
Three defects that all turn on whether the hold knows what a scanner is doing. Neither end had a keepalive, a read deadline, or a read limit, so a half-open connection was invisible until some other timeout fired. That got worse with capacity-aware dispatch: a dead-but-connected scanner holds its advertised worker count out of the budget and keeps winning jobs. Both ends now ping every 30s against a 90s read deadline, so three unanswered pings condemn a connection. Detection takes about 90 seconds, after which the existing reconnect grace reclaims the rows, against the 60-minute queueing timeout that was previously the only escape. Liveness decides when a scanner is gone; the grace window decides when its work is reassignable. Read limits are asymmetric and deliberately generous, because exceeding one closes the connection rather than truncating, which would turn a large but legitimate result into a permanent retry loop. Write deadlines were absent everywhere; the scanner in particular held a mutex across an unbounded write, so a wedged write silenced it without disconnecting it. handleResult did two S3 uploads and a CAR commit inline on the reader goroutine, so a slow S3 looked exactly like a dead scanner. Terminal messages now go to a per-subscriber storage goroutine while acks and starts stay on the reader. One goroutine, not a pool: every path ends in CreateScanRecord, which serialises on the repo lock anyway, and ordering is worth more than parallelism that cannot be used. Uploads and the record write get separate budgets, so a stalled upload cannot spend the time the record needs and the record write stays unconditional. checkPredecessor cached an inconclusive answer as a definitive negative in a map that is never reset, so one unreachable hold meant its manifests were never scanned again for the life of the process. gc.go already carried the corrected logic for the same problem; this follows it rather than inventing a second approach, and also stops treating an unparseable captain record as definitive. The test PDS had to move off ":memory:", which go-libsql scopes per connection: writing a scan record from any goroutine but the caller's got a connection with no tables. That was invisible while every record write happened inline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF |
||
|
|
a63f668de0 |
scanner: fix five crash and halt classes found by a pipeline audit
An audit of the scan pipeline and the hold side of scanning found several ways scanning stops without saying so. Each fix here was written test-first: a test expressing the wanted behaviour, confirmed failing for the right reason, then the change. A summary-less result crash-looped both processes. worker.go dereferenced result.Summary unconditionally, but processJob only sets it when Grype runs, and SendResult puts the nil on the wire before the scanner dies on it, so handleResult's unguarded log killed the hold too. A nil Summary now means "not scanned for vulnerabilities", deliberately distinct from "scanned, found zero" — inventing a zeroed summary would report every image as clean when Grype never ran. The hold writes a record rather than orphaning the uploaded SBOM, and the appview renders an "SBOM only" state instead of a green Clean badge. The Grype database could wedge with no way back short of a restart. All three throttles in loadVulnDatabase were guarded by vulnDB != nil, so a scanner holding no provider retried a full download on every scan under the exclusive lock. Two earlier attempts at this bug each added one more condition to the same chain; this replaces the chain with a single decision function over a state snapshot, consulted by both call sites so they cannot disagree. That disagreement was itself a bug: the 50-scan reload had never once executed. Two independent halts. An unparseable frame was dropped in silence, stranding a row that held the hold's only dispatch slot forever; it is now answered "skipped" on first delivery. The 10-minute sweep leaked the in-flight digest and wrote no record, permanently retiring one image per timeout. A digest went unvalidated into filepath.Join and os.Create, so a layer digest of sha256:../../../x wrote outside the scan directory, and nothing verified that downloaded bytes hashed to the digest naming them. Digests come from records in a user's own PDS. Both are fixed together: verification is what makes an escaping write self-defeating. Concurrency did not work on either axis. The proactive capacity gate was depth-one hold-wide, so neither extra workers nor extra scanner processes received work. Depth is now the sum of the worker counts scanners advertise on connect, the gate is scoped to proactive work, and dispatch prefers the least-loaded scanner. Disconnects no longer hand a running scan to someone else: a scanner keeps a stable per-process identity and reclaims its own rows within a grace window, while a process that truly restarted returns with a new identity and has its work reclaimed, which is correct because the restart did lose it. The hold's scanning deadline measured queueing rather than scanning, because the scanner acks on receipt and handleAck never refreshed assigned_at. A new "started" message, sent by the worker that dequeues the job, separates the two budgets. An older scanner never sends it and falls under the queueing budget, which is more forgiving than the deadline it gets today. Adds an in-process mock hold and an e2e harness that runs the real client, queue and worker pool, seeded with 84 real manifest records fetched from a live PDS. Real image layouts and the Grype database are fetched by scripts and gitignored; suites needing them skip cleanly, so the default run stays offline and fast. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF |
||
|
|
f16a8eaa82 |
appview: warn about a deprecated chart on the tab people actually land on
|
||
|
|
e542dd12f6 |
appview: keep the port in the registry host printed to users
On the dev stack (registry_domains: ["127.0.0.1:5000"]) the /auth/token failure guidance said `docker login 127.0.0.1`, dropping the port docker needs. The message exists to tell a stuck developer what to run, so a command that cannot work is the whole defect. Production is unaffected, both registry domains being port-free. The stripping itself is deliberate and stays: NormalizeService removes the port so JWT audiences match the port-stripped routing host. Removing it would desynchronize the service key from the routing key. The unstripped form turned out to survive in only one place. resolveService returns a key from h.services, which SetServices normalizes, and cfg.Auth.Services is normalized too by deriveServices, so neither holds the original. Only cfg.Server.RegistryDomains, straight off the YAML, does, and it was never reaching the token package. Adds a display-only map keyed by the same NormalizeService function the other two key on, so a resolved service always maps back to the entry it came from, and a multi-domain deployment prints the domain the client is authenticating against rather than the primary. Collisions take the first configured entry, matching deriveServices' own first-wins dedupe. A miss falls through to the normalized name, which is today's behaviour. Audiences and routing are untouched, proven by a test that mints a real token, parses the JWT and asserts aud is still the normalized host. The wiring line landed in the previous commit, both changes having been made in server.go at the same time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
3589473feb |
appview: run the hold purge on a worker pool, not the request context
Deleting a tag while over quota could leave the user in the worst available state. DeleteTagHandler deleted the tag and manifest rows first, then called PurgeOnHold, which bounded itself at 10s against the *request* context. The UpCloud load balancer in front of the appview cuts at its default backend timeout at about the same moment, wins the race, hands the client a 504 and cancels that context, killing the purge partway. The appview logged a warning and returned 200. So: gateway error, nothing freed, still locked out, the image gone from the UI so the purge cannot be retried through it, blobs orphaned until the hold's GC, and the appview considering it a success. Measured on production at 10.002367218s. Purges now go to a fixed pool of 4 workers rooted at context.Background(), so they survive the request ending. Following the shape of the hold's startJob helper, minus the progress fragment, since nobody is watching a purge. The buffer is bounded at 256 and sheds with an ERROR rather than growing: an unbounded queue turns a slow hold into an appview memory leak. Submissions are deduplicated on holdDID|manifestURI so a double-clicked delete does one purge and one service-token fetch. The channel send happens under the mutex that guards close, so a concurrent drain cannot send on a closed channel, and the drain is wired into both exit paths before logging shuts down. Failures are now classified and surfaced instead of swallowed: transient ones retry three times under a 90s budget (the hold's purge is idempotent), an unauthorized third-party hold logs at DEBUG since it is expected, and anything else that exhausts its retries logs at ERROR naming the manifest and hold, which is enough to re-drive by hand. Deliberately not reordered. Purge-first-then-delete requires waiting for the purge to know whether to delete, which puts the 10s call straight back on the request. So the orphaned-blob window remains, materially narrower but real: a purge that exhausts its retries still leaves blobs referenced by nothing until the hold's GC, and there is no row left to say so. Closing that needs a durable pending-purge record, which was judged out of proportion here. server.go in this commit also carries one line belonging to the next one, the token handler's display-name wiring, since the two changes landed in the same file concurrently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
b4fccce4d1 |
hold: actually send subject, so the attestation scan guard can fire
The hold declines to enqueue a scan when a pushed manifest has a subject, which
is how it means to skip attestations, signatures and other referrer artifacts.
The AppView never sent one: notifyHoldAboutManifest built mediaType, config,
layers and manifests, and #manifestInfo defined only those four. So the
condition was always true, the guard never fired, and every referrer artifact
was enqueued for scanning.
The AppView already parsed subject. NewManifestRecord unmarshals it into
ManifestRecord.Subject, and Put hands that same pointer to
notifyHoldAboutManifest. The value was in scope and simply never serialized, so
this is one missing marshal step rather than a missing parse.
Adds subject to the lexicon as a #blobInfo ref, and mediaType to #blobInfo,
which config has always sent and the hold has always parsed. That only makes the
schema honest about what is already on the wire.
Hoists the hold's anonymous request struct to a named type with IsMultiArch,
IsReferrer and HasScannableContent, so the predicate is written once and
testable without standing up a HoldPDS.
Both directions degrade safely. An older hold ignores the unknown key and
behaves exactly as today, so shipping the appview alone is harmless but achieves
nothing until the hold catches up. An older appview sends no subject, leaving
the manifest enqueued as before.
Complements
|
||
|
|
9566201377 |
appview: give the confirm modal an accessible name and a focus trap
The modal guards tag deletion, device revocation and webhook deletion, so it is the wrong control to leave unlabelled and escapable. Note the original report was half wrong: role="dialog" and aria-modal="true" were already set on the outer element, and are in the deployed bundle. The observation was most likely taken against the inner .modal-box. Neither was added here. What was actually missing: the h2 existed but nothing pointed at it, so a screen reader announced a dialog with no name; and the only focus management was focusing Cancel on open, so Tab walked straight out into the skip link and page header behind the backdrop. Adds aria-labelledby and aria-describedby with sequence-suffixed ids so two modals cannot cross-reference. aria-describedby matters more than usual here: none of the three call sites' messages contain ". ", so the title is always the generic "Are you sure?" and the specific text is the body. The trap re-queries on each keypress rather than caching at open, and handles three cases: focus escaping to body gets pulled back, first plus Shift+Tab wraps to last, last plus Tab wraps to first. Focus is restored to the opener on every close path, guarded by document.contains, because confirming a tag deletion fires an htmx swap that can remove the button that opened the modal. Restoration happens before onConfirm so htmx sees a sane focus state. Native <dialog> would give the trap and Escape for free, but it renders in the top layer while this is styled entirely with daisyUI .modal classes that assume a positioned div, so converting means CSS work plus the seamark theme fork. Worth doing as its own change, not smuggled into this one. Includes the bundle rebuild, since nothing in the dev loop keeps that artifact current. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
dbb195a4ab |
appview: process daily stats, and drop the dead README branch fallback
io.atcr.hold.stats.daily was handled in the backfill collection list and in processor.go's dispatch, but missing from isRelevantCollection, which gates events before ProcessRecord ever sees them. So daily stats records arrived over the socket and were discarded at the worker, and the trend charts that read them got nothing live. This is the "present in one list, missing from the other" shape CLAUDE.md's firehose checklist warns about. Checked the whole class: this was the only gap. LayerCollection and ImageConfigCollection are absent deliberately, having no processor handler, and the test now pins that intent. Note this is currently masked by the relay outage, so fixing the relay alone would not have restored the charts. Separately, the README resolution tried "main" and fell back to "master", but DeriveReadmeURL never fetches: it parses the source URL and interpolates the branch, returning empty only for an unsupported platform, which is branch-independent. So if the main call returned empty the master call returned empty for the same reason, and the fallback could never fire. Removed, with a comment recording that a branch fallback has to happen at fetch time after a 404. The other two call sites already do exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
7a0769d8e4 |
appview: three small UI fixes, empty state, icon sizing, aria-selected
Artifacts filter had no empty state: filtering to zero matches left a blank panel, indistinguishable from something being broken. It now uses the existing state-empty partial, with no CTA since the filter input is right there. Rows appended by Load More now also obey the active filter, which is a change to existing behaviour but is required for the empty state to be truthful. The tag icon beside the tag selector computed to 5.44px by 24px on every repo page: the class was right, the flex parent was shrinking it. Adds shrink-0 to that instance only. 31 other icons are direct flex children without shrink-0 and are left alone, since a site-wide sweep found only this one squeezed. Digest-page scan tabs set role="tab" but never aria-selected, while the repo-page tabs do. Both are now set server-side so first paint is correct, with JS keeping them in sync afterwards, following switchRepoTab's pattern. The diff-content tabs had the identical defect and are fixed too, since the handler is generic over radio tabs and leaving them out would have meant attributes set by JS but never by the server. Includes the bundle rebuild for these and the two /auth/token-adjacent JS changes, since nothing in the dev loop keeps that artifact current on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
9d8bd513da |
appview: render the install scripts from config instead of shipping ATCR's
seamark.dev's /install and /settings/devices told users to pipe
seamark.dev/static/install.sh into bash. That file was the unmodified ATCR
script: it announced itself as the "ATCR Credential Helper Installer",
installed docker-credential-atcr, and finished by telling the user to configure
credHelpers for atcr.io, the wrong registry for that deployment. Anyone
following the documented setup ended up pointed at another service. The
templates hardcoded docker-credential-atcr, "atcr" and ~/.atcr/device.json
alongside a correctly themed {{ .RegistryURL }}.
The scripts are now rendered from config by a handler, rather than forked per
brand. A theme overlay was the alternative and was worse: it needed a full copy
of both install.sh and install.ps1 per brand, four scripts to keep in sync, and
the operator asked for these values to come from config.
credential_helper.name is the single knob. Docker resolves a credHelpers value
x by exec'ing docker-credential-x, so the credHelpers value, the binary suffix
and the config directory are genuinely one word, not three that can drift. It
is validated against a strict pattern because it is interpolated into a shell
script.
install.sh renders byte-identical to the deleted static file under the atcr
default, so existing installs are unaffected. install.ps1 differs by one line,
where a stale usage comment named a path the script is not served at.
Two behaviour changes worth noting: these two URLs drop from a one-year
Cache-Control to five minutes, since the body now depends on deployment config;
and credential_helper.tangled_repo becomes a real overridable default. It was
previously assigned over unconditionally and read by nothing, while the shipped
script used a different URL form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
|
||
|
|
2743445e65 |
appview: fix two /auth/token defects, wrong login host and dropped scopes
Both are pre-existing and were found while working on finding 27.
sendAuthError built its "docker login <host>" line from r.Host. /auth/token is
served on the UI domain as well as on every registry domain, and the
WWW-Authenticate realm points at the UI domain's copy, so a client following
the realm was told to run "docker login seamark.dev" - the one host that
deliberately refuses /v2/* with an OCI UNSUPPORTED error pointing at
seamark.cr. It now uses the service resolved for the token, which is the
registry domain, falling back to the deployment's primary rather than to
r.Host. The single-domain case still prints a host that serves /v2/, and an
unconfigured service supplied by the client cannot steer it.
Separately, the scope parameter was read with .Get, taking the first value
only. The Docker token spec allows scope to be repeated, so a client asking for
two repositories was issued a token covering one and got a 401 on the other.
Both wire forms are now flattened, on the GET query string and on the OAuth2
POST body, which had the same defect via PostFormValue.
Empty and whitespace-only values are dropped. Exact duplicate scope strings
collapse, but two entries naming the same repository with different actions are
left alone: merging them would union the action sets, and every gate downstream
is written only to narrow.
More entries now reach the anonymous gate added in
|
||
|
|
84872580a3 |
appview: show a chart's deprecation where people actually look
digest.html rendered a Deprecated chip, but the shared helm-metadata partial never read .Deprecated, so the repo page carried no deprecation signal at all. The data was already on the struct; this was a display omission. The notice leads the metadata card, above the description: deprecation decides whether you use the chart at all, so it has to be read before the prose that sells it. It uses the alert/alert-warning callout the codebase already uses for this kind of thing, including in the adjacent helm-digest-content partial, rather than a bare badge. A lone small badge reads as a stray tag once it is outside the digest page's row of status chips, and leaves no room to say why it matters. The digest page now shows deprecation twice, deliberately: its header chip is the scannable signal above the fold, and this carries the explanation further down. Removing the chip would push the only signal below the install command, which is the burial this fix is meant to undo. Note the repo page's Overview tab, which is where most people land, still shows nothing: it renders the README and never touches chart metadata. Only the Chart tab gains the notice here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
0490278fb8 |
appview: don't report a failed README guess as an error
Four of the twelve home-page repos showed "We couldn't load the README, it may be rate-limited or private". The URL in those cases was not configured by anyone: it was derived from org.opencontainers.image.source, a label images inherit from their base image, so the raw URL named an unrelated project and 404ed. A 404 on a URL the appview guessed is an expected outcome the owner cannot act on. The failure flag is now set only when the owner actually pointed us at the URL, via the io.atcr.readme annotation. A derived URL that fails renders as if there were no README. Both paths keep their debug log, now carrying an "explicit" field so the two cases stay distinguishable. Render failures are suppressed for derived URLs too. The panel's copy and its "Edit README" action address an owner who configured a source; on a derived URL there is no configured source, and content that failed to render is very likely another project's README anyway. This is the alarming half of the finding. Rendering the wrong project's README when the fetch succeeds is the larger half and is untouched: there is no reliable way to detect an inherited label, since the only signal is org.opencontainers.image.base.name, which is not always set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
44a17cbcdc |
appview: stop the upgrade banner inventing an improvement across arch mismatch
On a public repo, to anonymous visitors, the digest banner read "2 fixes, 12 Critical / 27 High / 26 Medium vulns, -22 layers, -53.6 MB" while the same page showed 244 vulnerabilities and Layers (22), and its own "View diff" link landed on "Layers 22 -> 22, every layer Unchanged". Platform matching only ran when both sides were manifest lists. The comment after that block said the mismatched case would "fall through and show a basic banner without layer/vuln details", but no such branch was ever written and nothing guarded the fallthrough, so execution continued into the layer and vuln computation with the unresolved originals still in place. The newer side was the multi-arch index, which carries no layers of its own and is not scanned, so all 22 layers of the other side read as removed and the vuln delta was computed against an absent scan. Returns 204 for either mismatch direction, as the no-common-platform path already does. The promised "basic banner" is not implementable as the function stands, which is presumably why it never appeared: the template renders only NewerTag, DiffURL and Summary, and DiffSummary is nothing but deltas, so a delta-less banner collapses to the tag name and would be suppressed by the existing "nothing meaningful changed" guard anyway. The comment is replaced with one that says what is actually true. Showing a real banner here would mean resolving the index to the child matching the single-arch side's platform, and that side's os/arch is not in the appview DB at all: Platforms is populated only for manifest lists and the manifests table has no os/arch columns. It would need either a config fetch from the hold at render time or denormalising os/arch during ingest. Not attempted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
39919cc832 |
appview: stop webhooks reaching private addresses
The URL check accepted http:// while telling the user "must be https", and guarded no addresses at all. POST /api/webhooks with http://127.0.0.1:9/hook returned 200 and created the webhook, so both scheduled deliveries and the synchronous Test button would dial arbitrary destinations from the appview host, on demand, for any authenticated user. Loopback, link-local (including the cloud metadata endpoint at 169.254.169.254) and RFC1918 were all reachable. Enforces https, and refuses non-public destinations. The load-bearing half is the dial-time check, not the creation-time one. An attacker controls their own DNS, so a hostname that resolves publicly when the webhook is created can resolve to loopback when it is delivered, and a creation-time check cannot see a redirect either. The guard is therefore a net.Dialer Control hook on the delivery client, which inspects the resolved address on every connection attempt. Transport.Proxy is explicitly nil: honouring HTTP(S)_PROXY would route around the Control hook and hand the bypass straight back. Redirects are re-validated per hop and capped at 3. The creation-time check stays so the user gets an immediate, comprehensible error instead of a silent delivery failure later. IPv4-mapped IPv6 is unmapped before every check, so ::ffff:127.0.0.1 and friends hit the IPv4 rules. Ranges with no net.IP helper are listed explicitly: CGNAT, NAT64, ::/96, TEST-NET and reserved space. Both outbound paths are covered, since the scheduled dispatcher and the Test button both funnel through attemptDelivery. The dispatcher's other client is deliberately left unguarded: it fetches quota stats from holds, which legitimately live on private addresses, and those URLs are not user-supplied. Note this removes the ability to point a webhook at a localhost receiver in local development. There is deliberately no environment-variable escape hatch, since a security toggle read from the environment is the same bypass wearing a nicer coat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
dfd604b106 |
hold/scanner: stop one undispatchable job freezing all scanning
Vulnerability scanning produced nothing across the whole deployment for nine days, from 2026-08-25 01:20:48 until a scanner restart on 2026-09-03. The scanner was connected and idle, the hold's discovery pass kept reporting unscannedFound=15 every four hours, and no scan_jobs row was created in that entire window. hasActiveJobs counted pending, assigned and processing rows globally with no age bound, and waitForCapacity spins while it is true. dispatchLoop calls it before popping any candidate, so a single pending row that never reached a terminal state reported "busy" forever: discovery kept pushing candidates into unscannedQueue and nothing ever popped them. That is why the symptom was an empty queue rather than a growing one. Nothing papered over it because push-triggered enqueue only fires for owner or a tier with scan_on_push, which in production means pro alone. All 210 manifests pushed to this hold in that window came from free, supporter, or accounts with no crew row, so the frozen proactive loop was the only source of jobs. Nor could it recover on its own. Only Enqueue and drainPendingJobs dispatch a pending row, and drainPendingJobs runs only when a scanner newly connects; reDispatchTimedOut considered assigned rows only. The hold had been up since Aug 14 and the scanner since Aug 21 on the same websocket, so the drain path had not run since the row appeared. So bound the capacity gate to pending rows younger than pendingStaleAfter, give reDispatchTimedOut a pending reclaim, and check RowsAffected on the assign UPDATE now that two dispatchers can race for a row. waitForCapacity warns and names the blocking jobs after ten minutes without capacity, because the failure mode above was completely silent. Two adjacent fixes for the same outage. The scanner never called InitLogger, so log_level and log_shipper were dead config and an idle scanner was mute, which is what made nine days invisible. And skipReason now also skips a job whose layers contain nothing tar-shaped: the job that wedged this queue was an in-toto attestation whose config mediaType is an ordinary image config, so the existing config-type check missed it and buildOCILayout would have handed Syft an empty image. The regression tests were verified against the old logic first: three of them fail on it and pass on the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPWkeCKcbtGoyXyyeMhSps |
||
|
|
af7522b154 |
appview: stop /auth/token granting anonymous pull the registry will refuse
An unauthenticated token request for a repo on a private hold came back with a signed token granting pull, and /v2/ then 401ed that exact token. The authorization server and the resource server disagreed about the same request. The old behaviour was deliberate — "minting a pull-only token is not a grant", with the hold owning the decision via captain.Public — but a token spec expects the server to issue the subset it will authorize, so granting pull and then refusing it is the wrong shape. Adds an optional AnonymousAuthorizer, kept separate from Authorizer because the anonymous path has no DID and no auth method (three of Authorize's four arguments are meaningless) and because it must drop whole entries rather than narrow actions in place, where entries can belong to different owners. Denied entries are dropped; if nothing granting survives, the caller gets the standard 401 challenge rather than a token with an empty access list, so docker prompts for credentials instead of proceeding to a second 401. The scope-less /v2/ ping and the actionless entry NarrowToPullOnly preserves on purpose both bypass the gate entirely — no identity resolution, no hold lookup — since anonymous discovery depends on them. Fails open on any lookup error, matching the /v2/ check, which states the reason: the hold is the enforcing authority and a transient failure must not break anonymous pulls of public images. /v2/ still enforces; this is a correctness and UX fix, not a security fix, and nothing was exposed. Also closes the successor asymmetry documented under finding 3: /v2/ applies a single-hop migration redirect before checking read access, so judging the pre-migration identity here would have reintroduced the disagreement this gate removes. It was one extra local read of hold_captain_records. Tests pin both directions and prove the chain is not followed past one hop. Costs one directory-cached identity resolution plus two local SQL reads per granting entry, and no call to the hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
4caeb25031 |
appview: rebuild the JS bundle so the committed asset matches src
Picks up the two JS changes in this batch: the crane destination argument in
updatePullCommand (app.js) and syncDiffMenu (repository.js).
The bundle is a committed build artifact, and nothing in the dev loop keeps it
current on its own. Air's pre_cmd is go generate and its cmd is a Go build; it
never invokes esbuild. Bundling happens in npm run js:watch, a separate
process. So a src-only change leaves the committed bundle stale until someone
runs npm run js:build by hand, which is how
|
||
|
|
85f312f953 |
appview: refuse /v2/_catalog with UNSUPPORTED, and send Allow on every 405
/v2/_catalog answered a bare request with 200 {"repositories":[]} but 400ed
any n, because buildDistributionConfig leaves Catalog.MaxEntries at 0 and the
library rejects n > max. crane catalog sends n=1000, so it always failed. The
same endpoint both worked and rejected a legal parameter.
Setting MaxEntries was the obvious fix and is the wrong one. This registry has
no global catalog and will not grow one: repositories live in per-user ATProto
PDS namespaces, and buildStorageConfig hands the library a placeholder
inmemory driver, so its enumeration is empty by construction. An empty 200
asserts that this registry contains no repositories, which is false and
silently so. UNSUPPORTED says the true thing, and matches the vocabulary
DomainRoutingMiddleware already uses to refuse /v2/* on the UI domain.
There is no conformance cost: _catalog is not in the OCI distribution spec at
all. It is a Docker Registry HTTP API V2 extension, and the spec places
repository discovery out of scope. Docker Hub and GHCR both refuse it outright
and Quay returns an unconditional empty list; none of them 400s a legal n.
The path had three distinct behaviours, not two, and all three now collapse to
one: GET varied by parameter, HEAD was answered by gorilla's MethodHandler
with a bare 405, and the trailing-slash form 301-redirected because
distribution sets StrictSlash(true). Both path forms are registered, and chi
prefers a static pattern over the /v2/* wildcard regardless of declaration
order (verified against the pinned chi version, and pinned by a test that
fails if a request reaches the distribution stand-in).
Also adds the Allow header that RFC 9110 requires on any 405 — a MUST in both
15.5.6 and 10.2.1, not a SHOULD. The value is empty, which 10.2.1 defines as
"the resource allows no methods": true for the catalog, and true for /v2/* on
the UI domain, so the pre-existing gap in DomainRoutingMiddleware is closed
too. Naming a method there would advertise something that does not work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
|
||
|
|
8bc6d65e1e |
appview: emit crane's mandatory destination in the pull command switcher
The client switcher built every command as "<client> pull <ref>". That is valid for docker, podman, buildah and nerdctl, but crane requires a destination: $ crane pull seamark.cr/user/bench8x1:v2 Error: requires at least 2 arg(s), only received 1 So selecting crane handed the user a command that cannot run. pullPrefix is a prefix-only helper, which is precisely why it could not express this; add a matching pullPostfix that returns " <image>.tar" for crane and "" for everything else, including "none" (image reference only), which must get neither prefix nor postfix. Both render paths change together, since fixing one leaves the bug visible in the other: the Go template helper paints first, and updatePullCommand in app.js re-renders when the dropdown changes. Repository names may contain slashes, so only the last path segment is used — otherwise the destination would name a subdirectory that does not exist. A name ending in "/" yields no destination at all rather than a bare ".tar", on the grounds that a visibly wrong-arity command beats silently writing a hidden file. That input is not reachable through the real repo-name path. The test asserts the whole command string rather than just the postfix, so it covers the prefix/postfix interaction and the "none" case where both vanish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
3158460298 |
appview: hide the current tag from the repository Diff dropdown
The Diff menu listed every tag including the one being viewed, and clicking
that entry did nothing: diffToTag returns early on to === currentTag, with no
navigation, no toast, no feedback.
Fixed in JS rather than in the template, which is the part that is easy to
get wrong. #diff-dropdown sits outside #tag-content, and the tag selector
block is marked "stays in DOM, never swapped" — so a {{ range }} filter would
be correct on first paint and stale after the first htmx tag swap, omitting
the originally loaded tag and re-including the newly current one. Same dead
entry, harder to see.
syncDiffMenu() reads the live value from #tag-selector and is called from
initTabs(), which already runs on load and again from the htmx:afterSettle
handler for #tag-content, so it stays correct across swaps.
Hidden rather than disabled: a disabled row still takes space and still reads
as an item to a screen reader, and "diff against the tag you are already on"
is meaningless rather than temporarily unavailable. Uses style.display to
match filterTags() in the same file, since daisyUI's .menu li rules outrank
Tailwind's .hidden.
The template guards the dropdown with {{ if gt (len .AllTags) 1 }} and tag
names are unique per repo (tags PK is did+repository+tag), so exactly one
entry is ever hidden and the menu can never end up empty. The early return in
diffToTag stays as a backstop.
Pre-existing, not a deploy regression. The baseline missed it because the
test repo had one tag, so the check skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
|
||
|
|
1631898005 |
appview: give every Repository() error an OCI code instead of 500 {}
A user with an unresolvable defaultHold (did:web:localhost%3A8080) got HTTP
500 with a body of literally {} on every request in their namespace, and
crane retried it three times because 500 is retryable.
The cause is in the distribution library. handlers/app.go:755 switches on the
error type returned by Repository() with cases for ErrRepositoryUnknown,
ErrRepositoryNameInvalid and errcode.Error, and no default. A bare fmt.Errorf
matches none of them, so context.Errors stays empty; ServeJSON then finds no
ErrorCoder, leaves sc == 0, falls through to 500, and Errors.MarshalJSON
renders the nil slice as {} via omitempty.
So every error leaving Repository() must be coded. Four were not:
hold URL unresolvable -> 404 NAME_UNKNOWN when errors.Is
atproto.ErrHoldDIDPermanent, else 503 UNAVAILABLE
no hold DID configured -> 500 UNKNOWN, but with a body and a log line
invalid image name -> 400 NAME_INVALID
name missing an owner -> 400 NAME_INVALID
The permanent/transient split is the point: a DNS blip must stay retryable,
but a did:web that can never resolve must not be retried at all. Stored user
data that cannot resolve is a 4xx condition, not a server fault, and the
NAME_UNKNOWN message now names the hold so the owner can fix their profile.
The hold DID is already world-readable in their sailor profile record.
Tests assert through a helper that replays distribution's exact type switch,
so an uncoded error still surfaces as 500 {} and the assertions bind to the
real behaviour rather than to the constructors.
Does not address the logrus line at app.go:757, which fires unconditionally
before the type switch and cannot be avoided by any returned error type.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
|
||
|
|
2ee5a35525 |
appview: stop the schema-drift check reporting phantom index differences
reportSchemaDrift logged 76 differences on every boot, all false positives: 38 indexes each reported twice, once as missing from the database and once as present but undeclared. The only difference was a single space before the column list. The two sides of the comparison are built differently. referenceSnapshot() applies schema.sql to a local in-memory libsql, which stores the CREATE text verbatim as "ON t(col)". Production is an embedded replica syncing to Bunny, whose parser re-emits normalized DDL as "ON t (col)". describeIndexes collapsed whitespace runs but could not normalize a space that exists on one side only, and SchemaDrift compares by exact string. Normalize whitespace adjacent to ( ) and , so both spellings converge. Space after ) is deliberately left alone, and the space before DESC is untouched, so column order and direction still have to match. This mattered because the check exists to catch a migration recorded but not executed (0004 was, which is why 0009 exists). At 76 phantom findings, real drift would have been one line among 77, under a hint telling the operator to write a corrective migration that is not needed. The first boot after this lands is the first honest reading of that warning. The existing tests all passed because they run local-only, which is exactly how this shipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 |
||
|
|
a63b839613 |
appview: gzip UI and static responses, leaving /v2 alone
Go's net/http compresses nothing by default and the appview is served by a
raw Go server behind a load balancer that does not compress either, so
everything went out uncompressed: style.css at 180 KB, bundle.min.js at
105 KB, and the homepage HTML at 95 KB. Lighthouse put style.css alone at
1,768 ms of blocked first paint, and mobile Performance measured 77 against
98 on desktop, entirely on paint metrics (TBT 10ms, CLS 0).
klauspost/compress is already a direct dependency and ships gzhttp, so this
costs no new one. Brotli would save roughly 11 KB more across the three
largest assets in exchange for a runtime dependency, which is not worth it.
The /v2 skip is the part that needs care. The OCI registry API is mounted on
the same chi router as the UI (server.go:561), and container layers are
already gzipped tarballs, so compressing that path burns CPU for no gain.
The content-type allowlist would catch most of it, but /v2/* also serves
application/json for tag listings and errors, so the path check keeps the
registry out of the compression path entirely. The predicate matches the one
the domain-routing middleware already uses.
Measured against a local build, gzip vs identity:
/ 14,526 -> 4,298 71%
/css/style.css 183,990 -> 31,596 83%
/js/bundle.min.js 107,781 -> 32,648 70%
/icons.svg 26,360 -> 8,403 69%
total 332,657 -> 76,945 77%
Verified end to end against a running binary: UI and static responses carry
Content-Encoding: gzip with Vary: Accept-Encoding, /v2/ and /v2/*/tags/list
carry neither, and woff2 stays untouched. Tests cover both directions plus
the under-1 KB and no-Accept-Encoding cases.
HTTP/2 is the remaining half and cannot be fixed here: the load balancer
terminates TLS and negotiates no ALPN at all, which pins every request to
HTTP/1.1. That is an LB setting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124r73LT4qoFE82TqwH2Gu9
|
||
|
|
cbd0c5f05c |
appview: rebuild the JS bundle so the committed asset matches src
The tracked bundle predated
|