Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-25-live-grant-lifecycle"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements the Option D redesign ratified in #25, closing the token-TTL bug #24 and the materialization-drift family behind it.
The bug (#24)
Backend.applyTokenphotocopied a token's policy rules intoSigningConditionrows at redeem.checkIfPubkeyAllowedmatched those rows (step 3) and short-circuited before the liveTokenjoin (step 4) — so an expired or revoked token kept signing forever, because the copy carried no lifecycle. The live join existed but was dead code for token-paired users, and even it only filteredrevokedAt, neverexpiresAt.Root cause: a cache (the materialized
SigningConditionset) with no invalidation. The same shape produced siblings — token-revoke (spirekeeper#22) and unenforced usage caps. Upstream Signet re-shipped the identical bug (see the prior-art survey doc), which is what convinced us to fix the family, not the instance.The fix (Option D)
grantIsLive(grant, now)— the single "valid right now?" predicate (revokedAtnull ∧ not pastexpiresAt), extracted tolib/acl/lifecycle.tsand used identically at redeem (Backend.validateToken) and sign (checkIfPubkeyAllowed). The two can no longer drift — that drift was the root of #24.Backend.applyTokenrecords only theKeyUser ← Tokenbinding. No more photocopy. The token's policy is evaluated live, every request.checkIfPubkeyAllowedstep 4 filters tokens throughliveWhere(now)(revoke + expiry) and grantsconnectoff a live bound token; the manual-override layer (step 3) now honorsSigningCondition.expiresAt/revokedAttoo, with denials beating grants.A new lifecycle rule is now one more predicate, never a forgotten photocopy.
Commits
docs(#25)— lnbits/nostr_bunker comparisondocs(#25)— source-verified ACL prior-art survey (Signet/Amber/FROSTR/promenade/NDK) + keep-our-fork decisionfeat(schema)—Request.keyUserId+SigningConditionlifecycle columns (additive migration20260619125847_live_grant_lifecycle_schema)fix(acl)— the live-enforcement gatekeeper rewrite + de-materializationtest(acl)— extract puregrantIsLive/liveWhere+node:testunit tests (npm test→ 7 passing)Verification
npm test→ 7 passing (boundary conditions: past expiry → dead, expiry == now → dead, revoke beats future expiry,liveWherepinned in lockstep withgrantIsLive).tsc --noEmit→ no new errors (3 pre-existing, unrelated, inauthorize.ts/web/authorize.ts).npm run build(tsup/esbuild) → clean.Deferred (follow-ups, called out deliberately)
PolicyRule.maxUsageCountis still unenforced (as before — no regression). Doing it right needs a durable record of allowed signings toCOUNT: todayRequestrows are ephemeral (only written on manual approval, auto-deleted after 60s) andLogis never written. The schema groundwork (Request.keyUserId+ index) is in place. Needs its own issue.checkIfPubkeyAllowed(expired/revoked token denied end-to-end, override lifecycle, connect-off-live-token). Blocked on adding a real test harness —pnpm add -D vitestfails against the nix-builtnode_moduleshoist pattern, so this PR shipsnode:testunit coverage of the predicate only. Needs its own issue.Notes
SigningConditionrows from already-redeemed tokens. A clean dev DB is assumed; leftover rows have no provenance flag to target.get_public_keybypasses the permit seam).🤖 Generated with Claude Code
prisma migrate/validatein the dev shell #30Review — approve with nits
Faithful implementation of the Option D direction from #25. The core fix is correct and well-built:
grantIsLiveis the single predicate used at both redeem (validateToken) and sign (checkIfPubkeyAllowed), de-materialization is complete, and the live token join now filters on expiry+revoke — which actually closes #24. The findings below are one deploy-safety gate, one testing gap, and two pre-existing things this PR is well-placed to clean up. None block the design; #1 should be resolved before merge.What's right
grantIsLive/liveWhereextracted to a pure, DB-freelifecycle.ts, used identically at redeem and sign — exactly the "define valid-right-now in one place" discipline #25 demanded. Boundary semantics are consistent (<= nowdead in the predicate ↔gt: nowlive in the where-fragment), with a test pinning the shape so the two can't silently drift.applyTokenrecords only the binding; the per-rule +connectphotocopy loop is gone. Multi-token users are now handled correctly — an expired token stops matchingfindFirst({keyUserId, ...live})while a second live token still pairs, which the old flat photocopy couldn't express.liveDenythenliveGrant); the bootstrap path (allowAllRequestsFromKey(..., {kind:'all'})) still matches viakind: {in:[K,'all']}.Findings
1. (Resolve before merge — deploy safety) Leftover materialized rows silently re-open #24 on any non-clean DB. The migration recreates
SigningConditionand copies existing rows forward (INSERT INTO new_SigningCondition SELECT … FROM SigningCondition). Any token redeemed before this PR leftallowed=truephotocopies withexpiresAt=NULL→ those are now permanent live manual-grants via step 3b, bypassing the new live token check entirely. So for already-paired clients the bug persists, invisibly. The pre-launch clean-DB assumption is consistent with our strict-from-the-start posture, but two asks:2. (Medium — testing) The actual #24 fix is untested; only the pure predicate is. Good boundary coverage on
grantIsLive/liveWhere, but the wiring that closes the bug — step 4 applyingliveWhereto theTokenjoin so an expired/revoked token is denied at sign time — has no assertion. For a security fix I'd want at least one end-to-end "expired token →checkIfPubkeyAllowedreturns falsy". Deferred for a real reason (vitest vs the nixnode_moduleshoist), but I'd treat it as a high-priority follow-up, not a parked one — the predicate passing tells us little about whether the query is shaped right.3. (Low — pre-existing, but this is the moment) The explicit-reject sentinel doesn't match its writer. Step 3a queries
method: '*', and the docstring attributes it torejectAllRequestsFromKey— but that function writes a row with nomethod(null), and appears to have no callers. So "reject all" looks like it's been a no-op. Since the new docstring re-asserts themethod='*'invariant, either wire reject-all correctly (writemethod:'*') or drop the dead branch so comment matches reality.4. (Low — confirm intent) Expired manual denies now go inert. Applying
...liveto the deny queries means a lapsed deny stops denying — correct under D1, but it means a lapsed deny could re-expose access a token grants. In practicerejectAllRequestsFromKey/web-approval denies are written with null expiry, so they stay permanent; only an explicitly-TTL'd deny lapses. Just confirming that's intended (I believe it is).5. (Nit)
Request.keyUserId+ index ship ahead of their consumer (usage-caps deferred). Fine as groundwork; just noting it.Recommendation
Land after resolving #1 (clean-DB gate → verify/wipe, in the merge steps) and deciding #3 (wire or remove reject-all). #2 as a high-priority follow-up issue; #4 a one-line confirm. The design is right, and the inline comments are genuinely excellent — they tie each change back to #24/#25 so the next reader inherits the reasoning.
Thanks — sharp review. Addressed below; pushed
7dcf97a.#1 (deploy safety) — investigated + merge checklist added
Verified the local dev bunker DB (
prisma/nsecbunker.db):.tablesis empty — uninitialized, noTokentable, no redeemed tokens, noSigningConditionrows. So there's no leftover-photocopy masking risk locally, and the local clean-DB assumption holds.I can't reach the regtest / any deployed dev bunker DB from here, so per your ask I'm moving the wipe/verify out of prose into an explicit gate. Merge checklist:
SELECT COUNT(*) FROM Token WHERE redeemedAt IS NOT NULLandSELECT COUNT(*) FROM SigningConditionare both0.SigningCondition(or the whole bunker DB on a pre-launch instance) before deploying — otherwise pre-PR pairings keep signing via permanentexpiresAt=NULLgrants and mask the fix (clean new pairings look correct while old ones bypass the live check).Strict-from-the-start / pre-launch means a DB wipe is acceptable; the gate just makes sure we don't deploy onto a mixed state that hides the regression.
#3 (dead reject-all) — fixed in
7dcf97aConfirmed your read exactly:
rejectAllRequestsFromKeywritesmethod=null(never'*') and has zero callers;revoke_userdoes subject-level revoke viaKeyUser.revokedAt(step 2). So step 3a could never match. Removed the dead step-3a branch and the orphaned function so the code matches reality. Per-(method,kind) denies (step 3, written byadd_signing_condition) are untouched.#4 (expired denies go inert) — confirmed intended
Yes. Verified in source:
add_signing_conditionand the web-approval path write conditions with nullexpiresAt/revokedAt→ permanent, never lapse. Only a deliberately TTL'd deny decays — which is the D1 behavior we want (a lapsed grant and a lapsed deny both fall back to the live evaluation). Default denies stay sticky.#2 (untested wiring) — agreed, bumping priority
You're right that the predicate passing says little about whether the query is shaped right — the actual #24 closure (step 4 applying
liveWhereto theTokenjoin) is the untested part. Tracked in #29; I'll treat it as high-priority, not parked. The blocker is real (pnpm add vitestvs the nixnode_moduleshoist pattern, +.js-specifier resolution under CommonJS), and #29 spells out the harness path and the exact case list (incl. the expired-token regression guard and the "applyToken writes no SigningConditions" assertion).#5 (groundwork ahead of consumer) — acknowledged
Request.keyUserId+ index ship for the deferred usage-cap work (#28); intentional groundwork, no consumer yet.Net: #1 is now a checklist gate rather than an assumption, #3 is fixed, #4 confirmed. Re-running locally after the change:
npm test→ 7 passing,tsc→ only the 3 pre-existing unrelated errors,npm run build→ clean. Ready for re-review / merge at your discretion (I'll leave the merge to you per the Forgejo-UI ritual).Note: the "clean DB assumed" stance has a shelf life (it ends at launch / #18)
This PR's remediation posture — strict-from-the-start, assume a clean DB, no migration of historical materialized rows — is correct now, but it's a property of being pre-launch, not a durable guarantee. Flagging it explicitly so a future deploy doesn't lean on it after it's expired.
It holds only while both are true:
nsecbunker.json(config), not the DB; policies are trivially recreatable; there's no audit trail to retain.Confirmed on cfaun (first real instance)
Non-clean (15 redeemed tokens, 390
SigningCondition), but a pure photocopy state: allallowed=1, all on token-paired KeyUsers, one sharedPolicy(25 rules),User/Request/Logempty, keys + admin in config (keysmatchesSELECT DISTINCT keyName FROM Token). SoDELETE FROM SigningCondition;is equivalent to a full wipe here, and a full wipe + re-pair is also harmless. The review's merge-checklist gate applies per-instance.Where the stance expires — the #18 endgame
Once LNbits operators and the server identity route signing through this bunker, neither condition survives:
KeyUser.id; the FK isON DELETE SET NULL, so deleting+recreating a binding nulls/orphans the history even though the client's npub is unchanged);At that point the only correct path is a provenance-aware data migration (keep manual overrides + bindings, drop only derived photocopies) plus a zero-downtime cutover — not a wipe.
Ask
The provenance-aware migration needs a flag this PR doesn't add. Before launch, either:
SigningCondition(e.g.source: 'token' | 'manual') so derived rows are targetable by a real migration, orCross-refs: #28 (durable log is what makes this urgent), #29 (integration harness), #18 (the endgame that triggers the expiry).
npm run prisma:migratestep in start.js #31Correction: on LNbits-connected instances, the remediation is targeted delete only — never a full wipe
The review checklist and my shelf-life note both offered "wipe
SigningConditionor the whole bunker DB" as equivalent remediations. On any instance where LNbits is the bunker client, they are not equivalent — a full wipe breaks signing and forces identity-changing re-provisioning. Learned the hard way deploying this to cfaun today; details so the next deploy doesn't repeat it.Why a full wipe is wrong here
The nsecbunkerd↔LNbits pairing is split across both systems:
nsecbunker.db): theKeyUserbinding (keyed by LNbits's stable client pubkey) + the redeemedToken.accounts.signer_config,RemoteBunkerSigner):{ token, client_nsec, policy_id }.At runtime,
RemoteBunkerSigner.sign_event()sendssign_eventdirectly with the storedclient_nsec— noconnect/re-redeem — relying on the bunker binding already existing.provision()runs only at new-account creation; there's no auto-repair, and restarting LNbits does not re-pair. So a full bunker-DB wipe deletes theKeyUserbindings → LNbits's stored configs dangle → every account fails to sign. Re-provisioning isn't a clean fix either: the standardprovision()callscreate_new_key→ a new npub, changing the user's identity.The correct remediation
This is safe precisely when the rows are pure token photocopies (all
allowed=1, all on token-paired KeyUsers, no manual overrides) — which is exactly what cfaun had (390 rows = 15 clients × (1 connect + 25 policy-rule grants), 0 denies, 0 non-token rows).cfaun worked example (2026-06-19)
_prisma_migrationshad20260619125847_live_grant_lifecycle_schema); the daemon's ownnpm run prisma:migrateENOENTs harmlessly (#31). Schema correct, data intact:KeyUser=15, Token=15, SigningCondition=390, Policy=1.nsecbunker.dbfrom backup, then ran the targetedDELETE FROM SigningCondition;instead. Result:KeyUser=15, Token=15, SigningCondition=0, Policy=1, all 15 tokens live (nullexpiresAt/revokedAt). LNbits signs again with zero re-provisioning, and #24 is closed (grants now come from the live token join,SigningConditionstays 0 after a fresh pairing).Checklist correction
DELETE FROM SigningCondition;(never a full DB wipe). Take a DB backup first so a mis-step is recoverable.nsecbunker.dbbefore either; keys live innsecbunker.jsonconfig and are never at risk from a DB operation.(Reinforces the shelf-life point above: once LNbits is a client, "re-pairing is cheap" no longer holds — the pairing state is split and a wipe is destructive. The provenance-flag ask stands.)
expiresAt(TTL) is not enforced post-bind — sign-time ACL ignores it #24prisma migrate/validatein the dev shell #30