@fuzdev/fuz_app 0.117.1 → 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.
- package/dist/auth/CLAUDE.md +17 -4
- package/dist/auth/audit_emitter.d.ts +13 -8
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +5 -4
- package/dist/auth/bootstrap_account.d.ts +3 -2
- package/dist/auth/bootstrap_account.d.ts.map +1 -1
- package/dist/auth/bootstrap_account.js +3 -3
- package/dist/auth/cleanup.d.ts +25 -11
- package/dist/auth/cleanup.d.ts.map +1 -1
- package/dist/auth/cleanup.js +47 -28
- package/dist/auth/deps.d.ts +1 -1
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +27 -0
- package/dist/auth/role_grant_offer_ddl.d.ts +4 -2
- package/dist/auth/role_grant_offer_ddl.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_ddl.js +10 -3
- package/dist/auth/role_grant_offer_queries.d.ts +16 -7
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_queries.js +33 -15
- package/dist/auth/role_grant_offer_schema.d.ts +9 -0
- package/dist/auth/role_grant_offer_schema.d.ts.map +1 -1
- package/dist/db/migrate.d.ts +8 -13
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +9 -38
- package/dist/db/status.d.ts +0 -6
- package/dist/db/status.d.ts.map +1 -1
- package/dist/db/status.js +3 -23
- package/dist/runtime/deno.d.ts.map +1 -1
- package/dist/runtime/deno.js +9 -6
- package/dist/runtime/deps.d.ts +3 -2
- package/dist/runtime/deps.d.ts.map +1 -1
- package/dist/runtime/secure_file.d.ts +23 -9
- package/dist/runtime/secure_file.d.ts.map +1 -1
- package/dist/runtime/secure_file.js +29 -10
- package/dist/server/app_backend.d.ts +1 -1
- package/dist/testing/audit_drift_guard.d.ts +3 -5
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +3 -5
- package/dist/testing/cross_backend/expected_schema.json +1 -0
- package/package.json +2 -2
package/dist/auth/CLAUDE.md
CHANGED
|
@@ -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` (
|
|
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.
|
|
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 (
|
|
26
|
-
*
|
|
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
|
|
30
|
-
*
|
|
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
|
|
123
|
-
* `await audit.emit_pool(...)` without try/catch boilerplate.
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
|
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 (
|
|
26
|
-
*
|
|
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
|
|
30
|
-
*
|
|
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,
|
|
57
|
-
* or oversized file must refuse rather than be
|
|
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
|
|
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 /
|
|
40
|
-
// every refusal masks as the same TOKEN_FILE_MISSING the
|
|
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();
|
package/dist/auth/cleanup.d.ts
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* production wiring always has a bound emitter on
|
|
32
|
-
* tests that need a no-op pass
|
|
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
|
-
*
|
|
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
|
|
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"}
|
package/dist/auth/cleanup.js
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
*/
|
package/dist/auth/deps.d.ts
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/auth/migrations.js
CHANGED
|
@@ -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
|
|
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)
|
|
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`)
|
|
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
|
-
*
|
|
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
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
|
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)
|
|
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`)
|
|
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
|
-
*
|
|
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
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
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(`
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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`.
|