Bitkit sign-in hardening: hand-off confirmation, grant cleanup, lapsed claims, rate limits - #112
Merged
BitcoinErrorLog merged 17 commits intoSep 24, 2026
Conversation
…be saved A failed BrowserSessionStore save left the approved grant live on the homeserver and a partial record plus the flow key in IndexedDB. The session is now signed out, then every grant key is removed under the finalization lock, and the save error reaches the caller.
The grant leg returned before the discard that bounds the cookie leg, so a hand-off captured on the same load could survive into a later restore after the grant signed out.
The URL carries the relay channel secret. It is drawn into the QR only; the data-auth-url attribute is gone, so a CSS attribute selector cannot read it. The launch-critical suite reads the URL through the copy button instead.
A claimer that died between acquire and complete left the flow in claiming, and every poll answered claim_in_progress until the cleanup job ran. A poll now abandons a claim whose lease lapsed (database clock) and asks for a fresh approval.
…ng its context A context that could not be opened left the cancelled flow awaiting. The flow is now terminal first, so no later poll can claim it.
Challenge creation is unauthenticated and names any pubky, so a bucket keyed by pubky alone let a third party spend the owner's bucket. Browser and CLI challenges now key it by IP and pubky.
…ts step-up refusal A grant approval or stored grant narrower than CAPABILITIES is signed out and refused, so a grant session never reaches the capability-based needs_reauth state. A 401/403 on the private document of a grant session no longer reports needs_reauth: a step-up widens scope and cannot repair a refused grant. MarketplaceReauthDialog loses its unreachable grant branch; R3.9a is not built, and step-up-approval.md records why.
Any page can link to the Shop with #s=<export> for a session whose cookie the browser holds, and nothing binds the link to this device. The fragment leg now asks the user to confirm the named pubky. Not me, Escape, or a logout declines it and suppresses the bridge leg for the tab; with no confirmer the hand-off is declined.
… empty A save failure whose key removal also failed dropped the obligation: no record pointer, no marker, so the partial record and delegated key stayed at rest. A durable marker is now set before every grant save and cleared only when the save lands or BrowserSessionStore reads back empty. The next load, the next Bitkit sign-in (before its new flow key exists) and every sign-out retry it. A signed-in grant session in any tab defers the sweep to its own sign-out.
Challenge creation is unauthenticated and may name any pubky, so any bucket keyed by pubky (alone or with the client IP) can be spent by someone else: a peer behind the same NAT still locked the owner out. The per-IP bucket alone bounds creation; clients behind one egress share it, which is documented and pinned by a test. SHOP_BFF_CLI_GRANT_CREATE_PER_PUBKY_PER_MINUTE is no longer read.
On Vercel the challenge buckets read the leftmost x-vercel-forwarded-for hop. Vercel writes that header itself, but a proxy that appends leaves client-written hops on the left, so the rightmost hop is the one used now. A rotating client-written hop no longer escapes the per-IP bucket.
Grant sessions are held to the full Shop grant at sign-in and restore, so the re-auth dialog has no state that opens it for one. getStepUpAuthUrl now also refuses a grant session before any Ring flow starts, so a future grant path that skips those checks cannot swap the grant session for a cookie session.
Grant approvals narrower than CAPABILITIES are now refused, as a real Bitkit approval never is.
BitcoinErrorLog
force-pushed
the
cursor/bitkit-grant-backlog-240a
branch
from
September 24, 2026 16:26
bd227fe to
b656309
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the review backlog of the Bitkit grant sign-in (Shop v0.6.24/25): six Kimi P3s from the PR 2/PR 3 audit, the R3.9a P2 from the code review, and the
#s=hand-off item from the sign-in QR incident audit. Every item is either fixed with a test that fails on revert, or decided with the reason recorded below. One Kimi finding (cancel after key rotation) was re-scoped after tracing the code; the reasoning is in its row.Base:
release/shop-v0.6.8ata26333e2e(includes #110 and #111). Head:b656309aa7be7adbc4f00aceab8fb30e9aadf65f.Backlog
saveGrantSessionleaves the approved grant live and a partial record plus the flow key in IndexedDB0d53eca0adiscardFragmentSessionExportfinallydiscards the cached#s=export, the same bound as the cookie leg.2b3a7d0d7data-auth-urllaunch-critical.mjs.7d8e29aebacquireCliClaimand complete/abandon strands the flow inclaiminglease_until <= now()) and answers422 fresh_approval_required. A live lease still answers409 claim_in_progress.65f6708dfhashBoundCookie) and the context seal use the same key epoch. So when the epoch leaves the config, cancel fails at the cookie check before it ever opens the context. Such a flow cannot be claimed either, and the cleanup job expires it. Terminalizing it without the cookie would let anyone holding a state id end another user's flow, so that case is left as is and pinned by a test. What did leave a flow un-terminalized is a context that fails to open for another reason (result_denied). Cancel now terminalizes first, then opens the context.15af2e8786a60b15fc,9390b585ba3833566f#s=hand-off can restore a session without confirmationa5a2109a6ed0e90cedadds the changelog fragment.255157ee3is formatting and import order only.b656309aagives #111's side-by-side Bitkit test fixture the full Shop grant, which the full-grant check now requires.R3.9a decision
A Bitkit sign-in requests exactly
CAPABILITIES. This PR makes that a guarantee rather than an assumption:AuthApplication.assertFullGrantSessionsigns out and refuses an approved grant session narrower than the Shop grant. A stored grant that restores narrower is signed out and its record removed. So every live grant session can write/priv/pubky.app/, and capability-basedneeds_reauthcannot occur for one.The other trigger for
needs_reauthis a 401/403 on the private document. For a full-grant grant session, that means the grant itself is refused (revoked in Bitkit, or expired). A step-up widens scope; it cannot repair a refused grant. So a grant session's refusal now reportserrorfor watchlist sync (the outbox job stays pending) andunavailablefor receipts, and both retry on the next load.MarketplaceReauthDialogrenders only inneeds_reauth, so its grant branch was unreachable and is removed;AuthController.getStepUpAuthUrlstill refuses a grant session (revision below).docs/ecommerce/step-up-approval.mdrecords this, and ADR 0029 records the#s=confirmation.Contract rows (§6d)
info.capabilities≠CAPABILITIESuseAuthUrlcopy)an approval narrower than the Shop grant is signed out and never savedapproveAuthRequest)a real full-grant approval passes the full-grant check and signs inCAPABILITIESa stored grant narrower than the Shop grant is signed out and its record removedBrowserSessionStore.saverejectsa failed save signs the grant out, removes its keys and surfaces the failure#s=captured on the same loadgrant restore drops a #s= export captured on the same loaddata-auth-urlholds the full URLkeeps the relay secret out of the DOM markup;renders QRCodeSVG and ring logo when url is availableclaimingflow whose lease lapsed409 claim_in_progressuntil cleanupabandoned;422 fresh_approval_requireda claim whose lease lapsed ends the flow and asks for a fresh approval; real Postgreslapsed-claim-sql.logclaimingflow with a live lease409 claim_in_progressa claim with a live lease stays in progress403, flow staysawaitingcancelled, then403 result_deniedcancel ends the flow even when its context cannot be opened422 fresh_approval_requireda flow whose key epoch rotated out can be neither cancelled nor claimed429 retry_later(per-pubky bucket)challenges a NAT peer creates for someone's pubky never lock the owner outx-vercel-forwarded-forhop429 retry_laterrotating a client-written forwarded-for hop does not escape the per-IP bucketa grant session is refused a step-up and no Ring flow startsneeds_reauth(dialog with refusal copy)error, receiptsunavailablea grant session refused with 403 fails the round…;…reports unavailable, not a step-upneeds_reauthflips to needs_reauth when the actual write is refused with 403#s=<export>that restores, user answers Continuepubky+ 8…8a #s= hand-off waits for the prompt and only a yes passes through; staging Chromium#s=user answers Not me / Escapea declined #s= hand-off restores nothing and turns the bridge off for the tab;Not me declines…;dismissing the dialog declines…#s=with no confirmer (Application default)a #s= hand-off is declined when no one can confirm itlogout declines an unanswered #s= promptIdentity predicates: the hand-off prompt names the pubky of the session the export restores (
Identity.z32FromSession). Accepting it runs the existing restore finalization, which compares against the persistedcurrentUserPubkyand cleans up on a different identity (unchanged). No new connected/owner/can* predicate is added.isCurrentSessionGrantreads the live store session, the same source ascanCurrentSessionWrite.Implementer self-check
PREPUSH OK dc3b11bcf1998c9175d082ea3cf693ed22a6f79f 1472(the seconds include waiting on the shared heavy-job lock). Prettier (27 files), eslint (24 files), typecheck, and vitest related (424 files, 6,547 tests) passed; Linux VRT on 58 specs passed in the pinned container. Log:/Volumes/vibedrive/vibes-dev/.evidence/bitkit-grant-backlog-240a/prepush-2.log. A full unit-project run on the pre-format tree was 15,161 passed, 2 skipped.pubky-marketplace-stagingdeploymentdpl_AgZ4CANJAuSKULsb68deiMo7QtnNbuilt from the pre-rebase commit2afd59d4awithNEXT_PUBLIC_VIBE_SESSION_BRIDGE_ORIGIN=https://pubky.app: 17/17 PASS, covering the#s=prompt, Not me, reload, Continue, sign-out withDELETE /session 200,#s=replay after sign-out, the Bitkit full-grant sign-in, reload, and sign-out. Log:/Volumes/vibedrive/vibes-dev/.evidence/bitkit-grant-backlog-240a/chromium-proof.log. Screenshots:chromium/02-handoff-prompt.png,chromium/04-handoff-accepted-signed-in.png,chromium/08-bitkit-signed-in.png. Calibration: the same script (sha256d3f9072a…) fails on the unfixed release head5e7fc0e64, built the same way and deployed asdpl_GmB5Q8A4VTfh8HLyAjWu54Ec6CrQ. It reportsdata-auth-url=true secretInHtml=trueon the QR, and the#s=link signs the tab in silently as the seat with no prompt (chromium-proof-base-fail.log,chromium-base-fail/02-handoff-no-prompt.png). It then passes 17/17 again on the branch deployment. The settled prompt frame ischromium/diag-handoff.png. The review revision and the rebase onto fix(marketplace): settle the inventory grant in launch-e2e #110/Sign-in: Ring and Bitkit QRs side by side, profile prefill, clear quota error #111 are not re-proven on staging; the targeted checks in the revision section and the release deploy cover them.Loggercalls use static messages. Arguments are only caught error objects (logoutError,error), the existing pattern; no id, pubky, query value, or URL.mutations.logshows 15/15 mutations caught, and the revision adds 11/11 (mutations-r2.log,mutations-r3.log). Each fix reverted fails its named test, and the source is restored byte-for-byte with the test passing again.lapsed-claim-sql.logshows the abandon SQL against real Postgres 17 with the repo migrations: lapsed abandoned, live left, second call a no-op, table CHECKs hold.Staging
The staging alias was on
dpl_GXBWJBkmvoZF4EREUu41mLKTbuhVbefore this work and is promoted back to it after the proof (staging/target-confirmation.md,staging/promote-back.log). No project env var was changed; consumer mode was a build-only override.Revision after the Sol review (REVISE) and Kimi audit (SHIP)
pubky-grant-key-cleanup-pending-v1) is set before every grant save and cleared only when the save lands orBrowserSessionStorereads back empty. It is retried on the next load (RouteGuard, after hydration), at the next Bitkit sign-in (before its new flow key exists), and on every sign-out. A signed-in grant session in any tab defers the sweep to its own sign-out.2bc65d16aa failed save whose key removal also fails keeps the cleanup, and the next Bitkit sign-in empties the store first;the next load removes grant keys a failed cleanup left behind;a pending cleanup leaves a signed-in grant session alone until its sign-out removes every key;a successful grant save drops only the marker it set; RouteGuardretries a pending grant-key cleanup once the auth store has hydratedcreateCliChallengeand pinned by a test.SHOP_BFF_CLI_GRANT_CREATE_PER_PUBKY_PER_MINUTEis no longer read (not set on staging).9390b585bchallenges a NAT peer creates for someone's pubky never lock the owner out; documentation testthe per-IP bucket is shared behind one NAT (accepted limit of unauthenticated creation)x-vercel-forwarded-forhop (the one the platform writes; Vercel overwrites the header, per its request-headers docs). It never uses a leftmost hop. Off Vercel it was already the hop the configured trusted proxies appended, and with zero trusted proxies every client shares one bucket.0645f3cbbrotating a client-written forwarded-for hop does not escape the per-IP bucketAuthController.getStepUpAuthUrlrefuses a grant session before any Ring flow starts; the dialog shows the error.step-up-approval.mdrecords it.a2a2e07aca grant session is refused a step-up and no Ring flow startsKimi's P3 that a failed save wipes another tab's healthy grant is covered by the Sol P1 change: that cleanup now goes through the same sweep, which leaves the store alone while any tab's signed-in grant pointer is persisted.
Rebase onto
release/shop-v0.6.8ata26333e2e(#110, #111): the old launch-critical harness commit is dropped, and #110'slaunch-critical.mjsis kept unchanged.getGrantAuthUrlkeeps #111's two-flow arbitration.settlePendingGrantKeyCleanup()runs before the new flow key is created.assertFullGrantSession(session)runs after #111's first-winner check and beforegrantEpochAtStart.set(...).Checks for this revision and the rebase are targeted, not the full gate (
targeted-rebase.logatb656309aa): typecheck, eslint and prettier on every changed file, and 34 test files / 520 tests across the auth controller and application, the single-approval side-by-side suite, RouteGuard, marketplace-grant, commerce watchlist/receipts, QrCodeSlot, SignIn, Scan, DialogSessionHandoff, MarketplaceReauthDialog, useAuthUrl, and vibe-session.Follow-ups (not in this PR)
lapsed-claim-sql.log); add a DB-boundary test.DialogSessionHandofffails to mount.#s=captured on that load can prompt on /logout.HomeserverService.isCurrentSessionGrantreads the auth store from the service layer (same pattern ascanCurrentSessionWrite).cli_rate_buckets.bucket_key(TTL-pruned; pubkys no longer appear there since the per-pubky bucket is gone).