feat(acl): per-rule windowed usage caps enforced live at sign time (#28) #34

Merged
padreug merged 2 commits from issue-28-usage-caps into dev 2026-06-21 10:34:23 +00:00
Owner

Closes #28. Completes the lifecycle family from #25 — usage caps were the third sibling (after expiry #24 and revoke), written-but-never-enforced.

⚠️ Stacked on #33. Based on dev (the MCP wouldn't target the #33 branch), so the diff currently also shows #33's commit bbcc9cd (the test harness). Net-new here is f332559 + 5929531. Review/merge #33 first; this PR auto-narrows once #33 lands. The cap tests reuse #33's harness.

Model

  • PolicyRule.windowSeconds added; the never-enforced mutable currentUsageCount dropped. A cap = (maxUsageCount, windowSeconds): at most N signings of this (method, kind) per rolling window. windowSeconds NULL = lifetime; maxUsageCount NULL = uncapped.
  • SigningLog — new durable, append-only record of allowed signings; the source of truth caps count against (derive-don't-count — no counter to drift, the lnbits/nostr_bunker pattern).

Enforcement

checkIfPubkeyAllowed step 4: among a live token's matching rules, every capped rule must have remaining budget in its window (COUNT(SigningLog) < maxUsageCount), counted live. Stacked caps all bind — a 20/hr rule and a 200/day rule on the same policy are enforced together. recordSigning() writes a SigningLog row from the permit callback when a consequential request (sign_event/encrypt/decrypt) is allowed.

Retuning is live (your question)

New update_policy_rule admin RPC patches maxUsageCount/windowSeconds/method/kind in place:

{ "ruleId": 7, "patch": { "maxUsageCount": 20, "windowSeconds": 3600 } }

Switching 200/day → 20/hr is that one call — and because the ACL reads the rule live every request (the #27 Option D payoff), it takes effect on the next sign for every client on that policy, no re-pairing, no migration. Tightening is immediate. get_policies now returns each rule's id + window_seconds so an operator can target it.

Tests — 22 integration + 7 unit, all green

9 new cap cases: under/at-limit, signings outside the window excluded, uncapped, lifetime (null window) all-time count, kind-specific counting, both stacked-cap directions (hourly binds vs daily binds), and the recordSigning → count → deny loop.

Verification

  • nix develop -c npm run test:all → 7 + 22 passing.
  • tsc --noEmit → only the 3 pre-existing unrelated errors.
  • npm run build → clean.
  • Migration 20260620194331_usage_caps_signing_log (drops currentUsageCount, adds windowSeconds + SigningLog + index).

Deferred

  • SigningLog retention/pruning — the log grows unbounded; a prune reaper (older than the longest active window) is a follow-up I'll file.
  • Stacked-cap semantics are AND across matching capped rules; an uncapped matching rule grants access without imposing a limit (documented in the ACL).

🤖 Generated with Claude Code

Closes #28. Completes the lifecycle family from #25 — usage caps were the third sibling (after expiry #24 and revoke), written-but-never-enforced. > ⚠️ **Stacked on #33.** Based on `dev` (the MCP wouldn't target the #33 branch), so the diff currently *also* shows #33's commit `bbcc9cd` (the test harness). **Net-new here is `f332559` + `5929531`.** Review/merge **#33 first**; this PR auto-narrows once #33 lands. The cap tests reuse #33's harness. ## Model - **`PolicyRule.windowSeconds`** added; the never-enforced mutable **`currentUsageCount` dropped**. A cap = `(maxUsageCount, windowSeconds)`: at most N signings of this `(method, kind)` per rolling window. `windowSeconds` NULL = lifetime; `maxUsageCount` NULL = uncapped. - **`SigningLog`** — new durable, append-only record of *allowed* signings; the source of truth caps count against (derive-don't-count — no counter to drift, the `lnbits/nostr_bunker` pattern). ## Enforcement `checkIfPubkeyAllowed` step 4: among a live token's matching rules, **every capped rule must have remaining budget** in its window (`COUNT(SigningLog) < maxUsageCount`), counted live. **Stacked caps all bind** — a `20/hr` rule and a `200/day` rule on the same policy are enforced together. `recordSigning()` writes a `SigningLog` row from the permit callback when a consequential request (`sign_event`/encrypt/decrypt) is allowed. ## Retuning is live (your question) New **`update_policy_rule`** admin RPC patches `maxUsageCount`/`windowSeconds`/`method`/`kind` in place: ```json { "ruleId": 7, "patch": { "maxUsageCount": 20, "windowSeconds": 3600 } } ``` Switching `200/day → 20/hr` is that one call — and because the ACL reads the rule **live every request** (the #27 Option D payoff), it takes effect on the next sign for every client on that policy, **no re-pairing, no migration**. Tightening is immediate. `get_policies` now returns each rule's `id` + `window_seconds` so an operator can target it. ## Tests — 22 integration + 7 unit, all green 9 new cap cases: under/at-limit, signings outside the window excluded, uncapped, lifetime (null window) all-time count, kind-specific counting, **both stacked-cap directions** (hourly binds vs daily binds), and the `recordSigning → count → deny` loop. ## Verification - `nix develop -c npm run test:all` → 7 + 22 passing. - `tsc --noEmit` → only the 3 pre-existing unrelated errors. - `npm run build` → clean. - Migration `20260620194331_usage_caps_signing_log` (drops `currentUsageCount`, adds `windowSeconds` + `SigningLog` + index). ## Deferred - **`SigningLog` retention/pruning** — the log grows unbounded; a prune reaper (older than the longest active window) is a follow-up I'll file. - Stacked-cap semantics are **AND across matching capped rules**; an uncapped matching rule grants access without imposing a limit (documented in the ACL). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
test(acl)(#29): DB-backed integration tests for checkIfPubkeyAllowed
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
bbcc9cd998
Closes the gap flagged in #27 review: the wiring that actually closes
#24 (step-4 Token join filtered by liveWhere) was untested — only the
pure predicate was. Now covered end-to-end against a throwaway SQLite DB
+ the real prisma client.

Harness (no new dependency; pnpm add is blocked by the nix node_modules
hoist pattern):
- tests/register-ts.cjs: ts-node (transpile-only) + a CommonJS resolver
  that maps the app's '.js' ESM-style specifiers to their '.ts' sources.
- node:test temp DB via 'prisma db push'; a before() guard refuses to run
  unless DATABASE_URL points at tests/.tmp/ (never truncates a real DB).
- npm run test:integration / test:all.

13 cases incl. the #24 regression guard (expired token -> denied),
revoke, connect-off-live-token, override expiry/revoke ignored,
deny-beats-grant, kind mismatch, no-KeyUser.

Also: acl/index.ts NDK import -> 'import type' (NostrEvent/NIP46Method are
type-only) so the ACL module no longer pulls ESM-only NDK at runtime —
required for the CommonJS test import, and a correct cleanup besides.

Requires the prisma engine env (CI/nix ok; devShell pending #30).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the lifecycle family from #25 — usage caps were the third
sibling (after expiry #24 and revoke), written-but-never-enforced.

Model:
- PolicyRule gains windowSeconds; drops the never-enforced mutable
  currentUsageCount. A cap = (maxUsageCount, windowSeconds): at most N
  signings of this (method,kind) per rolling window. windowSeconds NULL
  = lifetime; maxUsageCount NULL = uncapped.
- New SigningLog: durable append-only record of allowed signings — the
  source of truth caps count against (derive-don't-count; no counter to
  drift).

Enforcement (checkIfPubkeyAllowed step 4): among the live token's
matching rules, every capped rule must have remaining budget in its
window (COUNT(SigningLog) < maxUsageCount), counted live. Stacked caps
all bind — 20/hr AND 200/day enforced together. recordSigning() writes
a SigningLog row from the permit callback when a consequential request
(sign_event / encrypt / decrypt) is allowed.

Retune live: new update_policy_rule admin RPC patches maxUsageCount/
windowSeconds/method/kind in place; takes effect next request, no
re-pairing (a payoff of the #27 Option D design). get_policies now
returns each rule's id + window_seconds so callers can target it.

Retention/pruning of SigningLog is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(acl)(#28): integration cases for windowed + stacked usage caps
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
59295318f8
9 cases: under/at limit, signings outside the window excluded, uncapped,
lifetime (null window) all-time count, kind-specific counting, both
stacked-cap directions (hourly binds vs daily binds), and the
record->count->deny loop via recordSigning. 22 integration + 7 unit green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
padreug force-pushed issue-28-usage-caps from 59295318f8
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
to c76bbf2791
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
2026-06-21 10:31:56 +00:00
Compare
padreug deleted branch issue-28-usage-caps 2026-06-21 10:34:23 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/nsecbunkerd!34
No description provided.