diff --git a/package.json b/package.json index 4975b68..f1f8732 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "scripts": { "build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client", "build:client": "tsup src/client.ts -d dist/client", + "test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/*.test.ts", "prisma:generate": "npx prisma generate", "prisma:migrate": "npx prisma migrate deploy", "prisma:create": "npx prisma db push --preview-feature", diff --git a/src/daemon/lib/acl/index.ts b/src/daemon/lib/acl/index.ts index a3f17ab..d693e72 100644 --- a/src/daemon/lib/acl/index.ts +++ b/src/daemon/lib/acl/index.ts @@ -1,37 +1,11 @@ import { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk'; import prisma from '../../../db.js'; +import { liveWhere } from './lifecycle.js'; -/** - * "Is this grant valid right now?" — the single lifecycle predicate, used - * identically at redeem time (Backend.validateToken) and sign time - * (checkIfPubkeyAllowed). Both Token and SigningCondition carry `revokedAt` - * + `expiresAt`, so one shape governs token grants and manual overrides. - * - * The original #24 bug was possible because redeem-time checked expiry and - * sign-time didn't — two definitions of "valid" that drifted. Defining it - * once makes them impossible to disagree. See aiolabs/nsecbunkerd#25. - */ -export function grantIsLive( - grant: { revokedAt?: Date | null; expiresAt?: Date | null }, - now: Date = new Date(), -): boolean { - if (grant.revokedAt) return false; - if (grant.expiresAt && grant.expiresAt.getTime() <= now.getTime()) return false; - return true; -} - -/** - * Prisma `where` fragment selecting only lifecycle-live rows (not revoked, - * not past expiry) — grantIsLive expressed in SQL so the live filter happens - * in the query, not in app code after the fact. `now` is threaded in so a - * single request evaluates every row against one clock reading. - */ -function liveWhere(now: Date) { - return { - revokedAt: null, - OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], - }; -} +// Re-export the single lifecycle predicate so callers (e.g. +// Backend.validateToken) import it from the ACL module. The implementation +// lives in ./lifecycle.ts so it can be unit-tested without a database. +export { grantIsLive } from './lifecycle.js'; /** * Layered authorization check. Order matters (denials beat grants): diff --git a/src/daemon/lib/acl/lifecycle.ts b/src/daemon/lib/acl/lifecycle.ts new file mode 100644 index 0000000..783dd84 --- /dev/null +++ b/src/daemon/lib/acl/lifecycle.ts @@ -0,0 +1,40 @@ +/** + * Pure grant-lifecycle logic, extracted from the ACL so it can be unit-tested + * without a database and reused verbatim at redeem time and sign time. + * + * The original #24 bug was possible because redeem-time checked expiry and + * sign-time didn't — two definitions of "valid" that drifted. Defining "is + * this grant valid right now?" exactly once makes them impossible to disagree. + * See aiolabs/nsecbunkerd#25. + */ + +/** The lifecycle fields every grant (Token, SigningCondition) carries. */ +export type Lifecycle = { + revokedAt?: Date | null; + expiresAt?: Date | null; +}; + +/** + * "Is this grant valid right now?" — the single lifecycle predicate. A grant + * is live iff it has not been revoked and its expiry (if any) is still in the + * future. Expiry is treated as exclusive at the boundary: a grant whose + * `expiresAt` equals `now` is already dead. + */ +export function grantIsLive(grant: Lifecycle, now: Date = new Date()): boolean { + if (grant.revokedAt) return false; + if (grant.expiresAt && grant.expiresAt.getTime() <= now.getTime()) return false; + return true; +} + +/** + * `grantIsLive` expressed as a Prisma `where` fragment, so the live filter + * runs in the query rather than in app code after the fetch. `now` is threaded + * in explicitly so a single request evaluates every row against one clock + * reading. Kept in lockstep with `grantIsLive` (see lifecycle.test.ts). + */ +export function liveWhere(now: Date) { + return { + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }; +} diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts new file mode 100644 index 0000000..4bbe5f1 --- /dev/null +++ b/tests/lifecycle.test.ts @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { grantIsLive, liveWhere } from '../src/daemon/lib/acl/lifecycle'; + +// Fixed reference clock so the assertions don't depend on wall time. +const now = new Date('2026-06-19T12:00:00.000Z'); +const past = new Date(now.getTime() - 60_000); +const future = new Date(now.getTime() + 60_000); + +test('grantIsLive: no revoke, no expiry -> live', () => { + assert.equal(grantIsLive({}, now), true); + assert.equal(grantIsLive({ revokedAt: null, expiresAt: null }, now), true); +}); + +test('grantIsLive: future expiry -> live', () => { + assert.equal(grantIsLive({ expiresAt: future }, now), true); +}); + +test('grantIsLive: past expiry -> dead (the #24 case the old code missed at sign time)', () => { + assert.equal(grantIsLive({ expiresAt: past }, now), false); +}); + +test('grantIsLive: expiry exactly now -> dead (boundary is exclusive)', () => { + assert.equal(grantIsLive({ expiresAt: new Date(now.getTime()) }, now), false); +}); + +test('grantIsLive: revoked -> dead even with a future expiry (revoke wins)', () => { + assert.equal(grantIsLive({ revokedAt: past, expiresAt: future }, now), false); +}); + +test('grantIsLive: defaults now to the current time', () => { + assert.equal(grantIsLive({ expiresAt: new Date(Date.now() + 3_600_000) }), true); + assert.equal(grantIsLive({ expiresAt: new Date(Date.now() - 3_600_000) }), false); +}); + +// liveWhere is the SQL mirror of grantIsLive; pin its shape so the two +// can't silently drift (a drift would re-open the redeem-vs-sign gap #25 +// exists to close). +test('liveWhere: mirrors grantIsLive as a prisma where-fragment', () => { + assert.deepEqual(liveWhere(now), { + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }); +});