fix(acl): hard-reject a lapsed token binding instead of prompting (#36) #38
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-36-expired-binding-hard-reject"
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?
Closes #36.
Problem
checkIfPubkeyAllowedreturned the sameundefinedfor two very different situations:(method, kind), andundefinedroutes the caller into the admin-prompt path. For an interactive operator that's fine; for an unattended client (a bitspire ATM) there is no admin to prompt, so the request hangs until aBunkerTimeoutError.This diverges from a
KeyUser-level revoke, which exits at step 2 withfalse→ a cleanBunkerRejectedError. The Sintra smoke proved it on hardware:falseBunkerRejectedError→ "Pairing Required" ✅undefinedBunkerTimeoutError→ "Signer Unreachable" ❌Same operator intent ("this pairing is over"), two outcomes, one broken.
Fix
Before the final
undefined, classify the no-live-grant case. If a token bound to thisKeyUserwould have granted the request (its policy carries a matching rule; forconnect, any bound token) but is now expired or token-revoked, returnfalseso the client re-pairs immediately. A genuinely never-granted(method, kind)still returnsundefined, preserving the admin-approval path for new permission.The
(method, kind)matcher (policyMatch) is hoisted so the live-grant query and the lapsed-binding query share one predicate.Scope note — usage-cap exhaustion stays
undefinedA windowed cap (#28) is a temporary rate-limit that refills as the window rolls — not a permanent lapse — so it must not be reclassed as the re-pair signal. A dedicated rate-limit reply is a separate follow-up; this PR is strictly the expiry/revoke lifecycle lapse.
Tests
tests/acl.integration.test.ts(the #29/#33 DB harness):expired token -> sign_eventandtoken-revoked -> sign_eventnow assertfalse(wereundefined).expired token -> connectassertsfalse.undefined— proving the re-pair signal doesn't swallow genuinely-new permission.tsc --noEmitintroduces no new errors (the 3 pre-existingauthorize.tserrors are unchanged);npm run build(tsup) green.🤖 Generated with Claude Code
A request that finds no live token grant exited `checkIfPubkeyAllowed` at `undefined` regardless of *why* — whether the binding never existed or had simply lapsed (expired / token-revoked). `undefined` routes the caller into the admin-prompt path, which for an unattended client (an ATM spire) means the request hangs until a BunkerTimeoutError. The Sintra smoke proved the divergence directly: a KeyUser-level revoke exits at step 2 with `false` and the spire sees a clean BunkerRejected ("Pairing Required"), but a TTL expiry fell through to `undefined` and the spire saw a BunkerTimeout ("Signer Unreachable") — same operator intent ("this pairing is over"), two different, one-broken outcomes. Classify the no-live-grant case before returning: if a token bound to this KeyUser *would* have granted the request (its policy carries a matching rule; for `connect`, any bound token) but is now expired or token-revoked, return `false` so the client re-pairs immediately. Only a genuinely never-granted (method/kind) request stays `undefined` so an admin can still approve new permission out-of-band. Usage-cap exhaustion is left at `undefined` deliberately: a windowed cap is a temporary rate-limit that refills as the window rolls, not a permanent lapse, so it must not be reclassed as the re-pair signal. A dedicated rate-limit reply is a separate follow-up. Tests: the #24 expired-token and token-revoke guards now assert `false`; added connect-lapse, and two distinction cases proving a never-granted method (live token, or a method the lapsed token never covered) stays `undefined`.Reviewed from the bitspire/consumer side — LGTM, approve.
The lapsed-vs-never-granted distinction is exactly right, and the two guard tests (
live token, method outside policy → undefinedandexpired token, method it never covered → undefined) lock the crucial property that the re-pair signal doesn't swallow genuinely-new permission. Theconnectcase (any lapsed token →false) is correct, and re-pair with a fresh client key is unaffected since it's a differentKeyUser— which is what leg 9 showed live on the Sintra.No consumer-side change needed: my existing
BunkerRejectedError→ "Pairing Required" path absorbs the now-falseexpiry. Once this is ondevand the stack rebuilds, the leg-8 case flips from "Signer Unreachable" → "Pairing Required" with zero bitspire change. Happy to re-run leg 8 against the rebuilt bunker to confirm the flip on hardware.One non-blocking follow-up (you already scoped it out, just noting the consumer tie-in): usage-cap exhaustion staying
undefinedmeans an unattended client hitting a cap hangs the same way expiry used to. The clean answer is the dedicatedrate_limitedreply you mention — and the bitspire error layer already has the matching slot (LnbitsErrorCode.RateLimited→ retry-long-backoff, bitspire#52 Phase D), so both ends are pre-shaped for it whenever caps get applied to spire policies (#28).Nice fix — turns the smoke finding into a clean lifecycle symmetry.