mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-04 09:16:56 +00:00
cbd0c5f05cab2ba7ec57bb51797f53af3f0744c7
501
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cbd0c5f05c |
appview: rebuild the JS bundle so the committed asset matches src
The tracked bundle predated
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
1253ca15ec |
appview: report a never-scanned image as unscanned, not as a failure
digest_content.go branched on Error == "never-scanned" to pick the "not scanned yet" copy, but nothing anywhere produced that string: both FetchVulnDetails and FetchSbomDetails returned the human sentence "No scan record found" for a missing record. So vulnReason and sbomReason could never be "not-scanned", the friendly branches in vulns-section.html and sbom-section.html were dead code, and every unscanned image fell through to fetch-failed. Free-tier accounts have scan_on_push off, so this was every image they push, told "Scan data couldn't be loaded... try refreshing in a minute" about something that had never been scanned and never would be by refreshing. The digest page showed the raw internal string instead. Replace the prose sentinel with a NotScanned bool the 404 path actually sets, and give other non-200 statuses a distinct message so a 500 from the hold stops being indistinguishable from an absent record. The detail templates branch on it before Error, so nothing leaks the internal value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk |
||
|
|
27ce122db0 |
auth: stop logging an unresolvable hold DID at ERROR
A hold DID that can never resolve is a property of stored user data, not a fault on our side. The value comes from a user's own sailor profile defaultHold, so any account can choose the appview's ERROR volume, and nothing is cached on the failure path, so it re-logs on every request for that user. On production this was not a rounding error: two accounts pointing at did:web:localhost%3A8080 produced 2956 of 2958 ERROR lines over seven days, 99.9%. The genuine rate underneath was about two a day, which made level=ERROR useless as a signal or an alert threshold. Classify at the resolution boundary instead of string-matching prose. ErrHoldDIDPermanent marks a malformed identifier or a missing DID document; those log at DEBUG while everything an operator could act on stays at ERROR. didWebHostUnusable is conservative on purpose: it only claims the cases we are sure about (percent-encoded ports, bare IPs, localhost), so an unfamiliar failure stays loud rather than being quietly swallowed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk |
||
|
|
a4aedbd2c8 |
gc: retry transient PDS failures instead of pinning a user's storage
Both paginated walks bailed on the first error, and the caller treats a failed walk as "assume everything is referenced", so one blip skipped that DID's storage for the whole run. Measured before changing anything: every DID GC had classified unreachable but healthy failed only one or two runs out of six, and replaying the exact same listRecords calls afterwards returned 200 in 45-680 ms with no rate limiting. Ordinary blips on small self-hosted PDSes, amplified into a full-DID skip. Share one listRecordsPage helper between fetchUserTags and fetchUserManifestsFromEndpoint. The retry decision splits deliberately: timeouts, connection reset, 5xx and 429 get another attempt, while DNS failure, TLS failure, connection refused and any 4xx do not. Those are stable facts about an endpoint, and retrying them would only slow the run and keep a dead PDS looking alive longer. Unrecognised errors stay permanent, so an unfamiliar failure degrades to today's behaviour rather than hammering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk |
||
|
|
c36e90f6b7 |
hold/admin: swap out the deleted crew row instead of sending 204
The delete handler returned 204 No Content for htmx requests, on the theory that an empty body plus hx-swap="outerHTML" would make the row disappear. htmx's default responseHandling maps 204 to swap:false, so it never swapped at all: the record was gone from the PDS but the row stayed on screen until a manual refresh. Return an empty 200, which htmx does swap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ai43R3s33cBGMybGp2gUcG |
||
|
|
2a58ccebd8 |
hold/gc: name the third ownership state instead of encoding it as a lie
|
||
|
|
264d332bbd |
hold/gc: stop an unreachable hold's manifests from being adopted
|
||
|
|
15871ad188 |
deps: update all modules, bump go and builder images to 1.26.7
Update every direct dependency across all five workspace modules to latest. Notable jumps: syft v1.43.0 -> v1.51.1, grype v0.111.1 -> v0.118.0, stereoscope v0.1.23 -> v0.3.1, indigo -> 2026-09-01, aws-sdk-go-v2/service/s3 v1.99.1 -> v1.110.0, grpc v1.80.0 -> v1.83.2, x/crypto v0.50.0 -> v0.55.0. Three deps needed more than a version bump: go-libipfs could not be updated at all. The repo was renamed to boxo, so every tag past v0.7.0 declares `module github.com/ipfs/boxo` and cannot be required under the old path. sqlite_store.go already imported go-block-format alongside it and used the archived package exactly once, inside a function already returning blockformat.Block, so it was relying on structural interface satisfaction. Collapsing to the native type drops the archived dependency entirely. go-didplc moved its package from the repo root into a didplc/ subdir in v0.2.2. Package name is unchanged and every symbol we use (RegularOp, OpEnum, OpService, Client.DirectoryURL, Submit) is intact, so this is an import path change only. The go-diskfs replace in scanner/go.mod had inverted. It pinned v1.7.0 because syft v1.43 passed diskfs entries as os.FileInfo; syft v1.51.1 fixed that upstream and now requires v1.9.4, so the workaround had become the thing breaking the build. Removed per its own "Remove when syft ships a fix" note, closing anchore/syft#4796 for us. The indigo bump needed no code changes: of the 21 packages we import only 5 changed, and the repo/MST/CAR-store core is byte-identical. It does bring a util/ssrf fix blocking 6to4 addresses (2002::/16), which we inherit through atproto/auth/oauth. Go 1.26.7 across go.work, all five go.mod files, the four Dockerfiles, the three tangled workflows, and the stale references in docs/DEVELOPMENT.md. Verified golang:1.26.7-trixie resolves on mirror.gcr.io, which is what the Dockerfiles actually pull from. Makefile's TRIXIE_BUILDER_IMAGE stays on the floating golang:1-trixie. make test, make lint, and make test-race all pass, as do the scanner module's tests and the integration-tagged build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWoKzpgtBJ33sCyGxJGR7x |
||
|
|
606338b33e |
appview: fix mockup-code contrast in both themes
daisyUI dims the gutter prefix with `opacity: .5`, which multiplies against whatever opacity the line's own text color carries. On bg-base-300 a plain `$` measured 3.20:1 in light and 4.19:1 in dark, and the `#` on a line already dimmed to /70 compounded to 2.15:1. The text needs 4.5:1, so this failed in dark mode too, just less visibly than the light-mode report that surfaced it. Give the prefix an absolute muted color instead of a multiplying opacity. The override has to sit unlayered: daisyUI ships this selector in `@layer daisyui`, declared after `@layer components`, so a rule in components loses on layer order however specific it is. A first attempt inside components left daisyUI's `opacity: .5` live on top of the new 70% color, which made the `$` worse (0.5 -> 0.35 effective) rather than better. The hero tagline drops its /70 and now reads at the same weight as the docker commands above it. install.html's two comment lines were at /50, which failed on the text itself (3.20:1 light), and move to /70 where they still read as comments. Measured in Chromium against the built stylesheet, compositing each pseudo-element color over its real background on a canvas: every prefix and comment is now 5.84:1 light / 6.71:1 dark, against 14.03:1 / 12.02:1 for the command text, so the gutter stays visibly de-emphasized. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpqkSRqpcnAjsSZgFTUN5z |
||
|
|
ee9ccc1fb8 |
appview: drop crossorigin from the imgs.blue preconnect
Connections are keyed by credentials mode. The crossorigin attribute opened the imgs.blue socket in anonymous-CORS mode, but every request to that origin is a plain <img src> avatar fetch (BlobCDNURL / resizeImage) in no-cors mode, so nothing could reuse it. The browser opened a second connection anyway and Lighthouse flagged the hint as unused while still listing imgs.blue as a preconnect candidate worth ~300ms of LCP. The attribute is correct for the font preloads below, which is where it was likely copied from; comment the distinction so it stays put. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpqkSRqpcnAjsSZgFTUN5z |
||
|
|
521cf143e5 |
appview: cover the half of 13edb71 that had no tests
That commit throttles two writes and states a statement order. Only one of the three claims was defended. touchLastSeen is the hotter of the two writes — it ran once per indexed record, so a busy firehose meant a database round trip per event for a timestamp read in hours or days. Deleting its throttle outright left every existing test green, as did keying it globally instead of per DID, which would let one busy account suppress every other account's first write. Both now fail. The statement order is the third claim: UpdateLastUsed stamps the throttle before the write rather than after, so a slow or failing write cannot let every concurrent caller through to queue another attempt behind it. That matters because this runs on the authentication path, once per layer during a push, and the pile-up is worst exactly when the database is least able to absorb it. A failing write makes the ordering observable without timing anything: with the stamp after the write every call retries, with it before only the first does. Dropping the table leaves no row to inspect, so the attempts are counted through the warning the function already logs. Under the reordering it reports 10 attempts across 10 calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011rmjvU2gSRL9wFnmqVsWaF |
||
|
|
ff0942196e |
db: cover the orphan drop in 0034, the case production actually presents
TestMigration0034PreservesLayersAndReferences seeds no orphan and says why: the live foreign key refuses to create one, so the migration's join through manifest_id is "insurance for a database whose foreign keys were off at some point, not for anything reachable now". Production is that database. It carries 160 layers and 22 manifest_references pointing at manifests.id values that no longer exist, left by deletes performed under mattn/go-sqlite3, where the constraint the DDL declared was not enforced. libSQL turns foreign keys on by default and mattn did not, so the rows predate the driver swap. The insurance is load-bearing on the only database that matters, and nothing tested it. The new case rebuilds the pre-0034 shape with the child foreign keys absent, which is what that era's schema behaved like, and seeds three orphaned layers and two orphaned references beside live ones. It asserts the orphans are gone, the live rows survive attached to the right key, nothing lands keyless, and foreign_key_check is clean afterwards. Verified against the defect: with the child manifest_key made nullable and the joins turned into LEFT JOINs, all five orphans survive and the test fails on both counts. Recorded honestly, the two guards are redundant with each other — LEFT JOIN alone still drops them, because INSERT OR IGNORE swallows the NOT NULL violation. Only removing both carries an orphan forward, and such a row counts, selects, and joins to no manifest ever again. Confirmed on a copy of the production database: layers 18803 -> 18643 and manifest_references 2175 -> 2153, exactly the rows the join excludes, with manifests unchanged at 3864. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011rmjvU2gSRL9wFnmqVsWaF |
||
|
|
8c9a85826d |
appview: stop leasing the billing tier refresh, and let it be cancelled
|
||
|
|
f0b28c04f5 |
db: cover the DID guard on the batched tag delete
TestDeleteTagsNotInListScopedToDID passes a nil keep list, which takes the
early return and deletes with `DELETE FROM tags WHERE did = ?`. So the scoping
it proves is the shortcut's, not the one inside the chunk loop that
|
||
|
|
fa6473a896 |
appview/holdclient: cover the tier fan-out itself, and its failure path
The existing tests covered updateCrewTierWithRetry and UpdateCrewTierOnHold. UpdateCrewTierOnAllHolds -- the function the Stripe webhook actually calls, and whose error decides whether a paid upgrade is retried or dropped -- had none. Three cases: the joined error names every failing hold and not the one that succeeded; a hold that accepts and never answers does not starve the holds after it (mutation-verified by making the fan-out serial, which leaves the healthy hold contacted zero times); and a context deadline aborts the retry loop rather than running to tierUpdateMaxAttempts. That last one records a real mismatch rather than an intent. Three attempts at a 5s client timeout need ~15s, and the webhook allows the whole fan-out 10s, so under a hang the budget funds two attempts and never three -- confirmed against a blackholed hold on the dev stack, which failed at exactly 10.0s with a bare context error rather than the "after N attempts" wrapper. If either constant or the deadline moves, that test is where the arithmetic gets re-checked. Also covers the other half in pkg/billing: a fan-out failure has to reach Stripe as a 5xx and leave stripe_processed_events empty. A hold that is briefly down otherwise costs the customer their tier permanently -- the same shape of loss as the customer-lookup hole, one layer further out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB |
||
|
|
4dd473bbf1 |
hold/pds: cover HandleUpdateCrewTier, which had no test
The hold end of the billing fan-out had no test at all -- only the ErrCrewMemberNotFound sentinel was covered. Its answer decides whether the Stripe webhook records an event as processed or retries it, so each status it can return means something different upstream and is covered separately: the applied path (asserting the stored crew record actually changed, not just the response body), not-crew as a successful no-op, the 403 on a body userDid that disagrees with the signed subject, an empty body userDid falling back to the token subject, 401 unsigned, 400 with no tiers configured, and rank clamping. Each was mutation-verified. One of them corrected the test's own comment: removing the 403 guard does not let a body retarget a grant, because every step after it keys off the token's sub claim and req.UserDID is read nowhere else. The guard makes a disagreeing body loud rather than silently ignored, and the stored-tier assertion is the regression guard for the day something reaches for that unsigned field when it needs "which user". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB |
||
|
|
3263156067 |
billing: key entitlements on the Stripe product, not the price
Tier resolution matched a subscription's price ID against the configured stripe_price_monthly/stripe_price_yearly, which inverts what a price change is supposed to do. Stripe prices are immutable, so changing what a tier costs means creating a new price, and Stripe never migrates existing subscribers off the old one. Updating the config to the new price IDs therefore un-tiers precisely the subscribers a price change is meant to leave alone. They did not even drop cleanly to free. An unresolved tier logs a warning, returns nil, and the event is recorded in stripe_processed_events -- so Stripe answers 200, never redelivers, and a later dashboard Resend is swallowed by the idempotency check. Reproduced against the sandbox: a subscription on a price the config does not list granted nothing, and the event could not be replayed afterwards. A tier has one product and many prices over its life, so the product is the durable key for an entitlement. Tiers gain a stripe_product field, and resolution tries the product first, falling back to the price IDs so configs without it keep working unchanged. Checkout still keys on price -- that direction has to name a specific price to charge. Verified live: a subscription on a price created outside the config, under the Pro product, resolved to tierName=Pro tierRank=2 and landed on the hold. The same shape with an unknown product produced the silent no-op before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB |
||
|
|
4b9d4bcbeb |
billing: retry a failed customer lookup instead of dropping the subscription
getCustomerDID returned "" for a FAILED customer.Get exactly as it does for a
customer carrying no user_did. handleSubscriptionChange read that as "not our
customer" and returned nil, so HandleWebhook recorded the event as processed
and answered 200. Stripe never redelivered. A transient Stripe API error
therefore dropped a paid upgrade permanently — the precise "paid but never
received tier" hole
|
||
|
|
894dd243da |
hold/admin: cover the top-users panel 7d9de7c fixed
|
||
|
|
f4d0c8bf05 |
auth: verify the hold before caching a captain record on the third path
|
||
|
|
5112425673 |
appview: make the footer Bluesky link configurable
The link was hardcoded before |
||
|
|
17e25a4df2 |
appview: guard the tag-listing paging that gates a shared-record delete
The delete path decides whether an io.atcr.manifest record is still wanted by
enumerating the DID's tag records. That enumeration is load-bearing in a way a
tag listing usually is not: records for every one of a DID's repositories share
one collection, so a single page is a per-account budget rather than a per-repo
one. Past it a live tag falls off the end, the digest reads as unreferenced,
and the shared record is deleted out from under a repository nobody touched —
along with its layers on the hold.
Two cases, both mutation-verified:
* the tag that keeps the digest alive sits on page 3. Stopping after the
first page deletes the record. cleanupUntaggedManifest has carried this
hazard in a comment since
|
||
|
|
0b212a527f |
appview: guard the two UI delete paths against cross-repo destruction
Same defect as the OCI path, two more places. The io.atcr.manifest record is
keyed by digest alone, so one record backs every repository of a DID holding
identical content, and both of these deleted it without asking whether another
repository still wants it — then purged the layers on the hold.
DeleteManifestHandler already removes this repository's tags before deleting
the record, so a tag remaining at that point can only belong to another
repository. It now checks IsManifestTaggedAnyRepo there and keeps the shared
record when one does, reporting sharedRecordKept so the caller can tell the
difference between "deleted" and "deliberately left alone".
DeleteUntaggedManifestsHandler is the subtler one. Its digest list comes from
GetAllUntaggedManifestDigests, whose tag join is scoped to one repository
(m.repository = t.repository), so a digest tagged only in a DIFFERENT
repository is reported as untagged and swept. The query is a reasonable
per-repository view and a dangerous delete list; the guard goes at the delete,
not in the query, matching how DeleteTagHandler already works. Skips are
counted separately from failures, because a skip is the guard working and
folding it into "failed" would make a correct run look broken.
Both fail closed. Leaving a manifest behind is recoverable; deleting one
another repository is still serving is not.
The new db test pins both halves of the interaction: that the query really does
report a cross-repo-tagged digest as untagged, so a change there is noticed,
and that IsManifestTaggedAnyRepo answers DID-wide, which is the thing actually
standing between the sweep and another repo's live image.
DeleteTagHandler needed no change —
|
||
|
|
701c866723 |
appview: stop an OCI manifest delete destroying another repo's image
The io.atcr.manifest record is keyed by digest alone (digestToRKey), so one
record backs every repository of a DID holding identical content.
ManifestStore.Delete removed it unconditionally and then called
purgeDeletedManifest, which asks the hold to drop the layer records and free
the blobs. Deleting through one repository therefore stripped the manifest out
from under every other repository sharing that digest and took their bytes with
it — reachable from the public OCI API with `crane delete`, and not recoverable.
Reproduced end to end before fixing: push identical content to shared-a and
shared-b, delete shared-a by digest, and shared-b:v1 answers 404.
The codebase already had the right guard and the right policy written down.
|
||
|
|
ae1d7ba626 |
auth: test NarrowToPullOnly, the function that gates the anonymous path
NarrowToPullOnly had no test. IsPullOnlyScope has a thorough one, but it only answers yes/no — NarrowToPullOnly rewrites the access list, so what it emits is what gets signed, and anonymous tokens skip the authgate entirely. Nothing downstream re-authorizes what this function decides to hand out. The load-bearing property is the allowlist: "pull" is the only action that can survive. Beyond the per-case assertions, every case re-checks that no other action reached the output, so a new case cannot accidentally assert its way past the property the function exists to hold. The wildcard cases are the point. A wildcard action means "any action" to distribution's actionSet.contains, so expanding "*" into "pull" is the single rewrite that would turn a wildcard request into a grant. Mutation-verified: * treat "*" as pull -> the three wildcard cases fail * stop narrowing the actions -> the four narrowing cases fail * trim the action slice in place -> DoesNotMutateInput fails That last one initially did NOT fail, and the fixture is why. The input had "pull" first, so an in-place trim writing "pull" into index 0 changed nothing observable and the test passed against the exact defect it was written for. "pull" is now deliberately not first, with a comment saying so, because the ordering is the whole instrument here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SeaUS5AFPX9gqCahoLRMRh |
||
|
|
915d9adcb2 |
test/e2e: cover the /auth/token surface, and correct 9d4ad84's provenance
batch09-token.sh drives the request shapes against a running stack, which is where the interesting part of |
||
|
|
85a07d660a |
appview: give the O(n) bcrypt scan a guard that can actually fail
TestDeviceStore_ValidateDoesNotScanIndexedRows is documented as "the
regression guard for the O(n) bcrypt scan", but it does not observe whether a
scan happened. It asserts that every row is indexed and that an unknown secret
errors, and both hold with or without the fix. Deleting the
`WHERE secret_lookup IS NULL` filter from the fallback query — which is the
defect
|
||
|
|
ce01e47ba6 |
auth: cover the two batch-09 commits that shipped without tests
|
||
|
|
2984331f0c |
hold/gc: test the blob sweep, and give the S3 mock object ages
deleteOrphanedBlobs is the only part of GC that removes bytes and it had no test. It could not have had one: object age decides whether a blob is deletable, the in-process harness stamps every object with time.Now(), and MockS3Client's ListObjectsV2 set no LastModified at all. Neither could express "this blob is nine days old", so both halves of the grace rule went unexercised — including the half that protects a push still in flight. MockS3Client gains ObjectTimes, a per-key LastModified consulted by ListObjectsV2. Keys with no entry list without a timestamp exactly as before, so existing tests are unaffected. It also gains DeleteObjectError, matching the error injection the other operations already had. Four cases, three of which are reasons NOT to delete: an old unreferenced blob goes; a young unreferenced blob stays; a referenced old blob stays; a /link object is never treated as a blob. Plus a failure case pinning that one undeletable object does not abort the walk, and that a blob which never left storage is not counted as deleted or reported as reclaimed space. Verified by mutation. Disabling the grace check deletes the young blob; disabling the referenced check deletes the live one; both fail. Disabling the /data suffix check changes nothing, because extractDigestFromPath anchors on /data$ and rejects everything else — so that check is a redundant early-out rather than a guard, and the test says so rather than implying otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4542897f08 |
hold/gc: escape the DID in listRecords/getRecord repo parameters
fetchUserManifestsFromEndpoint, fetchUserTags and fetchUserProfile
interpolated the user DID straight into a query string. A did:web carrying a
port spells that port as a literal %3A, so the PDS received the parameter
decoded back to ":" — a different DID, matching no repo. listRecords then
answers 200 with an empty list and GC reads it as "this user has no
manifests": every blob they own drops out of the referenced set and is
deleted once past the seven-day blob grace.
There is no error and no status code to notice, which is the same soft
failure
|
||
|
|
891ad01de3 |
hold/gc: require a predecessor's successor to name this hold
|
||
|
|
48eee49ef9 |
hold/gc: cover checkPredecessorAt and the unresolved-holds reset
|
||
|
|
bb45a80d51 |
hold/pds: cover the scanner-disconnect teardown 05b856b fixed
newTestScanBroadcaster builds the struct with only a database, so nothing in
the suite ever reached handleWriter, handleReader, or the Unsubscribe teardown
they share — which is precisely what
|
||
|
|
8cd59a61f1 |
appview: stop a UI session outliving the OAuth session behind it
Only one oauth_sessions row is kept per account, so signing in again — on a
second device, or simply a second time — replaces it and leaves every earlier
ui_sessions row pointing at an oauth_session_id that no longer exists. Get
checked only expiry, so those still read back as usable.
Found on a live appview: four ui_sessions rows, three orphaned, and requesting
/settings/user with an orphaned cookie returned 200 with the account's handle
rendered throughout, where an anonymous request gets a 302. The browser looks
signed in while the credential behind it is gone, so every PDS-backed action
fails against a UI insisting the session is fine. It now fails closed and sends
the user back through login.
Get also never checked ownership. oauth_sessions is unique on
(account_did, session_id), so the existence check is scoped by both; matching
session_id alone would let one account's live OAuth session validate another
account's dangling reference. That has its own test.
An empty oauth_session_id stays valid, since Create makes sessions that never
had one, and a test pins that so the check cannot start rejecting them.
TestSessionStore_CreateWithOAuth referenced an OAuth session it never inserted,
which is an orphan by definition, so it now creates the row. Its intent was
that CreateWithOAuth persists the ID; it relied on the orphan behaviour only
incidentally. Its not-found branch used t.Error and then dereferenced the nil
session, so that is now t.Fatal.
Pre-existing at
|
||
|
|
c9f8b4178c |
credhelper: stop dev builds nagging about an update to themselves
isNewerVersion split versions on "." and ran each component through
strconv.Atoi, discarding the error and substituting 0. For a git-describe
build the last component is "4-18-g8f70cce", which does not parse, so it
became 0 and every published release compared as newer. Running
v0.1.4-18-g8f70cce printed "Update available: v0.1.4" on every single
invocation, naming a version the binary was already 18 commits past.
Versions are now parsed properly: the "-<commits>-g<sha>" tail is recognised
and kept as a count of commits past the tag, and a version that cannot be
read in full returns false rather than being silently treated as 0. That
second part is the actual root cause — the comparison could not distinguish
"this component is zero" from "I could not read this component".
Ordering for a git-describe build is deliberately not semver, where a
prerelease sorts below its release. Such a build is commits AHEAD of its tag,
so v0.1.4-18-g8f70cce is newer than v0.1.4 and older than v0.1.4-20-gabc1234.
The function had no tests. Both failing cases are pinned along with the
ordinary release comparisons, so the git-describe handling cannot regress the
normal upgrade path.
Pre-existing at
|
||
|
|
f8d9ad7fe9 |
appview: let a registry domain keep its port, and its /v2
DomainRoutingMiddleware normalized the request Host to a bare hostname but
matched server.registry_domains verbatim, so any configured domain carrying a
port could never match. config-appview.example.yaml ships
`registry_domains: [127.0.0.1:5000, atcr.io]`, which means that entry has been
inert since it was written.
It fails closed in the worst way. That same host is also the auto-detected UI
host, and `host == uiHost` was evaluated first, so /v2/* was answered with
"registry API is not available on this domain, use 127.0.0.1:5000" — naming the
exact host the client had just used. The registry API is unreachable on the dev
stack, and any single-host deployment hits the same wall: listing a host in
registry_domains does nothing if it is also the UI host.
Both sides are now normalized through hostWithoutPort, and a registry domain
takes /v2/* even when it doubles as the UI host, which is a legitimate
single-domain deployment. Everything else is unchanged: a UI-only host still
refuses /v2/, registry domains still redirect non-/v2 traffic to the UI, and
/auth/token and /auth/device/* are still served directly so a cross-host 307
cannot strip the Authorization header.
hostWithoutPort uses net.SplitHostPort instead of the previous LastIndex(":")
scan, which mangled bracketed IPv6 literals into "[::1" and could never match
the "::1" that url.URL.Hostname() yields for the UI host.
The middleware had no tests at all. The two failing cases are pinned first, and
the four pre-existing behaviours are pinned alongside them so the reorder
cannot quietly widen what /v2/ is served on.
Pre-existing at
|
||
|
|
4c04983e23 |
appview: stop the backfill claiming every user was just active
last_seen means "this user did something recently". The backfill walks every historical record in the network, so stamping it there recorded when the backfill ran, not when the user was active — for every user at once, on every run. That destroys the only signal the column carries, and it is the one column in users that nothing upstream can rebuild. It is now written on the two paths that represent real activity: an interactive login, and a live commit event on the firehose, which does mean the user just wrote a record. The backfill still corrects handle, PDS endpoint and avatar, which is why it re-resolves rather than trusting a cache; it just no longer claims the user was present. UpsertUser grows an options form rather than a fourth named variant, since the avatar and last_seen decisions are independent and all four combinations occur. Anyone computing MAU from this column should know it was unreliable for every backfill run before this change. Also corrects docs/HORIZONTAL_SCALING.md, which claimed oci_client and registry_domain were local-only preferences. They are fields on io.atcr.sailor.profile: settings writes them to the user's PDS and ProcessSailorProfile refreshes the local cache. users is fully derived apart from last_seen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
13edb7184d |
appview: stop writing last_seen and last_used on every event
Neither is a correctness problem; both are round trips on hot paths for timestamps nothing reads at that resolution. UpdateUserLastSeen ran per Jetstream event for cached users, so once per indexed record. DeviceStore.UpdateLastUsed ran per /auth/token call, so once per docker push and pull including each layer's re-auth. Cheap against a local file, a network round trip each against a remote primary, and the second sat on the authentication path. Both are now throttled to once per five minutes per subject. The MAU queries and the admin views work in hours or days, so nothing loses meaning. The throttle state is per-process and lost on restart, costing at most one extra write per subject per boot; only the lease holder runs the consumer, so exactly one process is doing the first of these at a time. UpdateLastUsed stamps the throttle before writing rather than after, so a slow or failing write cannot let every concurrent layer upload through to pile on more of them. Verified by disabling the throttle: 50 back-to-back calls then rewrite the timestamp every time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e9d43aa767 |
db: cover the recency change on all four surfaces, not one
The MAX(id) replacement touched four queries that each carry their own copy of the same CTE: SearchRepositories, GetRepoCards, GetUserRepoCards and GetStarredRepoCards. Only the third had a test. Fixing one and missing another would leave the UI disagreeing with itself about which manifest is current, depending on which page you were looking at. All four now assert that recency follows created_at rather than insert order, and that a tie between manifests pushed in the same second resolves the same way on every surface. Verified by regressing the CTEs back to rowid ordering: each of the four fails independently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
454a6bad3d |
db: key manifests by manifest_key and drop the rowid
Completes the swap 0033 set up. layers and manifest_references move onto manifest_key and manifests.id is gone, which removes the last node-allocated identifier in the AppView schema. Statement order in 0034 is load-bearing. With foreign keys on, DROP TABLE performs an implicit DELETE FROM, so dropping manifests while layers still holds an ON DELETE CASCADE reference deletes every layer row. Migration 0009 did exactly that; it went unnoticed because the Jetstream backfill rebuilds layers from PDS records, so the damage healed itself. PRAGMA foreign_keys is no help: it is a no-op inside a transaction and migrations run in one. So the new children are built pointing at manifests_new, the old children are dropped first, and only then is the old manifests table dropped, by which point nothing references it. Verified both behaviors before relying on them. manifest_key is declared NOT NULL as well as PRIMARY KEY, because in SQLite a PRIMARY KEY column still accepts NULL unless it is INTEGER PRIMARY KEY. That constraint immediately caught four test helpers inserting manifests without one. Five queries used MAX(id) as "the newest manifest in this repo", which I had previously reported as absent after grepping only for ORDER BY. A derived key has no ordering, so recency now comes from created_at with manifest_key as a deterministic tiebreak. This is a real behavior change, and a fix: the two disagree whenever a manifest is indexed out of order, which the backfill does routinely, and created_at is the push time these queries always wanted. Both directions are tested, including that ties resolve the same way every run. InsertManifest and BatchInsertManifests no longer read anything back. The key is derived from (did, repository, digest), so the writer knows it before the statement runs: the select-back, its per-DID IN list, and the "manifest missing id after batch insert" branch all go away, along with the UNIQUE-conflict fallback that existed only to recover a rowid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
11b85e5102 |
db: fill manifest_key inside its migration, not at runtime
0033 added the column and left the fill to the Jetstream backfill. That is fine for a running system and wrong for replay: a database several releases behind runs every pending migration back-to-back at boot, long before any worker starts. Anything built on top of manifest_key would, on that path, silently operate on NULLs while working perfectly on a system that had been up for a while. Establishing a migration's data precondition out-of-band means replay cannot see it. The runner now supports a Go step per migration version, running inside the same transaction as that migration's SQL, after the DDL it depends on and before the version is recorded. A version is never recorded without its Go half. 0033's step fills manifest_key for every row lacking one. It has to be Go: the value is a truncated sha256 and SQLite has no hash builtin. It pages through the table and writes one UPDATE ... CASE per 500 rows, because a statement per row would be correct and unusably slow against a remote primary. Verified by unregistering the hook: replay then leaves 3 of 3 seeded manifests with NULL keys. With it, all three are filled, from a snapshot of the pre-0009 schema forward. The upserts keep their "OR manifests.manifest_key IS NULL" clause. It is a self-healing net for rows that somehow arrive without a key rather than the mechanism anything depends on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
802cc4ba96 |
db: add manifest_key and let the existing backfill populate it
First half of replacing manifests.id with a node-independent identity. Nothing depends on the column yet: id is still the primary key, and layers and manifest_references still reference it. Getting the column in place and filled first means the eventual swap operates on data that is already complete and already proven unique, instead of doing the fill and three table rebuilds in one step. The value cannot be computed by the migration. It is a truncated sha256, SQLite has no hash builtin, and go-libsql exposes no way to register one. So the fill uses machinery that already exists: the Jetstream backfill re-upserts every manifest across the protocol on startup, and both upsert paths now write manifest_key. That only works because of one extra clause. Both upserts guard their DO UPDATE with a WHERE that skips rows where nothing changed, which on a backfill re-run is nearly every row, so they would have skipped the very manifests that need filling. Adding "OR manifests.manifest_key IS NULL" is what makes an otherwise no-op pass populate the column. Verified by removing it: the backfill then fills zero of three manifests instead of three of three. The index is UNIQUE even though the column is nullable. SQLite treats NULLs as distinct, so unfilled rows coexist while every filled row is checked. That makes production data verify the 16-byte truncation rather than us assuming it: if two manifests ever derived the same key, it fails loudly at insert instead of silently attaching one manifest's layers to another after the swap. AppView logs the unfilled count at startup, since there is no single moment at which this becomes complete and the follow-up migration is only safe at zero. ManifestKey replaces the old fat "did|repo|digest" map key rather than sitting beside it; they were always the same question, answered without asking the database. The jetstream tests hand-maintained their own CREATE TABLE statements, which is the drift problem moved into a test: the copy had already fallen behind (it still had tags.id) and only failed once a query touched the difference. They use db.InitDB now, with foreign keys switched off to preserve the behavior the hand-rolled schema had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f186760847 |
db: drop the vestigial tags.id
Nothing joined on it. It was selected into a struct field no caller read, and used only by DeleteTagsNotInList, which fetched surrogate ids, filtered them in Go with a nested loop over the keep list, and issued one DELETE per row. The natural key was already enforced by UNIQUE(did, repository, tag), so that becomes the primary key and the column goes. An AUTOINCREMENT rowid is allocated by whichever node performs the insert. That is fine while every write funnels through one writer and stops being a stable identity the moment they do not, so removing an identifier nobody used is the cheapest way to shrink that surface before local-write replicas. DeleteTagsNotInList now diffs against a set and deletes in batches. It still reads the current tags first rather than issuing one NOT IN over the keep list: that would need two placeholders per kept tag and would break past the driver's parameter ceiling for a user with enough tags, and it cannot be chunked, because each chunk would delete the tags every other chunk meant to keep. An explicit delete list chunks safely. idx_tags_did_repo is dropped rather than recreated: the new primary key indexes (did, repository) as a prefix. It existed only because the primary key used to be the surrogate id. The rebuild names its columns explicitly. Column order is not guaranteed to match between a fresh install and a migrated one, so INSERT ... SELECT * here could write values into the wrong columns. TestMigration0032PreservesTagRows runs the migration body against a table in the old shape and checks the contents survive, which the schema drift test cannot: it compares shape, not data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e75b2e246b |
oauth: compare-and-swap session writes so a concurrent refresh cannot delete a live session
Refresh tokens rotate on use, and DoWithSession serializes refreshes per DID with an in-process mutex. That is the right mechanism and it protects nothing once there are two instances: both can refresh the same account at the same time, the slower one presents a refresh token the auth server has already superseded, gets invalid_grant, and isAuthError deletes the session. The user is signed out mid-push, and the session another instance had just legitimately refreshed is destroyed along with it. oauth_sessions gains a rev that increments on every write. A store that has read a session writes with a compare-and-swap against the revision it read and gets ErrSessionRevConflict if anyone wrote first, so a stale writer can no longer replace rotated tokens with invalidated ones. The persist callback treats that conflict as an ordinary outcome rather than an error, since leaving the newer state alone is exactly right. The delete path is now guarded by the same signal. An auth error on a session whose revision has moved since we read it means "someone else refreshed this", not "this session is dead", so it retries once against the newer tokens instead of deleting. Exactly once: a second failure means staleness was not the problem, and looping would hold the per-DID lock while getting the same answer. The guard is deliberately conservative. A store without revisions, no recorded revision, a failed lookup, a session that is simply gone: all answer "not advanced" and keep the previous delete-on-error behavior. Wrongly claiming a concurrent refresh would keep a genuinely dead session alive with no way out but waiting; wrongly missing one costs a re-login. The sentinel lives in pkg/auth/oauth rather than next to the SQLite store, because pkg/appview/db already imports pkg/auth/oauth and the other direction would be an import cycle. The db package re-exports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
934e4a2a59 |
appview: stop the crypto key first-boot race
Two instances booting against a fresh database both find no key, both generate one, and both write. PutCryptoKey was last-writer-wins, so the loser kept its own key in memory while the database held the other's. It then signed OAuth client assertions with a key absent from the published JWKS, and issued registry JWTs that did not match the certificate written to disk. Every one of them fails verification, and nothing logs why. PutCryptoKey now keeps the first write, and both loaders re-read afterwards and use whatever is stored. Nothing in the codebase rotates a key through this function, so the update arm only ever fired on the race. Verified against the old behavior: with last-writer-wins restored, three of six concurrent loaders returned a key that was not the one in the database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
985ebd3a5f |
auth: make the crew denial counter atomic
cacheDenial read denial_count, incremented it in Go, and wrote the result back.
Two overlapping denials for the same (hold, user) both read the same value and
both wrote the same value, so one increment vanished. The effect is that the
backoff ladder advances more slowly than configured, which means a denied client
keeps hammering the hold's PDS for longer than intended. Already reachable
across goroutines on one instance; routine with several behind a load balancer.
It is now a single INSERT ... ON CONFLICT DO UPDATE that increments in place.
next_retry_at moved into SQL as well, derived from the count the same statement
is producing, rather than computed in Go from a count that may already be stale
by the time the write lands. The CASE ladder is generated from
dbBackoffDurations so configuration still drives the backoff, and no request
data reaches the string.
The measured difference, with 20 concurrent denials: the old code recorded 12
where it should have recorded 21, losing 9. The new code loses none.
The surviving SELECT only picks a branch (first denial goes to memory only), so
a stale answer costs at most one skipped or one extra write, never a count.
Two implementation notes. datetime() truncates to whole seconds and the backoff
ladder is sub-second in tests, so timestamps use
strftime('%Y-%m-%dT%H:%M:%fZ', ...) instead; libSQL normalizes that to RFC 3339
and it scans back into time.Time with the right instant, which was verified
before relying on it. And a one-rung ladder emits a bare number rather than a
CASE, because "CASE ELSE x END" with no WHEN arm is a syntax error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
182a5463d6 |
auth: stop wiping the shared denial cache on every boot
ClearAllDenials ran unconditionally at startup, and its database half is "DELETE FROM hold_crew_denials" with no scoping at all. One instance, that is a clean slate on deploy. Several instances, and a rolling deploy wipes the shared table once per instance while every scale-out event wipes it again, so the backoff that exists to stop a denied client hammering a hold's PDS keeps getting reset out from under it. The intent is worth keeping: a restart usually means a fix shipped, and someone sitting on a backoff of up to an hour should get to retry rather than wait it out. So it moves under the cleanup lease instead of being deleted, and now happens once per deploy rather than once per instance. Worth noting the in-memory half was always a no-op here. recentDenials belongs to the process, and a process that has just started has an empty one, so the table-wide DELETE was the only thing the startup call ever really did. The cleanup worker moved down past the hold authorizer's construction, since it now needs a handle on it. Reading s.HoldAuthorizer from the worker goroutine while the constructor was still assigning it would have been a data race. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |