@fuzdev/fuz_app 0.117.0 → 0.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/auth/CLAUDE.md +17 -4
  2. package/dist/auth/audit_emitter.d.ts +13 -8
  3. package/dist/auth/audit_emitter.d.ts.map +1 -1
  4. package/dist/auth/audit_emitter.js +5 -4
  5. package/dist/auth/bootstrap_account.d.ts +3 -2
  6. package/dist/auth/bootstrap_account.d.ts.map +1 -1
  7. package/dist/auth/bootstrap_account.js +3 -3
  8. package/dist/auth/cleanup.d.ts +25 -11
  9. package/dist/auth/cleanup.d.ts.map +1 -1
  10. package/dist/auth/cleanup.js +47 -28
  11. package/dist/auth/deps.d.ts +1 -1
  12. package/dist/auth/migrations.d.ts.map +1 -1
  13. package/dist/auth/migrations.js +27 -0
  14. package/dist/auth/role_grant_offer_ddl.d.ts +4 -2
  15. package/dist/auth/role_grant_offer_ddl.d.ts.map +1 -1
  16. package/dist/auth/role_grant_offer_ddl.js +10 -3
  17. package/dist/auth/role_grant_offer_queries.d.ts +16 -7
  18. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  19. package/dist/auth/role_grant_offer_queries.js +33 -15
  20. package/dist/auth/role_grant_offer_schema.d.ts +9 -0
  21. package/dist/auth/role_grant_offer_schema.d.ts.map +1 -1
  22. package/dist/db/migrate.d.ts +8 -13
  23. package/dist/db/migrate.d.ts.map +1 -1
  24. package/dist/db/migrate.js +9 -38
  25. package/dist/db/status.d.ts +0 -6
  26. package/dist/db/status.d.ts.map +1 -1
  27. package/dist/db/status.js +3 -23
  28. package/dist/runtime/deno.d.ts.map +1 -1
  29. package/dist/runtime/deno.js +9 -6
  30. package/dist/runtime/deps.d.ts +3 -2
  31. package/dist/runtime/deps.d.ts.map +1 -1
  32. package/dist/runtime/secure_file.d.ts +23 -9
  33. package/dist/runtime/secure_file.d.ts.map +1 -1
  34. package/dist/runtime/secure_file.js +29 -10
  35. package/dist/server/app_backend.d.ts +1 -1
  36. package/dist/testing/audit_drift_guard.d.ts +3 -5
  37. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  38. package/dist/testing/audit_drift_guard.js +3 -5
  39. package/dist/testing/cross_backend/expected_schema.json +1 -0
  40. package/dist/ui/AccountSessions.svelte +1 -1
  41. package/dist/ui/AdminAccounts.svelte +1 -1
  42. package/dist/ui/AdminAuditLog.svelte +1 -1
  43. package/dist/ui/AdminInvites.svelte +5 -4
  44. package/dist/ui/AdminInvites.svelte.d.ts.map +1 -1
  45. package/dist/ui/AdminOverview.svelte +6 -6
  46. package/dist/ui/AdminRoleGrantHistory.svelte +2 -2
  47. package/dist/ui/AdminSessions.svelte +1 -1
  48. package/dist/ui/AdminSettings.svelte +3 -3
  49. package/dist/ui/AdminSurface.svelte +1 -1
  50. package/dist/ui/RoleGrantOfferHistory.svelte +1 -1
  51. package/dist/ui/RoleGrantOfferInbox.svelte +1 -1
  52. package/dist/ui/SurfaceExplorer.svelte +7 -7
  53. package/package.json +2 -2
@@ -125,7 +125,7 @@ its registered listeners + optional `AuditLogConfig`. Six methods:
125
125
 
126
126
  - `emit(ctx, input)` — fire-and-forget pool write, pushes to `ctx.pending_effects`
127
127
  - `emit_role_grant_target(ctx, auth, input)` — lifts `actor_id` / `account_id` / `ip` boilerplate for role-grant-shape events
128
- - `emit_pool(input)` — awaitable pool write for code paths without `pending_effects` (cleanup sweeps)
128
+ - `emit_pool(input)` — awaitable pool write for code paths without `pending_effects` (ad-hoc maintenance scripts; not for success audits paired with a mutation — those write in-tx and `notify` post-commit)
129
129
  - `notify(event)` — fan out an already-written row to listeners (used by in-tx audit batches like `query_accept_offer.audit_events`)
130
130
  - `add_listener(listener)` — append-only listener registration (twin of Rust `fuz_auth` `AuditEmitter::add_listener`)
131
131
  - `listener_count()` — registered-listener count, for tests / diagnostics
@@ -843,11 +843,24 @@ drops the hand-maintained method-name mappings:
843
843
 
844
844
  `auth/cleanup.ts` — `run_auth_cleanup(deps)` runs every sweep (expired
845
845
  sessions + expired offers) and returns counts. Re-throws sweep errors so the
846
- caller's scheduler can log/alert. Idempotency: audit log has no tombstone on
847
- `role_grant_offer_expire`, so concurrent runs double-audit — deploy a single
848
- scheduled invocation per instance. Expired offer rows are preserved (audit
846
+ caller's scheduler can log/alert. Expired offer rows are preserved (audit
849
847
  value for the history view).
850
848
 
849
+ **Idempotency.** `cleanup_expired_role_grant_offers` runs one transaction:
850
+ `query_role_grant_offer_sweep_expired` claims the expired pending offers not
851
+ yet audited by stamping `role_grant_offer.expire_audited_at` (`UPDATE …
852
+ RETURNING`), and each claimed row's `role_grant_offer_expire` audit row is
853
+ inserted in the same transaction; events fan out via `audit.notify` after the
854
+ commit. Each expiry is audited exactly once — a second run finds nothing, and
855
+ concurrent runs claim disjoint rows (a row another sweep holds blocks on its
856
+ row lock, then fails the re-checked stamp). A failed audit insert rolls the
857
+ stamps back and the next run retries, so the offer sweep can throw. The stamp
858
+ is not a lifecycle state (outside the terminal CHECK and the pending
859
+ predicate) and stays off the wire (`to_role_grant_offer_json` drops it); the
860
+ create upsert's re-offer clears it, so a refreshed offer's own expiry is
861
+ audited too. The `role_grant_offer_expire_sweep` partial index serves the
862
+ claim. Twin of the Rust `fuz_auth::auth_cleanup` module.
863
+
851
864
  `AuthCleanupDeps` requires `audit: AuditEmitter` — production wiring always
852
865
  has a bound emitter; tests pass `create_test_audit_emitter()` from
853
866
  `testing/stubs.ts`.
@@ -22,12 +22,13 @@
22
22
  * `actor_id` / `account_id` / `ip` boilerplate every role-grant-shape audit
23
23
  * site repeated. Delegates to `emit`.
24
24
  * - `emit_pool(input)` — awaitable pool write for code paths without a
25
- * `pending_effects` queue (cleanup sweeps, ad-hoc maintenance scripts).
26
- * Same write-then-notify semantics as `emit`, just synchronous-with-await.
25
+ * `pending_effects` queue (ad-hoc maintenance scripts). Same
26
+ * write-then-notify semantics as `emit`, just synchronous-with-await.
27
27
  * - `notify(event)` — fan out an already-written audit row (e.g. rows
28
28
  * returned by `query_accept_offer` that were inserted in-transaction by
29
- * the query layer). Runs every registered listener; per-listener throws
30
- * are isolated.
29
+ * the query layer, or the offer-expiry rows `auth/cleanup.ts` writes in
30
+ * its sweep transaction). Runs every registered listener; per-listener
31
+ * throws are isolated.
31
32
  *
32
33
  * Listeners are a documented registration seam — `create_app_server`
33
34
  * registers additional listeners via `add_listener` after the backend is
@@ -119,10 +120,14 @@ export interface AuditEmitter {
119
120
  * Awaitable pool write for code paths without a `pending_effects` queue.
120
121
  *
121
122
  * Same write-then-notify semantics as `emit`. Errors are logged and
122
- * swallowed (resolved void), so callers can sequence sweeps with
123
- * `await audit.emit_pool(...)` without try/catch boilerplate. The
124
- * primary user is `auth/cleanup.ts` — sweeps have no per-request
125
- * `pending_effects` to attach to.
123
+ * swallowed (resolved void), so callers can sequence writes with
124
+ * `await audit.emit_pool(...)` without try/catch boilerplate.
125
+ *
126
+ * Not for a success audit paired with a state mutation: the pool write
127
+ * can't roll back with the state, and a swallowed error leaves the
128
+ * mutation unaudited. Write those in the mutation's transaction
129
+ * (`query_audit_log` against the tx) and `notify` after the commit — the
130
+ * shape `auth/cleanup.ts`'s offer sweep and `query_accept_offer` use.
126
131
  *
127
132
  * @mutates `audit_log` table - inserts the row via the captured pool
128
133
  */
@@ -1 +1 @@
1
- {"version":3,"file":"audit_emitter.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_emitter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAEnD,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAEhE,OAAO,EAEN,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,MAAM,uBAAuB,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,mBAAmB;IACnC,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;CACtC;AAED;;;;GAIG;AACH,MAAM,WAAW,yBAA0B,SAAQ,mBAAmB;IACrE,0FAA0F;IAC1F,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC5B;;;;;;;;;;;;;;;;OAgBG;IACH,IAAI,CAAC,CAAC,SAAS,MAAM,EAAE,GAAG,EAAE,mBAAmB,EAAE,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IAChF;;;;;;;OAOG;IACH,sBAAsB,CAAC,CAAC,SAAS,MAAM,EACtC,GAAG,EAAE,yBAAyB,EAC9B,IAAI,EAAE,mBAAmB,EACzB,KAAK,EAAE;QACN,UAAU,EAAE,CAAC,CAAC;QACd,iBAAiB,EAAE,IAAI,GAAG,IAAI,CAAC;QAC/B,eAAe,EAAE,IAAI,GAAG,IAAI,CAAC;QAC7B,QAAQ,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;QACvC,OAAO,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;KAChC,GACC,IAAI,CAAC;IACR;;;;;;;;;;OAUG;IACH,SAAS,CAAC,CAAC,SAAS,MAAM,EAAE,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE;;;;;;;OAOG;IACH,MAAM,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IACnC;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,GAAG,IAAI,CAAC;IAC7D,+EAA+E;IAC/E,cAAc,IAAI,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,SAAS,MAAM,EAC1C,GAAG,EAAE,mBAAmB,EACxB,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,KACnB,IAAI,CAAC;AAEV;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,WAAW,KAAK,WAAW,CAAC;AAEhE,0CAA0C;AAC1C,MAAM,WAAW,yBAAyB;IACzC,oDAAoD;IACpD,EAAE,EAAE,EAAE,CAAC;IACP,qDAAqD;IACrD,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACzD;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,cAAc,CAAC;IAClC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAAI,SAAS,yBAAyB,KAAG,YAuFzE,CAAC"}
1
+ {"version":3,"file":"audit_emitter.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_emitter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAEnD,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAEhE,OAAO,EAEN,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,MAAM,uBAAuB,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,mBAAmB;IACnC,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;CACtC;AAED;;;;GAIG;AACH,MAAM,WAAW,yBAA0B,SAAQ,mBAAmB;IACrE,0FAA0F;IAC1F,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC5B;;;;;;;;;;;;;;;;OAgBG;IACH,IAAI,CAAC,CAAC,SAAS,MAAM,EAAE,GAAG,EAAE,mBAAmB,EAAE,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IAChF;;;;;;;OAOG;IACH,sBAAsB,CAAC,CAAC,SAAS,MAAM,EACtC,GAAG,EAAE,yBAAyB,EAC9B,IAAI,EAAE,mBAAmB,EACzB,KAAK,EAAE;QACN,UAAU,EAAE,CAAC,CAAC;QACd,iBAAiB,EAAE,IAAI,GAAG,IAAI,CAAC;QAC/B,eAAe,EAAE,IAAI,GAAG,IAAI,CAAC;QAC7B,QAAQ,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;QACvC,OAAO,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;KAChC,GACC,IAAI,CAAC;IACR;;;;;;;;;;;;;;OAcG;IACH,SAAS,CAAC,CAAC,SAAS,MAAM,EAAE,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE;;;;;;;OAOG;IACH,MAAM,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IACnC;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,GAAG,IAAI,CAAC;IAC7D,+EAA+E;IAC/E,cAAc,IAAI,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,SAAS,MAAM,EAC1C,GAAG,EAAE,mBAAmB,EACxB,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,KACnB,IAAI,CAAC;AAEV;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,WAAW,KAAK,WAAW,CAAC;AAEhE,0CAA0C;AAC1C,MAAM,WAAW,yBAAyB;IACzC,oDAAoD;IACpD,EAAE,EAAE,EAAE,CAAC;IACP,qDAAqD;IACrD,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACzD;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,cAAc,CAAC;IAClC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAAI,SAAS,yBAAyB,KAAG,YAuFzE,CAAC"}
@@ -22,12 +22,13 @@
22
22
  * `actor_id` / `account_id` / `ip` boilerplate every role-grant-shape audit
23
23
  * site repeated. Delegates to `emit`.
24
24
  * - `emit_pool(input)` — awaitable pool write for code paths without a
25
- * `pending_effects` queue (cleanup sweeps, ad-hoc maintenance scripts).
26
- * Same write-then-notify semantics as `emit`, just synchronous-with-await.
25
+ * `pending_effects` queue (ad-hoc maintenance scripts). Same
26
+ * write-then-notify semantics as `emit`, just synchronous-with-await.
27
27
  * - `notify(event)` — fan out an already-written audit row (e.g. rows
28
28
  * returned by `query_accept_offer` that were inserted in-transaction by
29
- * the query layer). Runs every registered listener; per-listener throws
30
- * are isolated.
29
+ * the query layer, or the offer-expiry rows `auth/cleanup.ts` writes in
30
+ * its sweep transaction). Runs every registered listener; per-listener
31
+ * throws are isolated.
31
32
  *
32
33
  * Listeners are a documented registration seam — `create_app_server`
33
34
  * registers additional listeners via `add_listener` after the backend is
@@ -53,8 +53,9 @@ export interface BootstrapAccountDeps {
53
53
  token_path: string;
54
54
  /**
55
55
  * Hardened secret-file read (see `FsSecureReadDeps.read_secure_file`) —
56
- * the token mints the keeper account, so a symlinked, group/other-readable,
57
- * or oversized file must refuse rather than be honored. Throws on refusal;
56
+ * the token mints the keeper account, so a symlinked, non-regular,
57
+ * group/other-readable, or oversized file must refuse rather than be
58
+ * honored. Throws on refusal;
58
59
  * every throw reads as `TOKEN_FILE_MISSING` upstream.
59
60
  */
60
61
  read_secure_file: (path: string) => Promise<Uint8Array>;
@@ -1 +1 @@
1
- {"version":3,"file":"bootstrap_account.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/bootstrap_account.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACtD,OAAO,EACN,mBAAmB,EACnB,0BAA0B,EAC1B,wBAAwB,EACxB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAGrE,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAEtC,gDAAgD;AAChD,MAAM,WAAW,qBAAqB;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,6DAA6D;AAC7D,MAAM,WAAW,uBAAuB;IACvC,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,WAAW,EAAE;QAAE,MAAM,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,SAAS,CAAA;KAAE,CAAC;IACrD,wFAAwF;IACxF,kBAAkB,EAAE,OAAO,CAAC;CAC5B;AAED,gCAAgC;AAChC,MAAM,MAAM,uBAAuB,GAChC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,0BAA0B,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GACpE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,wBAAwB,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GAClE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC;AAEjE,qFAAqF;AACrF,MAAM,MAAM,sBAAsB,GAAG,uBAAuB,GAAG,uBAAuB,CAAC;AAEvF;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACpC,EAAE,EAAE,EAAE,CAAC;IACP,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACxD,qBAAqB;IACrB,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,6EAA6E;IAC7E,QAAQ,EAAE,IAAI,CAAC,gBAAgB,EAAE,eAAe,CAAC,CAAC;IAClD,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,iBAAiB,GAC7B,MAAM,oBAAoB,EAC1B,gBAAgB,MAAM,EACtB,OAAO,qBAAqB,KAC1B,OAAO,CAAC,sBAAsB,CA2EhC,CAAC"}
1
+ {"version":3,"file":"bootstrap_account.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/bootstrap_account.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACtD,OAAO,EACN,mBAAmB,EACnB,0BAA0B,EAC1B,wBAAwB,EACxB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAGrE,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAEtC,gDAAgD;AAChD,MAAM,WAAW,qBAAqB;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,6DAA6D;AAC7D,MAAM,WAAW,uBAAuB;IACvC,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,WAAW,EAAE;QAAE,MAAM,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,SAAS,CAAA;KAAE,CAAC;IACrD,wFAAwF;IACxF,kBAAkB,EAAE,OAAO,CAAC;CAC5B;AAED,gCAAgC;AAChC,MAAM,MAAM,uBAAuB,GAChC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,0BAA0B,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GACpE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,wBAAwB,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GAClE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC;AAEjE,qFAAqF;AACrF,MAAM,MAAM,sBAAsB,GAAG,uBAAuB,GAAG,uBAAuB,CAAC;AAEvF;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACpC,EAAE,EAAE,EAAE,CAAC;IACP,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACxD,qBAAqB;IACrB,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,6EAA6E;IAC7E,QAAQ,EAAE,IAAI,CAAC,gBAAgB,EAAE,eAAe,CAAC,CAAC;IAClD,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,iBAAiB,GAC7B,MAAM,oBAAoB,EAC1B,gBAAgB,MAAM,EACtB,OAAO,qBAAqB,KAC1B,OAAO,CAAC,sBAAsB,CA2EhC,CAAC"}
@@ -36,9 +36,9 @@ import { query_create_role_grant } from './role_grant_queries.js';
36
36
  export const bootstrap_account = async (deps, provided_token, input) => {
37
37
  const { db, token_path, read_secure_file, delete_file, password, log } = deps;
38
38
  // 1. Read and verify token (non-destructive, before transaction). The
39
- // secure read fails loud on a symlink / permissive mode / oversized file —
40
- // every refusal masks as the same TOKEN_FILE_MISSING the wire always
41
- // carried, with the specific reason logged for the operator.
39
+ // secure read fails loud on a symlink / non-regular file / permissive mode /
40
+ // oversized file — every refusal masks as the same TOKEN_FILE_MISSING the
41
+ // wire always carried, with the specific reason logged for the operator.
42
42
  let expected_token;
43
43
  try {
44
44
  expected_token = new TextDecoder().decode(await read_secure_file(token_path)).trim();
@@ -11,10 +11,13 @@
11
11
  * `role_grant_offer_expire` audit emission and is the piece most likely to be
12
12
  * reused in a consumer's bespoke scheduler.
13
13
  *
14
- * Idempotency: the audit log has no tombstone on `role_grant_offer_expire`, so
15
- * concurrent sweep runs double-audit. The expected deployment pattern is a
16
- * single scheduled invocation per instance — matching
17
- * `query_session_cleanup_expired`.
14
+ * Idempotency: the offer sweep claims each expired offer by stamping
15
+ * `role_grant_offer.expire_audited_at` in the same transaction as its
16
+ * `role_grant_offer_expire` audit row, so every expiry is audited exactly
17
+ * once — a second run finds nothing, and concurrent runs claim disjoint rows
18
+ * (row locks serialize them on the re-checked stamp). A failed audit insert
19
+ * rolls the stamps back with it and the next run retries. A re-offer clears
20
+ * the stamp, so the refreshed offer's own expiry is audited too.
18
21
  *
19
22
  * @module
20
23
  */
@@ -25,11 +28,12 @@ import type { AuditEmitter } from './audit_emitter.js';
25
28
  export interface AuthCleanupDeps extends QueryDeps {
26
29
  log: Logger;
27
30
  /**
28
- * Bound audit emitter. `cleanup_expired_role_grant_offers` writes via
29
- * `audit.emit_pool` (the captured pool + config + listener chain), so
30
- * one slot covers both row persistence and SSE/WS fan-out. Required —
31
- * production wiring always has a bound emitter on `AppDeps.audit`, and
32
- * tests that need a no-op pass `create_test_audit_emitter()`.
31
+ * Bound audit emitter. `cleanup_expired_role_grant_offers` writes its audit
32
+ * rows in its own transaction and fans each one out through `audit.notify`
33
+ * after the commit, so listeners never see an event that rolled back.
34
+ * Required — production wiring always has a bound emitter on
35
+ * `AppDeps.audit`, and tests that need a no-op pass
36
+ * `create_test_audit_emitter()`.
33
37
  */
34
38
  audit: AuditEmitter;
35
39
  }
@@ -42,15 +46,24 @@ export interface AuthCleanupResult {
42
46
  }
43
47
  /**
44
48
  * Sweep expired role_grant offers and emit one `role_grant_offer_expire` audit
45
- * event per row.
49
+ * event per newly expired row.
46
50
  *
47
- * Returns the count of offers audit-stamped. The offer rows themselves are
51
+ * One transaction: `query_role_grant_offer_sweep_expired` claims the expired,
52
+ * not-yet-audited offers (stamping `expire_audited_at`) and each claimed row's
53
+ * audit row is inserted beside it. After the commit, each event fans out
54
+ * through `deps.audit.notify` — per-listener throws are isolated there, so one
55
+ * bad listener never starves the rest. Twin of the Rust
56
+ * `cleanup_expired_role_grant_offers`.
57
+ *
58
+ * Returns the count of offers audited. The offer rows themselves are
48
59
  * preserved — offers carry audit value for the history view even after
49
60
  * expiry, and accepted rows are the provenance for the resulting role_grant
50
61
  * (deleting expired rows would not threaten that, but keeping them uniform
51
62
  * with the retention policy for terminal rows is simpler).
52
63
  *
64
+ * @mutates `role_grant_offer` rows - stamps `expire_audited_at` on each swept offer
53
65
  * @mutates `audit_log` table - inserts one `role_grant_offer_expire` row per swept offer
66
+ * @throws Error if the claim or an audit insert fails — the transaction rolls back, so no offer is stamped and the next run retries
54
67
  */
55
68
  export declare const cleanup_expired_role_grant_offers: (deps: AuthCleanupDeps) => Promise<number>;
56
69
  /**
@@ -64,6 +77,7 @@ export declare const cleanup_expired_role_grant_offers: (deps: AuthCleanupDeps)
64
77
  * directly if you need finer error isolation.
65
78
  *
66
79
  * @mutates `auth_session` table - deletes expired sessions
80
+ * @mutates `role_grant_offer` rows - stamps `expire_audited_at` on expired offers
67
81
  * @mutates `audit_log` table - emits `role_grant_offer_expire` rows for expired offers
68
82
  * @throws Error re-thrown from any sweep that fails (no per-sweep isolation here)
69
83
  */
@@ -1 +1 @@
1
- {"version":3,"file":"cleanup.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/cleanup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,4CAA4C;AAC5C,MAAM,WAAW,eAAgB,SAAQ,SAAS;IACjD,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;OAMG;IACH,KAAK,EAAE,YAAY,CAAC;CACpB;AAED,oCAAoC;AACpC,MAAM,WAAW,iBAAiB;IACjC,8CAA8C;IAC9C,gBAAgB,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,cAAc,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iCAAiC,GAAU,MAAM,eAAe,KAAG,OAAO,CAAC,MAAM,CAuB7F,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAAU,MAAM,eAAe,KAAG,OAAO,CAAC,iBAAiB,CAIvF,CAAC"}
1
+ {"version":3,"file":"cleanup.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/cleanup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIvD,4CAA4C;AAC5C,MAAM,WAAW,eAAgB,SAAQ,SAAS;IACjD,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;OAOG;IACH,KAAK,EAAE,YAAY,CAAC;CACpB;AAED,oCAAoC;AACpC,MAAM,WAAW,iBAAiB;IACjC,8CAA8C;IAC9C,gBAAgB,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,cAAc,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,iCAAiC,GAAU,MAAM,eAAe,KAAG,OAAO,CAAC,MAAM,CAgC7F,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,gBAAgB,GAAU,MAAM,eAAe,KAAG,OAAO,CAAC,iBAAiB,CAIvF,CAAC"}
@@ -11,50 +11,68 @@
11
11
  * `role_grant_offer_expire` audit emission and is the piece most likely to be
12
12
  * reused in a consumer's bespoke scheduler.
13
13
  *
14
- * Idempotency: the audit log has no tombstone on `role_grant_offer_expire`, so
15
- * concurrent sweep runs double-audit. The expected deployment pattern is a
16
- * single scheduled invocation per instance — matching
17
- * `query_session_cleanup_expired`.
14
+ * Idempotency: the offer sweep claims each expired offer by stamping
15
+ * `role_grant_offer.expire_audited_at` in the same transaction as its
16
+ * `role_grant_offer_expire` audit row, so every expiry is audited exactly
17
+ * once — a second run finds nothing, and concurrent runs claim disjoint rows
18
+ * (row locks serialize them on the re-checked stamp). A failed audit insert
19
+ * rolls the stamps back with it and the next run retries. A re-offer clears
20
+ * the stamp, so the refreshed offer's own expiry is audited too.
18
21
  *
19
22
  * @module
20
23
  */
21
24
  import { query_session_cleanup_expired } from './session_queries.js';
22
25
  import { query_role_grant_offer_sweep_expired } from './role_grant_offer_queries.js';
26
+ import { query_audit_log } from './audit_log_queries.js';
23
27
  /**
24
28
  * Sweep expired role_grant offers and emit one `role_grant_offer_expire` audit
25
- * event per row.
29
+ * event per newly expired row.
26
30
  *
27
- * Returns the count of offers audit-stamped. The offer rows themselves are
31
+ * One transaction: `query_role_grant_offer_sweep_expired` claims the expired,
32
+ * not-yet-audited offers (stamping `expire_audited_at`) and each claimed row's
33
+ * audit row is inserted beside it. After the commit, each event fans out
34
+ * through `deps.audit.notify` — per-listener throws are isolated there, so one
35
+ * bad listener never starves the rest. Twin of the Rust
36
+ * `cleanup_expired_role_grant_offers`.
37
+ *
38
+ * Returns the count of offers audited. The offer rows themselves are
28
39
  * preserved — offers carry audit value for the history view even after
29
40
  * expiry, and accepted rows are the provenance for the resulting role_grant
30
41
  * (deleting expired rows would not threaten that, but keeping them uniform
31
42
  * with the retention policy for terminal rows is simpler).
32
43
  *
44
+ * @mutates `role_grant_offer` rows - stamps `expire_audited_at` on each swept offer
33
45
  * @mutates `audit_log` table - inserts one `role_grant_offer_expire` row per swept offer
46
+ * @throws Error if the claim or an audit insert fails — the transaction rolls back, so no offer is stamped and the next run retries
34
47
  */
35
48
  export const cleanup_expired_role_grant_offers = async (deps) => {
36
- const expired = await query_role_grant_offer_sweep_expired(deps);
37
- for (const offer of expired) {
38
- // `role_grant_offer_expire` populates `target_actor_id` only when the
39
- // offer was actor-targeted (`to_actor_id` set at create time).
40
- // Account-grain offers (no `to_actor_id`) never bound to a
41
- // specific actor and leave the field null.
42
- // `emit_pool` swallows + logs both write errors and per-listener
43
- // throws, so a single bad row never starves the rest of the sweep.
44
- await deps.audit.emit_pool({
45
- event_type: 'role_grant_offer_expire',
46
- actor_id: offer.from_actor_id,
47
- target_account_id: offer.to_account_id,
48
- target_actor_id: offer.to_actor_id,
49
- ip: null,
50
- metadata: {
51
- offer_id: offer.id,
52
- role: offer.role,
53
- scope_id: offer.scope_id
54
- }
55
- });
56
- }
57
- return expired.length;
49
+ const events = await deps.db.transaction(async (tx) => {
50
+ const expired = await query_role_grant_offer_sweep_expired({ db: tx });
51
+ const written = [];
52
+ for (const offer of expired) {
53
+ // `role_grant_offer_expire` populates `target_actor_id` only when the
54
+ // offer was actor-targeted (`to_actor_id` set at create time).
55
+ // Account-grain offers (no `to_actor_id`) never bound to a
56
+ // specific actor and leave the field null. No request is behind a
57
+ // sweep, so `ip` stays null.
58
+ written.push(await query_audit_log({ db: tx }, {
59
+ event_type: 'role_grant_offer_expire',
60
+ actor_id: offer.from_actor_id,
61
+ target_account_id: offer.to_account_id,
62
+ target_actor_id: offer.to_actor_id,
63
+ ip: null,
64
+ metadata: {
65
+ offer_id: offer.id,
66
+ role: offer.role,
67
+ scope_id: offer.scope_id
68
+ }
69
+ }));
70
+ }
71
+ return written;
72
+ });
73
+ for (const event of events)
74
+ deps.audit.notify(event);
75
+ return events.length;
58
76
  };
59
77
  /**
60
78
  * Run every auth cleanup sweep — expired sessions and expired role_grant
@@ -67,6 +85,7 @@ export const cleanup_expired_role_grant_offers = async (deps) => {
67
85
  * directly if you need finer error isolation.
68
86
  *
69
87
  * @mutates `auth_session` table - deletes expired sessions
88
+ * @mutates `role_grant_offer` rows - stamps `expire_audited_at` on expired offers
70
89
  * @mutates `audit_log` table - emits `role_grant_offer_expire` rows for expired offers
71
90
  * @throws Error re-thrown from any sweep that fails (no per-sweep isolation here)
72
91
  */
@@ -24,7 +24,7 @@ export interface AppDeps {
24
24
  * Hardened secret-file read — used for the bootstrap token (the file that
25
25
  * mints the keeper account). Production wiring passes the runtime's
26
26
  * `read_secure_file` (`FsSecureReadDeps`), which rejects symlinks,
27
- * group/other-accessible modes, and oversized files; both the boot-time
27
+ * non-regular files, group/other-accessible modes, and oversized files; both the boot-time
28
28
  * availability probe and the request-time read go through this one
29
29
  * capability so the probe can never be laxer than the read it gates.
30
30
  */
@@ -1 +1 @@
1
- {"version":3,"file":"migrations.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/migrations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AA+BH,OAAO,KAAK,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAEtE,wDAAwD;AACxD,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,EAAE,aAAa,CAAC,MAAM,CAA8B,CAAC;AAE/F;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,SAAS,CA6K5C,CAAC;AAEF,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,EAAE,kBAG/B,CAAC"}
1
+ {"version":3,"file":"migrations.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/migrations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AA+BH,OAAO,KAAK,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAEtE,wDAAwD;AACxD,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,EAAE,aAAa,CAAC,MAAM,CAA8B,CAAC;AAE/F;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,SAAS,CA0M5C,CAAC;AAEF,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,EAAE,kBAG/B,CAAC"}
@@ -237,6 +237,33 @@ export const auth_migrations = [
237
237
  up: async (db) => {
238
238
  await db.query('ALTER TABLE auth_session DROP COLUMN IF EXISTS last_seen_at');
239
239
  }
240
+ },
241
+ // The expiry-audit stamp that makes the offer sweep idempotent.
242
+ //
243
+ // An expired pending offer has no terminal state — expiry is computed from
244
+ // `expires_at`, never written — so without a stamp every
245
+ // `cleanup_expired_role_grant_offers` run re-audited every expired offer.
246
+ // The sweep now claims rows by setting `expire_audited_at` in the same
247
+ // transaction as their `role_grant_offer_expire` audit rows, so each expiry
248
+ // is audited exactly once and concurrent sweeps claim disjoint rows.
249
+ //
250
+ // Not a lifecycle state: the column stays out of the
251
+ // `role_grant_offer_single_terminal` CHECK and the pending predicate, and
252
+ // off the wire. A re-offer of a stamped row clears it (the create upsert),
253
+ // so the refreshed offer's own expiry is audited too. The partial index
254
+ // serves exactly the sweep's claim predicate.
255
+ {
256
+ name: 'role_grant_offer_expire_audited_at',
257
+ up: async (db) => {
258
+ await db.query('ALTER TABLE role_grant_offer ADD COLUMN IF NOT EXISTS expire_audited_at TIMESTAMPTZ NULL');
259
+ await db.query(`CREATE INDEX IF NOT EXISTS role_grant_offer_expire_sweep
260
+ ON role_grant_offer (expires_at)
261
+ WHERE accepted_at IS NULL
262
+ AND declined_at IS NULL
263
+ AND retracted_at IS NULL
264
+ AND superseded_at IS NULL
265
+ AND expire_audited_at IS NULL`);
266
+ }
240
267
  }
241
268
  ];
242
269
  /** Pre-composed migration namespace for auth tables. */
@@ -27,13 +27,15 @@ export declare const ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN = "GLOBAL";
27
27
  * The full `role_grant_offer` column set, named explicitly so a row read
28
28
  * fails loud on schema drift (see `ACCOUNT_COLUMNS` in
29
29
  * `auth/account_queries.ts` for the outage class). Column order follows
30
- * `RoleGrantOffer` and `ROLE_GRANT_OFFER_SCHEMA` below.
30
+ * `RoleGrantOffer` and `ROLE_GRANT_OFFER_SCHEMA` below, with
31
+ * `expire_audited_at` last — the appended `role_grant_offer_expire_audited_at`
32
+ * migration adds it after the frozen `CREATE TABLE`.
31
33
  *
32
34
  * Lives here rather than in `auth/role_grant_offer_queries.ts` because two
33
35
  * query modules project this table — see the placement rule in
34
36
  * `db/sql_columns.ts`.
35
37
  */
36
- export declare const ROLE_GRANT_OFFER_COLUMNS: readonly ["id", "from_actor_id", "to_account_id", "to_actor_id", "role", "scope_kind", "scope_id", "message", "created_at", "expires_at", "accepted_at", "declined_at", "decline_reason", "retracted_at", "superseded_at", "resulting_role_grant_id"];
38
+ export declare const ROLE_GRANT_OFFER_COLUMNS: readonly ["id", "from_actor_id", "to_account_id", "to_actor_id", "role", "scope_kind", "scope_id", "message", "created_at", "expires_at", "accepted_at", "declined_at", "decline_reason", "retracted_at", "superseded_at", "resulting_role_grant_id", "expire_audited_at"];
37
39
  /**
38
40
  * The `role_grant_offer` timestamp override, by row qualifier (`''` bare,
39
41
  * `o` for the history JOIN, `u` for the `updated` CTE's outer select). Lives
@@ -1 +1 @@
1
- {"version":3,"file":"role_grant_offer_ddl.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/role_grant_offer_ddl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,mHAAmH;AACnH,eAAO,MAAM,oCAAoC,yCAAyC,CAAC;AAE3F;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,WAAW,CAAC;AAEjE;;;;;;;;;GASG;AACH,eAAO,MAAM,wBAAwB,uPAiBmB,CAAC;AAEzD;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,8DAOhC,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,oCAAoC,QAMI,CAAC;AAEtD,eAAO,MAAM,uBAAuB,qzCAkClC,CAAC;AAEH;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qCAAqC,2XAYpB,CAAC;AAE/B,+EAA+E;AAC/E,eAAO,MAAM,4BAA4B,kOAMX,CAAC"}
1
+ {"version":3,"file":"role_grant_offer_ddl.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/role_grant_offer_ddl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,mHAAmH;AACnH,eAAO,MAAM,oCAAoC,yCAAyC,CAAC;AAE3F;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,WAAW,CAAC;AAEjE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,wBAAwB,4QAkBmB,CAAC;AAEzD;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,8DAQhC,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,oCAAoC,QAMI,CAAC;AAKtD,eAAO,MAAM,uBAAuB,qzCAkClC,CAAC;AAEH;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qCAAqC,2XAYpB,CAAC;AAE/B,+EAA+E;AAC/E,eAAO,MAAM,4BAA4B,kOAMX,CAAC"}
@@ -28,7 +28,9 @@ export const ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN = 'GLOBAL';
28
28
  * The full `role_grant_offer` column set, named explicitly so a row read
29
29
  * fails loud on schema drift (see `ACCOUNT_COLUMNS` in
30
30
  * `auth/account_queries.ts` for the outage class). Column order follows
31
- * `RoleGrantOffer` and `ROLE_GRANT_OFFER_SCHEMA` below.
31
+ * `RoleGrantOffer` and `ROLE_GRANT_OFFER_SCHEMA` below, with
32
+ * `expire_audited_at` last — the appended `role_grant_offer_expire_audited_at`
33
+ * migration adds it after the frozen `CREATE TABLE`.
32
34
  *
33
35
  * Lives here rather than in `auth/role_grant_offer_queries.ts` because two
34
36
  * query modules project this table — see the placement rule in
@@ -50,7 +52,8 @@ export const ROLE_GRANT_OFFER_COLUMNS = [
50
52
  'decline_reason',
51
53
  'retracted_at',
52
54
  'superseded_at',
53
- 'resulting_role_grant_id'
55
+ 'resulting_role_grant_id',
56
+ 'expire_audited_at'
54
57
  ];
55
58
  /**
56
59
  * The `role_grant_offer` timestamp override, by row qualifier (`''` bare,
@@ -68,7 +71,8 @@ export const role_grant_offer_expr = iso8601_timestamp_expr(ROLE_GRANT_OFFER_COL
68
71
  'accepted_at',
69
72
  'declined_at',
70
73
  'retracted_at',
71
- 'superseded_at'
74
+ 'superseded_at',
75
+ 'expire_audited_at'
72
76
  ]);
73
77
  /**
74
78
  * Outer `SELECT` over an `updated` CTE of `role_grant_offer` rows, joining
@@ -81,6 +85,9 @@ export const role_grant_offer_expr = iso8601_timestamp_expr(ROLE_GRANT_OFFER_COL
81
85
  export const ROLE_GRANT_OFFER_WITH_GRANTOR_SELECT = `SELECT ${qualify_columns(ROLE_GRANT_OFFER_COLUMNS, 'u', role_grant_offer_expr('u'))}, grantor.account_id AS from_account_id
82
86
  FROM updated u
83
87
  JOIN actor grantor ON grantor.id = u.from_actor_id`;
88
+ // Frozen v1 body — `expire_audited_at` is added by the appended
89
+ // `role_grant_offer_expire_audited_at` migration (with its partial sweep
90
+ // index), so a fresh bootstrap and an old deployed DB converge on one shape.
84
91
  export const ROLE_GRANT_OFFER_SCHEMA = `
85
92
  CREATE TABLE IF NOT EXISTS role_grant_offer (
86
93
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -112,7 +112,9 @@ export declare class RoleGrantOfferActorAccountMismatchError extends Error {
112
112
  * `(to_account, role, scope)` while pending upserts the existing row,
113
113
  * refreshing `message` and `expires_at` (and `to_actor_id` — supplying
114
114
  * a different `to_actor_id` on re-offer narrows the existing row to the
115
- * named actor; supplying null widens it back to account-grain). A
115
+ * named actor; supplying null widens it back to account-grain) and clearing
116
+ * `expire_audited_at`, so a re-offer of an expired, already-audited row gets
117
+ * its own `role_grant_offer_expire` when the refreshed expiry passes. A
116
118
  * different grantor offering the same `(to_account, role, scope)` creates
117
119
  * a distinct row — multiple pending grantors coexist. After a terminal
118
120
  * state, a re-offer is a fresh INSERT.
@@ -176,7 +178,7 @@ export declare const query_role_grant_offer_retract: (deps: QueryDeps, offer_id:
176
178
  *
177
179
  * Expired offers are filtered server-side (`expires_at > NOW()`) so the
178
180
  * inbox never surfaces a row that can no longer be accepted. The periodic
179
- * sweep (`query_role_grant_offer_sweep_expired`) handles audit tombstoning.
181
+ * sweep (`query_role_grant_offer_sweep_expired`) audits each expiry once.
180
182
  */
181
183
  export declare const query_role_grant_offer_list: (deps: QueryDeps, to_account_id: string) => Promise<Array<RoleGrantOffer>>;
182
184
  /**
@@ -191,12 +193,19 @@ export declare const query_role_grant_offer_history_for_account: (deps: QueryDep
191
193
  */
192
194
  export declare const query_role_grant_offer_find_pending: (deps: QueryDeps, offer_id: string) => Promise<RoleGrantOffer | null>;
193
195
  /**
194
- * Return pending offers whose `expires_at` has passed.
196
+ * Claim the pending offers whose `expires_at` has passed and whose expiry has
197
+ * not been audited yet, stamping `expire_audited_at` on each, and return them
198
+ * soonest-expiry first.
195
199
  *
196
- * Callers fire `role_grant_offer_expire` audit events for each row. The schema
197
- * does not tombstone the row, so callers are responsible for their own
198
- * idempotency (e.g. check whether a `role_grant_offer_expire` audit event
199
- * already exists for the offer id).
200
+ * The stamp is the sweep's idempotency: a claimed row never matches again, so
201
+ * each expiry is audited exactly once. It must share a transaction with the
202
+ * caller's `role_grant_offer_expire` audit inserts — committed together, or
203
+ * rolled back together so the next sweep retries. Concurrent sweeps claim
204
+ * disjoint rows: a row another sweep has stamped but not committed blocks on
205
+ * its row lock, then fails the re-checked `expire_audited_at IS NULL`.
206
+ * `cleanup_expired_role_grant_offers` is the caller.
207
+ *
208
+ * @mutates `role_grant_offer` rows - stamps `expire_audited_at` on each claimed row
200
209
  */
201
210
  export declare const query_role_grant_offer_sweep_expired: (deps: QueryDeps) => Promise<Array<RoleGrantOffer>>;
202
211
  /** Input for `query_accept_offer`. */
@@ -1 +1 @@
1
- {"version":3,"file":"role_grant_offer_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/role_grant_offer_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAEnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AASrD,OAAO,KAAK,EACX,yBAAyB,EACzB,cAAc,EACd,eAAe,EACf,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAG3D;;;;;GAKG;AACH,qBAAa,kCAAmC,SAAQ,KAAK;gBAChD,QAAQ,EAAE,MAAM;CAI5B;AAED;;;;;GAKG;AACH,qBAAa,0BAA2B,SAAQ,KAAK;gBACxC,QAAQ,EAAE,MAAM;CAI5B;AAED;;;;;GAKG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;gBACzC,QAAQ,EAAE,MAAM;CAI5B;AAED;;;;;;;;GAQG;AACH,qBAAa,6BAA8B,SAAQ,KAAK;;CAKvD;AAED;;;;;;;GAOG;AACH,qBAAa,gCAAiC,SAAQ,KAAK;gBAC9C,QAAQ,EAAE,MAAM;CAI5B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,mBAAmB,GAAG,gBAAgB,GAAG,QAAQ,GAAG,wBAAwB,CAAC;AAEzF;;;;;GAKG;AACH,qBAAa,iCAAkC,SAAQ,KAAK;IAC3D,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;gBAC1B,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,mBAAmB;CAM/D;AAED;;;;;GAKG;AACH,qBAAa,uCAAwC,SAAQ,KAAK;;CAKjE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,OAAO,yBAAyB,KAC9B,OAAO,CAAC,cAAc,CAuDxB,CAAC;AAEF,uGAAuG;AACvG,MAAM,WAAW,aAAc,SAAQ,cAAc;IACpD;;;;;OAKG;IACH,eAAe,EAAE,IAAI,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,SAAS,EACf,UAAU,MAAM,EAChB,eAAe,MAAM,EACrB,QAAQ,MAAM,GAAG,IAAI,KACnB,OAAO,CAAC,aAAa,GAAG,IAAI,CAmB9B,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,SAAS,EACf,UAAU,MAAM,EAChB,eAAe,MAAM,KACnB,OAAO,CAAC,cAAc,GAAG,IAAI,CAe/B,CAAC;AA8BF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,SAAS,EACf,eAAe,MAAM,KACnB,OAAO,CAAC,KAAK,CAAC,cAAc,CAAC,CAY/B,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,0CAA0C,GACtD,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAW,EACX,eAAU,KACR,OAAO,CAAC,KAAK,CAAC,cAAc,CAAC,CAS/B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mCAAmC,GAC/C,MAAM,SAAS,EACf,UAAU,MAAM,KACd,OAAO,CAAC,cAAc,GAAG,IAAI,CAY/B,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,oCAAoC,GAChD,MAAM,SAAS,KACb,OAAO,CAAC,KAAK,CAAC,cAAc,CAAC,CAU/B,CAAC;AAsCF,sCAAsC;AACtC,MAAM,WAAW,gBAAgB;IAChC,QAAQ,EAAE,IAAI,CAAC;IACf,mGAAmG;IACnG,aAAa,EAAE,IAAI,CAAC;IACpB;;;;;;;;;;OAUG;IACH,QAAQ,EAAE,IAAI,CAAC;IACf,gDAAgD;IAChD,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACnB;AAED,6HAA6H;AAC7H,MAAM,WAAW,iBAAiB;IACjC,UAAU,EAAE,SAAS,CAAC;IACtB,KAAK,EAAE,cAAc,CAAC;IACtB,oJAAoJ;IACpJ,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;OAKG;IACH,iBAAiB,EAAE,KAAK,CAAC,eAAe,CAAC,CAAC;IAC1C,yMAAyM;IACzM,YAAY,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,eAAO,MAAM,kBAAkB,GAC9B,MAAM,SAAS,EACf,OAAO,gBAAgB,KACrB,OAAO,CAAC,iBAAiB,CAgP3B,CAAC"}
1
+ {"version":3,"file":"role_grant_offer_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/role_grant_offer_queries.ts"],"names":[],"mappings":"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"}
@@ -118,7 +118,9 @@ export class RoleGrantOfferActorAccountMismatchError extends Error {
118
118
  * `(to_account, role, scope)` while pending upserts the existing row,
119
119
  * refreshing `message` and `expires_at` (and `to_actor_id` — supplying
120
120
  * a different `to_actor_id` on re-offer narrows the existing row to the
121
- * named actor; supplying null widens it back to account-grain). A
121
+ * named actor; supplying null widens it back to account-grain) and clearing
122
+ * `expire_audited_at`, so a re-offer of an expired, already-audited row gets
123
+ * its own `role_grant_offer_expire` when the refreshed expiry passes. A
122
124
  * different grantor offering the same `(to_account, role, scope)` creates
123
125
  * a distinct row — multiple pending grantors coexist. After a terminal
124
126
  * state, a re-offer is a fresh INSERT.
@@ -170,7 +172,8 @@ export const query_role_grant_offer_create = async (deps, input) => {
170
172
  DO UPDATE SET
171
173
  to_actor_id = EXCLUDED.to_actor_id,
172
174
  message = EXCLUDED.message,
173
- expires_at = EXCLUDED.expires_at
175
+ expires_at = EXCLUDED.expires_at,
176
+ expire_audited_at = NULL
174
177
  RETURNING ${columns_sql(ROLE_GRANT_OFFER_COLUMNS, role_grant_offer_expr(''))}`, [
175
178
  input.from_actor_id,
176
179
  input.to_account_id,
@@ -269,7 +272,7 @@ const resolve_terminal_or_missing = async (deps, offer_id, scope) => {
269
272
  *
270
273
  * Expired offers are filtered server-side (`expires_at > NOW()`) so the
271
274
  * inbox never surfaces a row that can no longer be accepted. The periodic
272
- * sweep (`query_role_grant_offer_sweep_expired`) handles audit tombstoning.
275
+ * sweep (`query_role_grant_offer_sweep_expired`) audits each expiry once.
273
276
  */
274
277
  export const query_role_grant_offer_list = async (deps, to_account_id) => {
275
278
  return deps.db.query(`SELECT ${columns_sql(ROLE_GRANT_OFFER_COLUMNS, role_grant_offer_expr(''))} FROM role_grant_offer
@@ -308,21 +311,36 @@ export const query_role_grant_offer_find_pending = async (deps, offer_id) => {
308
311
  return row ?? null;
309
312
  };
310
313
  /**
311
- * Return pending offers whose `expires_at` has passed.
314
+ * Claim the pending offers whose `expires_at` has passed and whose expiry has
315
+ * not been audited yet, stamping `expire_audited_at` on each, and return them
316
+ * soonest-expiry first.
312
317
  *
313
- * Callers fire `role_grant_offer_expire` audit events for each row. The schema
314
- * does not tombstone the row, so callers are responsible for their own
315
- * idempotency (e.g. check whether a `role_grant_offer_expire` audit event
316
- * already exists for the offer id).
318
+ * The stamp is the sweep's idempotency: a claimed row never matches again, so
319
+ * each expiry is audited exactly once. It must share a transaction with the
320
+ * caller's `role_grant_offer_expire` audit inserts — committed together, or
321
+ * rolled back together so the next sweep retries. Concurrent sweeps claim
322
+ * disjoint rows: a row another sweep has stamped but not committed blocks on
323
+ * its row lock, then fails the re-checked `expire_audited_at IS NULL`.
324
+ * `cleanup_expired_role_grant_offers` is the caller.
325
+ *
326
+ * @mutates `role_grant_offer` rows - stamps `expire_audited_at` on each claimed row
317
327
  */
318
328
  export const query_role_grant_offer_sweep_expired = async (deps) => {
319
- return deps.db.query(`SELECT ${columns_sql(ROLE_GRANT_OFFER_COLUMNS, role_grant_offer_expr(''))} FROM role_grant_offer
320
- WHERE accepted_at IS NULL
321
- AND declined_at IS NULL
322
- AND retracted_at IS NULL
323
- AND superseded_at IS NULL
324
- AND expires_at <= NOW()
325
- ORDER BY role_grant_offer.expires_at ASC`);
329
+ return deps.db.query(`WITH claimed AS (
330
+ UPDATE role_grant_offer
331
+ SET expire_audited_at = NOW()
332
+ WHERE accepted_at IS NULL
333
+ AND declined_at IS NULL
334
+ AND retracted_at IS NULL
335
+ AND superseded_at IS NULL
336
+ AND expires_at <= NOW()
337
+ AND expire_audited_at IS NULL
338
+ -- raw columns; the outer select formats them
339
+ RETURNING ${columns_sql(ROLE_GRANT_OFFER_COLUMNS)}
340
+ )
341
+ SELECT ${qualify_columns(ROLE_GRANT_OFFER_COLUMNS, 'c', role_grant_offer_expr('c'))}
342
+ FROM claimed c
343
+ ORDER BY c.expires_at ASC, c.id ASC`);
326
344
  };
327
345
  /**
328
346
  * Whether `grantor_actor_id` holds an active **global** `admin` on an active
@@ -60,6 +60,15 @@ export interface RoleGrantOffer {
60
60
  */
61
61
  superseded_at: string | null;
62
62
  resulting_role_grant_id: Uuid | null;
63
+ /**
64
+ * When the expiry sweep audited this offer's `role_grant_offer_expire` —
65
+ * the claim stamp `query_role_grant_offer_sweep_expired` sets in the same
66
+ * transaction as the audit row, so each expiry is audited once. Not a
67
+ * lifecycle state (an expired offer is still pending by the terminal
68
+ * columns) and server-only: `to_role_grant_offer_json` drops it, and the
69
+ * strict `RoleGrantOfferJson` would reject it. A re-offer clears it.
70
+ */
71
+ expire_audited_at: string | null;
63
72
  }
64
73
  /**
65
74
  * A superseded offer row annotated with the grantor's `account_id`.