Commit Graph
647 Commits
Author SHA1 Message Date
Evan JarrettandClaude Fable 5.1 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
e0a2dda and b2d6842, so a hold that upgraded across that window still
has them. Members hit the O(1) path; only genuine non-members pay for
the walk, and they are denied anyway.

Every authorization decision and error string is unchanged. Tests cover
the deterministic hit, a legacy TID-keyed member found only through the
fallback, a non-member, and a storage error surfacing as an error rather
than a silent denial.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5
2026-09-09 09:31:16 -05:00
Evan JarrettandClaude Fable 5.1 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 (416ba4a) but reaches the
hold over HTTP/1.1, so multiplexing stops at the LB. The win is the
handshake and socket churn, not multiplexing.

The struct field stays per-instance so tests can substitute a client.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5
2026-09-09 09:31:16 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-09 09:01:45 -05:00
Evan JarrettandClaude Opus 5 b886a75532 deploy: make appview billing opt-in, and verify what was built
build-trixie hardcoded `-tags billing`, so the only way to build a non-billing
appview was to bypass the Makefile and drive the trixie container by hand. Both
production deploys of this deployment did exactly that, and the workspace notes
record it as "the deploy tool cannot reproduce this build" — a tool that cannot
produce the artifact you actually ship is a tool nobody can safely use.

Worse, the failure is silent and one-directional. Nothing about a billing binary
looks different: same name, same version stamp, same vcs.revision. A deploy that
forgot the manual path would quietly switch billing on for a deployment whose
operator had chosen to run without it, exposing a live Stripe webhook endpoint
and a paid tier ladder on a service configured for neither. Production has been
running a non-billing appview since May precisely because someone did not use
`make build-trixie`.

Billing is now opt-in and off by default:

    make build-trixie                        # no billing
    make build-trixie BILLING=1              # billing
    deploy/upcloud update appview            # no billing
    deploy/upcloud update appview --with-billing

Only the appview is affected; hold, scanner, labeler and the credential helper
never reference pkg/billing.

The flag alone is not enough, so verifyAppviewBilling reads the built binary
before anything is uploaded and refuses to ship a mismatch. It observes rather
than trusts, because the flag and the artifact can disagree for reasons the flag
cannot see: a stale bin/atcr-appview from an earlier build, a Makefile that
hardcodes the tag, a builder image that ignored it. The Stripe SDK links only
under the tag, so its symbols are a direct measurement — verified as a
discriminator here: 2338 stripe-go strings with the tag, 0 without.

A missing binary is an error rather than an absence of symbols, so a deploy
cannot proceed on a file that was never built by reading it as "no billing".

Verified: the default build-trixie output has 0 stripe-go symbols and does carry
the "Billing is not enabled on this deployment" stub. Guard covered both
directions by test, plus the missing-binary case. make lint 0 issues across
root, deploy and credential-helper; 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
2026-09-09 08:51:38 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-08 22:38:01 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-08 22:35:35 -05:00
Evan JarrettandClaude Opus 5 265e533ad3 scanner: reclaim scan directories a killed process left behind
buildOCILayout already removes its scan dir on every error path, and syft.go
defers the stereoscope generator's Cleanup. What neither can do is clean up
after a process that dies mid-scan: the deferred call never runs, and nothing
afterwards ever looks at what was left. Every restart therefore leaks the
in-flight layout and extraction permanently, and a restart is routine — a
deploy is one.

On seamark-hold that reached 8.8 GB of orphaned scan-*, syft-scan-* and
syft-cataloger-* directories under a 20 GB disk, at which point the disk was
97% full and scans began failing on it:

  failed to load OCI image: unable to populate layer cache
    dir="/var/lib/seamark/scanner/tmp/syft-scan-1546187834/..."
    : no space left on device

  failed to download layer 5: failed to write blob:
    write /var/lib/seamark/scanner/tmp/scan-4160414849/blobs/sha256/...
    : no space left on device

The leaked directories cluster at the scanner's restart timestamps, which is
what identifies the killed process rather than the error paths as the source.
Manual removal reclaimed 8.8 GB and took the disk from 97% to 50%.

Startup is where this belongs: it is the one moment the previous process is
known to be gone, and it is immediately after the event that caused the leak.
The sweep runs in WorkerPool.Start after TMPDIR is set and before any worker
can dequeue, so nothing it removes can be work in progress here.

Three constraints shape what it will touch:

  - Only the three per-job prefixes, only as direct children, only
    directories. The Grype database lives beside the tmp dir at
    <parent>/vulndb and go-getter unpacks into grype-dl underneath it; both
    are state the scanner needs and neither matches a prefix. The prefixes now
    have one definition each, used by both the creator and the sweeper, so
    renaming a directory cannot silently take it out of the sweep's scope.

  - An age threshold, vuln.sweep_max_age, default 1h. A second scanner sharing
    the directory has an in-flight scan-* dir that is minutes old, and
    scanner.job_timeout is 8m, so an hour clears both with room to spare. 0
    disables the sweep rather than removing a peer's live work.

  - Nothing is fatal. A stat or removal failure is a WARN and the sweep moves
    on, so a permission problem in the tmp dir cannot keep the scanner from
    starting.

The sweep only runs at startup, so a scanner that is killed twice between
deploys carries the first leak until its next restart. That is the tradeoff
for never racing a live peer; a periodic sweep would be the follow-up if
processes ever live long enough for it to matter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg
2026-09-08 21:59:58 -05:00
Evan JarrettandClaude Opus 5 bd208661c6 build: run every workspace module's tests, not just the root's
go test ./... only ever covers the module it runs from, so the scanner's 17
test files were never reached by make test. Everything written for it this week
was dormant: the mock hold, the manifest corpus, the blob integrity suite, the
concurrency scenarios. The same shape as the billing tests a few commits back,
which is why the module list is derived from go.work rather than written out,
so adding a module to the workspace is enough to get it tested.

Wiring it up immediately failed, which is the argument for having done it.
TestSBOMIsStableAcrossScanDirectories asks the two SPDX keys that still vary
between encodings to keep varying, so that whoever makes the digest stable is
told to tighten the test rather than discovering the slack later. But
creationInfo.created has one-second granularity, so two scans inside the same
second produce an identical value and the assertion becomes a coin flip on how
fast the machine is. It passed under -race, which is slow enough to straddle a
second, and failed under -cover, which is not.

Only documentNamespace can carry that signal, since its UUID is redrawn on
every encode. creationInfo stays in the residue set, because the digest
comparison must still exclude it; it is simply no longer asked to prove
anything. Stable across repeated runs under -cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
2026-09-05 19:52:55 -05:00
Evan JarrettandClaude Opus 5 29fe24aa41 scanner: report the Grype version that is actually linked in
The vulnerability report's descriptor hardcoded "v0.107.1" while go.mod has
required v0.118.0 for some time, so every report ever stored misreported which
tool produced its findings. That field exists to answer the first question
anyone asks when two scans of one image disagree: did the scanner change, or
did the feed. A constant cannot answer it, and had been quietly wrong across
eleven minor versions.

It now reads the module version from debug.ReadBuildInfo, so a dependency bump
carries into the reports on its own. A build-time ldflags stamp was the
alternative and has the same failure mode as the constant, just moved to a step
someone has to remember. Where the build genuinely cannot say, the descriptor
carries "unknown" rather than a plausible-looking lie.

The lookup is split into a pure function because a test binary's build info
carries no dependency list, so the real call returns "unknown" under `go test`
no matter what the code does; testing through it would have proved nothing.
Verified against a real build instead: `go version -m bin/atcr-scanner` lists
dep github.com/anchore/grype v0.118.0, which is what the split-out lookup
returns for that input. A second test fails if the literal ever reappears in
the source, since that is the regression rather than the lookup being wrong.

Reports already stored keep the old string; there is no migration and nothing
reads the field today, the appview decoders declaring only matches and
dropping the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
2026-09-05 19:37:55 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-05 16:50:01 -05:00
Evan JarrettandClaude Opus 5 22058cc5f4 scanner: bound a scan job, and lose the race to the hold on purpose
Nothing limited how long one job could take. The worker's context was the
process's, buildOCILayout took none, and blob downloads used a package-level
client whose five-minute timeout is per request with no context, so a 19-layer
image had a hundred-minute worst case on downloads alone and cancellation could
not touch it. At the default single worker, one wedged job stopped that scanner
entirely.

scanner.job_timeout, default 8m, against the hold's 10m scanning timeout. Both
clocks start at the same instant: the worker sends "started" on dequeue and
derives the job context on the next line, so the scanner loses by two minutes,
which is enough for its terminal message to cross the socket and be recorded.
If the hold wins instead it re-dispatches while this scanner is still working,
which is duplicate work recorded under a generic reason. A scanner cannot read
the hold's config, so the relation is a mirrored constant used only for a
boot-time warning, and the same warning fires if the deadline is disabled.

What is actually bounded, since a deadline the code cannot honour is worse than
none: presign, download, stereoscope's Provide, Syft's CreateSBOM, and Grype,
which does have FindMatchesContext even though FindMatches does not.
stereoscope's img.Read takes no context and is 81% of a scan, so it is checked
either side rather than interrupted. Abandoning it on a goroutine would trade a
bounded overrun for one writing gigabytes into a directory the caller has
already deleted. max_image_size remains the real bound on that stage.

A timeout reports error, not skipped. It describes this host at this moment, a
contended CPU or a slow bucket, not the image, and skips are never retried, so
one bad afternoon would retire an image permanently with nothing in the record
to say why. Retry cost is bounded on the other side by max_image_size and by
the stale-scan schedule. The classification asks the job context rather than
the error, because several stages replace the cause and the uninterruptible one
knows nothing about the deadline, and a job that finishes after an overrun
still reports its real result rather than throwing away completed work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
2026-09-05 16:16:16 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-05 16:14:35 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-05 16:14:35 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-05 15:53:57 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-05 15:41:27 -05:00
Evan JarrettandClaude Opus 5 e57e31cefe scanner: name a scan by its manifest digest, not its temp directory
The Syft source reference was the per-job os.MkdirTemp path, which is different
on every scan and reaches five places in the encoded SPDX document as well as
the source object embedded in the vulnerability report. Rescanning unchanged
content therefore produced byte-different artifacts under fresh digests, so the
hold uploaded new blobs and orphaned the old ones on every pass of its
stale-scan loop.

The manifest digest is the identity the hold already uses: the scan record's
rkey is derived from it, one record per digest whatever repository the job
arrived under. "repository@digest" reads better but the same digest is
dispatched under whichever user's manifest discovery walked first, so it would
have reintroduced the same drift more slowly. An explicit source.Alias pins the
name, because Describe() otherwise parses a bare sha256:<hex> as a repository
called "sha256". Nothing reads the source field today; the only user-visible
change is that a downloaded report names the image instead of leaking a temp
path.

This makes vulnDigest stable. sbomDigest still moves, and cannot be fixed here:
Syft stamps creationInfo.created from time.Now() and appends a random UUID to
documentNamespace, neither configurable through the encoder. Stamping a
constant timestamp would be a falsehood in a security artifact and deriving the
namespace UUID means mutating a package global, which races across workers. The
test asserts equality outside those two keys and fails loudly if the residue
ever stops moving, so the next person is told to tighten it rather than
discovering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
2026-09-05 15:41:27 -05:00
Evan JarrettandClaude Opus 5 fcde9c879a docs: record why an all-negligible image is not rendered as clean
The previous wording called the 0 0 0 0 strip a visible consequence worth
closing and named a route to close it, which invites exactly the change it
should prevent.

Clean is not the same as nothing worth reporting. A clean image has no
findings; an image whose findings are all Negligible or Unknown has findings,
none of which rise to a flagged severity, and the vulnerabilities tab lists
what the strip does not. Rendering it as clean would assert something untrue.
The headline exceeding the four counts is the same distinction: the total is
how many were found, the boxes are how many merit action.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
2026-09-05 15:38:07 -05:00
Evan JarrettandClaude Opus 5 7cf58de78e docs: record which scanner findings were reviewed and accepted
Two of the open findings were reviewed and are working as intended, which is
worth writing down: an unrecorded decision reads identically to an oversight
six months later, and both of these look like bugs from the code alone.

The severity strip shows four buckets and does not account for Grype's
Negligible and Unknown. That is deliberate. Two more colours would clutter the
strip and those severities rarely merit attention, so the four boxes summarise
what matters rather than partitioning the total, and the detail table still
lists everything. The two places the gap stays visible are recorded with the
cheaper route, should either ever be worth closing.

GOMEMLIMIT not scaling with scanner.workers is a safeguard for a
memory-constrained host, not a tuning parameter, and the intended direction is
to remove it once scanners have their own nodes rather than to grow it. The
separate open question about the cgroup ceiling is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
2026-09-05 15:09:42 -05:00
Evan JarrettandClaude Opus 5 850c416dc8 docs: record the scanner audit, its fixes and what is still open
Section 1 is the status: what landed and what did not. Sections 2 to 6 are the
consolidated analysis. Sections 7 onward are the original agent reports, kept
unedited as dated evidence of the code as audited, which means they describe
behaviour several of the fixes have since changed; section 1 is the authority
and the renamed tests are listed in section 6.

Findings are marked CONFIRMED where someone reproduced them and SUSPECTED
where they were reasoned from source, because a reading pass and a failing
test are not the same kind of claim and the difference should survive into
whoever reads this next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
2026-09-05 15:01:27 -05:00
Evan JarrettandClaude Opus 5 0ea0e5494c deploy: bound the scanner's memory and yield the box to the hold
The scanner shares a 1 GiB host with the hold, and nothing stopped it taking
the whole box down with it.

max_image_size was absent from the template, so production ran the 2 GiB
default. Peak RSS tracks image size and a 389 MiB compressed image already
reaches 561 MiB, so 512 MiB is the ceiling that fits. Sizing matters in this
order: max_image_size rejects before a byte is downloaded, while MemoryMax
kills mid-scan, and the hold then returns the row to pending and the restarted
scanner is handed the same job again, which is a permanent crash loop on one
image. If the cgroup cap is ever seen firing, lower max_image_size rather than
raising it.

GOMEMLIMIT is soft: the runtime collects harder to respect it and never fails
an allocation to honour it. With no cgroup cap the kernel picks its own OOM
victim, and the other large process on that host is the hold, so the scanner's
overshoot could kill the service it reports to.

CPUWeight and IOWeight, because no unit here set any scheduling directive: a
scan finishing a minute later costs nothing, a hold that cannot answer a pull
costs a user. Weights apply only under contention, so the scanner still uses
the whole box when nothing else wants it.

workers drops to 1. Proactive dispatch was depth-one hold-wide until this
release so the second worker never received anything, and now that it can, two
concurrent scans of a node:22-class image need 687 MiB against a host this
size. Raise it with MemoryMax and the host together.

Note the cgroup ceiling still wants rechecking: it was sized from measurements
taken with Grype disabled, and the vulnerability database's resident pages
count against MemoryMax even though GOMEMLIMIT cannot see them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
2026-09-05 15:01:27 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-05 15:01:10 -05:00
Evan JarrettandClaude Opus 5 f16a8eaa82 appview: warn about a deprecated chart on the tab people actually land on
8487258 added the deprecation notice to the shared helm metadata partial, on
the understanding that both the Overview and Chart tabs rendered it. Only the
Chart tab does. The Overview panel renders the README and never touches chart
metadata, so a deprecated chart carried no warning on the default landing tab.

The notice is extracted into a shared helm-deprecation-notice block, so the
copy lives in one place, and the Overview panel renders it server-side as the
first element in the panel. Deprecation decides whether you should use the
chart at all, so it belongs in the first paint rather than arriving a beat
later from a lazy fetch.

Getting the data there costs nothing extra. The page already made a blocking
hold call for layer count, and for a chart that call was wasted: a helm config
blob is Chart.yaml, which has no history key, so the count always came back 0
and fell through to the database. That call is now FetchHelmChartMeta instead,
against the same XRPC endpoint, so a chart page makes one hold call rather than
two and the displayed layer count is unchanged. A container image never fetches
chart metadata and its path is byte-for-byte the old code.

Failure follows the layer-count precedent: log at warn, leave the metadata nil,
render the page. An unreachable hold means no notice, not a broken repository
page. The tradeoff against the lazy version is that a slow-but-up hold now
delays the whole page, bounded by the same 10s the page already accepted.

A container image emits no element at all rather than an empty one, so the
space-y-4 stack spacing is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 22:37:18 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 22:31:27 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 22:31:27 -05:00
Evan JarrettandClaude Opus 5 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 dfd604b rather than duplicating it. That guard lives in the scanner
after a job is created and dispatched, and catches unscannable work from any
source including the hold's proactive discovery pass. This one stops the row
being created at all, which matters because the row that froze all scanning for
nine days was exactly such an attestation. One gap neither closes: an
attestation with tar-shaped layers pushed by an old appview.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 22:31:04 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 22:31:04 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 22:30:37 -05:00
Evan JarrettandClaude Opus 5 a95c89aaef docs: record how a hold falls off the relay and stays off
Around 2026-08-20 the Bluesky relay marked every ATCR hold offline and stopped
dialing. Nobody noticed for two weeks, and it surfaced only indirectly as "pull
and push counts are up to 24 h stale". The stats were a symptom; the fleet was
simply disconnected.

It cannot recover on its own. Indigo's relay gives up on a host after 16
consecutive dial failures and returns from the redialer, and only a fresh
requestCrawl revives it. The hold sends requestCrawl exactly once, at boot
(server.go:400), with no ticker and no check that any relay is subscribed. So a
dropped hold is invisible until its process restarts, silently.

Documents the current mechanics, the failure mode, how to diagnose it with
getHostStatus and a frozen repo rev, and how to recover. The automatic
re-crawl is described as a deferred proposal and explicitly NOT implemented,
by decision: a jittered ticker guarded on subscriber liveness, plus surfacing
the subscriber count, since the deeper problem is that this was silent.

Two things found while writing it, both recorded. The proposal needs plumbing
that does not exist: EventBroadcaster has no exported subscriber count, and
Subscriber does not retain the userAgent, so "is a relay listening" cannot
currently be answered. And ResubscribeAllHosts selects only active hosts, so an
offline host is not recovered even by a relay restart.

Carries a replay warning. ca539b1 fixed a panic on subscriber disconnect during
firehose backfill, and the exposure condition is that a backfill goroutine
exists at all, which Subscribe skips when the cursor is current. So a caught-up
relay never triggered it and a hold whose relays are far behind is exposed on
every reconnect. Verify a deployed hold contains ca539b1 before provoking a
re-crawl; efabb677 does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 22:30:37 -05:00
Evan JarrettandClaude Opus 5 25e2aa0228 docs: design note for artifact type classification
Finding 32: pushing an artifact with an unrecognised config media type
classifies as "unknown", and every template branches two ways on "helm-chart"
with the container-image page in the else. So an in-toto attestation is served
a docker pull command, Layers/Vulnerabilities/SBOM tabs, "Image layer history",
and a promise that scans run shortly after push, which for that artifact will
never be true. This is a design note rather than a fix, since the change is
larger than the symptom.

The inventory is the part worth keeping. The classification rule exists in four
places, keyed off three different inputs (appview config media type, hold config
media type with no unknown case, scanner config map plus layer shape, hold layer
substrings), and the appview's artifact_type feeds none of the scan decisions.
Manifest-level artifactType is discarded at parse time on every push: it is
absent from the record struct, the constructor and the lexicon, surviving only
inside the unindexed manifest blob.

Two corrections to the framing this started from, both verified rather than
assumed. The repo does not have referrers support: the pinned distribution
version has no referrers code and ATCR registers no such route, so what exists
is subject_digest persistence plus an attestation badge. And GetTopLevelManifests
filters artifact_type != 'unknown', so an untagged unknown artifact is invisible
while a tagged one renders as an image, which the finding did not mention.

Proposes a type set, spec precedence (manifest artifactType, then config media
type, then structural signals), a UI contract stating what such a page must not
show, and a six stage plan. Only stage 2 needs a migration, for the raw string
column; new slug values need no DDL and no data migration, since jetstream
upserts artifact_type on every record it sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 21:43:09 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:38:21 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:38:10 -05:00
Evan JarrettandClaude Opus 5 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 af7522b, which is the
intended effect. Its per-entry verdict is unchanged: public entries survive,
private ones are dropped, and an all-private request still gets the challenge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 21:37:56 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:37:56 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:37:38 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:37:38 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:37:19 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:12:13 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:08:00 -05:00
Evan JarrettandClaude Opus 5 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 cbd0c5f came about.

Without this both fixes are invisible in a served page, and the crane one is
half-live in the worst way: first paint carries the destination because that
comes from the Go template helper, while the dropdown re-render does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 21:03:29 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:03:20 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:03:01 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:02:50 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:02:40 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 21:02:28 -05:00
Evan JarrettandClaude Opus 5 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
2026-09-02 20:21:33 -05:00
Evan JarrettandClaude Opus 5 74778bdd05 appview: apply the mockup-code contrast fix to the seamark theme
606338b fixed the hero tagline in pkg/appview/templates, but seamark.dev
renders themes/seamark/templates/components/hero.html, a May fork of that
file that already carried `text-base-content/65` at the time. So the commit
titled "fix mockup-code contrast in both themes" measured and corrected a
file the deployed theme never loads, and the tagline stayed dimmed.

The /65 also lands differently depending on how the theme was chosen. The
page only stamps data-theme once someone picks a theme explicitly, and the
explicit-light palette differs from the system-light default. Measured on
the live page with axe 4.10.2: system default 4.99:1 (passing by 0.49),
explicit light 4.20:1 (failing), explicit dark 5.99:1. That gap is why a
clean Lighthouse profile reported color-contrast clean while a browser with
the toggle set to Light reported the failure.

Dropping the class puts the tagline at full base-content, same as the two
command lines above it: 14.02:1 system, 10.85:1 explicit light, 12.01:1
dark. The gutter `#` is untouched and stays de-emphasized at 70% via the
unlayered rule from 606338b.

Templates are embedded, so this needs an appview rebuild to ship.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124r73LT4qoFE82TqwH2Gu9
2026-09-02 20:12:08 -05:00
Evan JarrettandClaude Opus 5 cbd0c5f05c appview: rebuild the JS bundle so the committed asset matches src
The tracked bundle predated a219df9, so it carried neither the
alert-error match in testWebhook nor plainTextReason's 400 branch. Any
deploy that copied the committed asset without regenerating would have
shipped the old JS and findings 13, 15 and 17 would have looked unfixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPWkeCKcbtGoyXyyeMhSps
2026-09-02 19:55:04 -05:00
Evan JarrettandClaude Opus 5 27fab41c1c appview: make the webhook cap agree between creation and delivery
The UI and the dispatcher asked the same question and got different
answers. getWebhookLimits short-circuited on a disabled billing manager and
returned unlimited without consulting it, while server.go hands
BillingManager.GetWebhookLimits straight to NewDispatcher, bypassing that
short-circuit entirely. With billing compiled out the stub answers
non-captains with (1, false).

So a non-captain saw "N / unlimited webhooks configured", could create as
many as they liked, and only the oldest was ever delivered. allTriggers was
false on that same path, so even the surviving one was restricted to
FreeTriggerMask; a webhook set to a scan trigger fired nothing at all, with
no message anywhere and only an INFO line server-side.

Route both paths through the manager so they cannot drift. The intended
non-billing policy is one webhook with TriggerFirst | TriggerPush |
TriggerQuota, which is what the stub already returned and what the shipped
config's Free tier specifies (max_webhooks: 1, webhook_all_triggers: false),
so enabling billing leaves free users exactly where they were and only
unlocks upward. That also removes a downgrade cliff: nobody can accumulate
webhooks under a phantom unlimited and lose them when billing turns on.

Drop the dead webhookLimits{Max: 1}, overwritten on the following line, and
give a nil manager the same policy rather than a third answer.

Latent, not live: production has zero webhooks configured today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk
2026-09-02 12:45:15 -05:00
Evan JarrettandClaude Opus 5 62aea5f3f0 credhelper: print the verification URL that carries the code
The interactive prompt named codeResp.VerificationURI while openBrowser was
handed verificationURL, the one with ?user_code= appended. Pressing Enter
therefore always worked, which is why this went unnoticed; copying the
printed URL instead landed on /device with no code.

That matters more than it looks, because the branch tests the wrong thing.
isTerminal(os.Stdin) asks whether stdin is a TTY, not whether a browser
exists, so an SSH session on a headless box takes the headed path and is
told to press Enter to open a browser it does not have. The non-interactive
branch, which already printed the full URL, is only reached by piping stdin.
Printing the code-carrying URL in both branches makes that mismatch moot
rather than requiring a smarter predicate.

Also give /device without a code the styled device-error page instead of
bare text/plain, matching the expired-code path beside it, and stop
renderError panicking when Templates is nil.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk
2026-09-02 12:45:04 -05:00
Evan JarrettandClaude Opus 5 a219df9545 appview/js: fix webhook test result, 400 toasts, and Layers tab init
Three unrelated client-side defects found while baselining production.

The webhook Test button always reported success. renderAlert writes no
status code, so both outcomes are HTTP 200 and the result lives in the
markup, which partials/alert.html emits as "alert alert-error". testWebhook
looked for class="error", which that string does not contain, and resp.ok
is always true, so the failure branch was unreachable. A webhook pointed at
a dead URL was reported as delivered. Match alert-error instead.

Avatar upload rejections lost the server's reason. The htmx:responseError
handler maps status codes to fixed strings and had no 400 case, so
"File too large (max 3MB)" and "Invalid file type" both surfaced as
"Something went wrong". Surface the body when it is short plain text; a
rendered error page or a long trace is not toast material.

The repository page's Layers tab was never initialised. initLayersTables
runs from DOMContentLoaded, when the panel is still a spinner, and from
htmx:afterSettle, which htmx.process() does not emit. So empty-layer hiding
and no-history run collapsing never ran there, and the checkbox claimed
rows were hidden while all of them were on screen. Export it and call it
from the tab controller. It now also re-seeds checkboxes within the loaded
scope, which fixes the digest page contradicting itself: the stored
preference was honoured for the rows and ignored for the control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk
2026-09-02 12:45:04 -05:00