@fuzdev/fuz_app 0.63.0 → 0.64.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 (107) hide show
  1. package/dist/actions/CLAUDE.md +124 -11
  2. package/dist/actions/connection_closer.d.ts +68 -0
  3. package/dist/actions/connection_closer.d.ts.map +1 -0
  4. package/dist/actions/connection_closer.js +41 -0
  5. package/dist/actions/register_action_ws.d.ts.map +1 -1
  6. package/dist/actions/register_action_ws.js +23 -2
  7. package/dist/actions/register_ws_endpoint.d.ts +11 -9
  8. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  9. package/dist/actions/register_ws_endpoint.js +5 -5
  10. package/dist/actions/transports_ws_auth_guard.d.ts +24 -8
  11. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  12. package/dist/actions/transports_ws_auth_guard.js +23 -7
  13. package/dist/actions/ws_endpoint_spec.d.ts +119 -0
  14. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -0
  15. package/dist/actions/ws_endpoint_spec.js +13 -0
  16. package/dist/auth/CLAUDE.md +79 -15
  17. package/dist/auth/account_action_specs.d.ts +1 -1
  18. package/dist/auth/account_actions.d.ts +13 -0
  19. package/dist/auth/account_actions.d.ts.map +1 -1
  20. package/dist/auth/account_actions.js +31 -1
  21. package/dist/auth/account_routes.d.ts +12 -2
  22. package/dist/auth/account_routes.d.ts.map +1 -1
  23. package/dist/auth/account_routes.js +55 -8
  24. package/dist/auth/account_schema.d.ts +3 -3
  25. package/dist/auth/admin_action_specs.d.ts +8 -8
  26. package/dist/auth/admin_actions.d.ts +11 -0
  27. package/dist/auth/admin_actions.d.ts.map +1 -1
  28. package/dist/auth/admin_actions.js +25 -0
  29. package/dist/auth/audit_emitter.d.ts +56 -12
  30. package/dist/auth/audit_emitter.d.ts.map +1 -1
  31. package/dist/auth/audit_emitter.js +38 -12
  32. package/dist/auth/audit_log_schema.d.ts +5 -3
  33. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  34. package/dist/auth/audit_log_schema.js +5 -3
  35. package/dist/auth/bootstrap_routes.d.ts +1 -1
  36. package/dist/auth/invite_schema.d.ts +2 -2
  37. package/dist/auth/signup_routes.d.ts +1 -1
  38. package/dist/auth/standard_rpc_actions.d.ts +1 -0
  39. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  40. package/dist/auth/standard_rpc_actions.js +1 -0
  41. package/dist/http/CLAUDE.md +26 -10
  42. package/dist/http/ip_canonical.d.ts +99 -0
  43. package/dist/http/ip_canonical.d.ts.map +1 -0
  44. package/dist/http/ip_canonical.js +191 -0
  45. package/dist/http/origin.d.ts +13 -5
  46. package/dist/http/origin.d.ts.map +1 -1
  47. package/dist/http/origin.js +13 -31
  48. package/dist/http/pending_effects.d.ts +1 -1
  49. package/dist/http/pending_effects.js +1 -1
  50. package/dist/http/proxy.d.ts +13 -5
  51. package/dist/http/proxy.d.ts.map +1 -1
  52. package/dist/http/proxy.js +15 -23
  53. package/dist/http/surface.d.ts +50 -0
  54. package/dist/http/surface.d.ts.map +1 -1
  55. package/dist/http/surface.js +27 -1
  56. package/dist/primitive_schemas.d.ts +20 -4
  57. package/dist/primitive_schemas.d.ts.map +1 -1
  58. package/dist/primitive_schemas.js +25 -4
  59. package/dist/realtime/sse_auth_guard.d.ts +16 -4
  60. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  61. package/dist/realtime/sse_auth_guard.js +15 -3
  62. package/dist/server/app_backend.d.ts +66 -19
  63. package/dist/server/app_backend.d.ts.map +1 -1
  64. package/dist/server/app_backend.js +57 -34
  65. package/dist/server/app_server.d.ts +60 -0
  66. package/dist/server/app_server.d.ts.map +1 -1
  67. package/dist/server/app_server.js +95 -2
  68. package/dist/server/startup.d.ts.map +1 -1
  69. package/dist/server/startup.js +12 -0
  70. package/dist/testing/CLAUDE.md +64 -28
  71. package/dist/testing/admin_integration.d.ts.map +1 -1
  72. package/dist/testing/admin_integration.js +4 -5
  73. package/dist/testing/adversarial_headers.d.ts +6 -0
  74. package/dist/testing/adversarial_headers.d.ts.map +1 -1
  75. package/dist/testing/adversarial_headers.js +13 -5
  76. package/dist/testing/app_server.d.ts +33 -32
  77. package/dist/testing/app_server.d.ts.map +1 -1
  78. package/dist/testing/app_server.js +4 -13
  79. package/dist/testing/attack_surface.d.ts +8 -7
  80. package/dist/testing/attack_surface.d.ts.map +1 -1
  81. package/dist/testing/attack_surface.js +12 -8
  82. package/dist/testing/audit_completeness.d.ts.map +1 -1
  83. package/dist/testing/audit_completeness.js +3 -5
  84. package/dist/testing/audit_drift_guard.d.ts +116 -0
  85. package/dist/testing/audit_drift_guard.d.ts.map +1 -0
  86. package/dist/testing/audit_drift_guard.js +134 -0
  87. package/dist/testing/connection_closer_helpers.d.ts +44 -0
  88. package/dist/testing/connection_closer_helpers.d.ts.map +1 -0
  89. package/dist/testing/connection_closer_helpers.js +48 -0
  90. package/dist/testing/integration.d.ts.map +1 -1
  91. package/dist/testing/integration.js +7 -9
  92. package/dist/testing/rate_limiting.js +4 -4
  93. package/dist/testing/rpc_helpers.d.ts +2 -1
  94. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  95. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  96. package/dist/testing/rpc_round_trip.js +6 -8
  97. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  98. package/dist/testing/sse_round_trip.js +12 -6
  99. package/dist/testing/stubs.d.ts +11 -0
  100. package/dist/testing/stubs.d.ts.map +1 -1
  101. package/dist/testing/stubs.js +4 -0
  102. package/dist/testing/surface_invariants.d.ts +66 -1
  103. package/dist/testing/surface_invariants.d.ts.map +1 -1
  104. package/dist/testing/surface_invariants.js +103 -1
  105. package/dist/ui/SurfaceExplorer.svelte +161 -2
  106. package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
  107. package/package.json +1 -1
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `WsEndpointSpec` — the canonical WebSocket endpoint declaration consumed
3
+ * by `create_app_server`'s `ws_endpoints` option (mirror of `RpcEndpointSpec`
4
+ * for HTTP RPC).
5
+ *
6
+ * Lives in its own module so both `server/app_server.ts` (which mounts
7
+ * endpoints from these specs) and `http/surface.ts` (which threads the
8
+ * resolved spec list into surface generation) can import it without a
9
+ * cycle between the two.
10
+ *
11
+ * @module
12
+ */
13
+ export {};
@@ -375,10 +375,11 @@ Zod enum; `AuditOutcome` is `'success' | 'failure'`.
375
375
  - **Consumer extensibility**: `create_audit_log_config({extra_events})`
376
376
  builds an `AuditLogConfig` merging builtins with consumer event-type
377
377
  strings keyed to a Zod schema (validates metadata) or `null` (registers
378
- without validation). Pass the result to `create_app_backend({audit_log_config})`
379
- it gets captured inside the bound `AppDeps.audit` emitter, and every
380
- call to `audit.emit` validates against it (defaults to
381
- `builtin_audit_log_config` when absent). `query_audit_log` still accepts
378
+ without validation). Pass the result into the consumer's `audit_factory`
379
+ body typically `({db, log}) => create_audit_emitter({db, log,
380
+ audit_log_config, on_audit_event})` so it gets captured inside the
381
+ bound `AppDeps.audit` emitter; every call to `audit.emit` validates
382
+ against it (defaults to `builtin_audit_log_config` when absent). `query_audit_log` still accepts
382
383
  the trailing `config` positional arg for in-transaction emit sites that
383
384
  hold a transaction-scoped DB only. Builtin collisions and
384
385
  `AuditEventTypeName` format failures throw at construction. The DB
@@ -756,7 +757,9 @@ run'` if the seed somehow missed (defensive — migrations always seed).
756
757
  - `query_audit_log_list_role_grant_history` (filters to `role_grant_create` / `role_grant_revoke`).
757
758
  - `query_audit_log_cleanup_before`.
758
759
  - **Audit fan-out runs through `AppDeps.audit`** (the bound emitter built
759
- by `create_audit_emitter` at backend assembly — see §`audit_emitter.ts`).
760
+ by the consumer's `audit_factory` callback on `CreateAppBackendOptions`,
761
+ typically a one-liner over `create_audit_emitter` — see
762
+ §`audit_emitter.ts`).
760
763
  `audit.emit(ctx, input)` writes via the captured pool, so audit entries
761
764
  persist even when the request transaction rolls back. The emitter
762
765
  closes over `on_audit_event` + `audit_log_config` so handlers can never
@@ -767,7 +770,11 @@ run'` if the seed somehow missed (defensive — migrations always seed).
767
770
  ### `audit_emitter.ts`
768
771
 
769
772
  `AuditEmitter` is the bound capability that lives on `AppDeps.audit`,
770
- built once at `create_app_backend` time.
773
+ built once by the consumer's `audit_factory` callback on
774
+ `CreateAppBackendOptions`. `create_app_backend` invokes the callback
775
+ with its constructed `{db, log}` after migrations run; the canonical
776
+ body is `({db, log}) => create_audit_emitter({db, log, on_audit_event,
777
+ audit_log_config})`.
771
778
 
772
779
  Four methods:
773
780
 
@@ -1331,6 +1338,19 @@ Closure state:
1331
1338
  When absent, those two specs are still present in `all_admin_action_specs`
1332
1339
  (surface-wise) but the handlers are not wired — RPC dispatch returns
1333
1340
  `method_not_found`.
1341
+ - `options.connection_closer?: ConnectionCloser | null` — when set,
1342
+ `admin_session_revoke_all` and `admin_token_revoke_all` handlers
1343
+ eagerly close the target account's live WS sockets via
1344
+ `close_sockets_for_account(input.account_id)` BEFORE emitting the
1345
+ audit event. Failure outcomes (404 account-not-found) skip the
1346
+ eager close. Listener-based close
1347
+ (`transports_ws_auth_guard` on `audit.on_event_chain`) stays as a
1348
+ fail-safe; primitives are idempotent. Symmetric with the
1349
+ self-service surface (see `AccountActionOptions.connection_closer`
1350
+ below). `BackendWebsocketTransport` satisfies the interface
1351
+ structurally. Mirrors `zzz_server`'s parity pass — fuz_app widens
1352
+ to admin-side too (Rust port's catch-up tracked in
1353
+ `~/dev/zzz/TODO_RUST_SERVER_DETAILS.md`).
1334
1354
 
1335
1355
  `all_admin_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
1336
1356
  registry of all eleven specs (always includes the two app-settings specs).
@@ -1494,6 +1514,8 @@ surface drop down to the per-domain factories directly.
1494
1514
  Option routing: `roles` is shared between admin and role-grant-offer;
1495
1515
  `app_settings` flows to admin only; `default_ttl_ms` and `authorize`
1496
1516
  flow to role-grant-offer only; `max_tokens` flows to account only;
1517
+ `connection_closer` is shared between admin and account (handler-side
1518
+ eager WS close on revoke; role-grant-offer ignores);
1497
1519
  `notification_sender` is wired through to role-grant-offer (admin +
1498
1520
  account ignore it).
1499
1521
 
@@ -1530,6 +1552,29 @@ and `create_frontend_rpc_client({specs})` wiring. Self-service role
1530
1552
  specs are not included (opt-in, app-specific `eligible_roles`) —
1531
1553
  spread `all_self_service_role_action_specs` separately when needed.
1532
1554
 
1555
+ To expose the standard surface over WebSocket as well as HTTP RPC,
1556
+ spread `protocol_actions` and `create_standard_rpc_actions(ctx.deps,
1557
+ opts)` into `create_app_server`'s `ws_endpoints` factory alongside the
1558
+ matching `rpc_endpoints` entry:
1559
+
1560
+ ```ts
1561
+ ws_endpoints: (ctx) => [{
1562
+ path: '/api/ws',
1563
+ allowed_origins,
1564
+ actions: [
1565
+ ...protocol_actions,
1566
+ ...create_standard_rpc_actions(ctx.deps, opts),
1567
+ ...consumer_local_actions,
1568
+ ],
1569
+ }],
1570
+ ```
1571
+
1572
+ The same action factory powers both surfaces; per-message authorization
1573
+ and rate limiting fire identically across HTTP RPC and WS. With both
1574
+ endpoints mounted, a typed frontend client can route per-method via
1575
+ `transport_for_method` (e.g. `account_*` over WS for live revocation
1576
+ detection, admin reads over HTTP).
1577
+
1533
1578
  ### `all_action_spec_registries.ts` — walker-only registry-of-registries
1534
1579
 
1535
1580
  `all_fuz_auth_action_spec_registries` — walker/codegen entry for every
@@ -1601,8 +1646,23 @@ The REST `password_change` audit row mirrors the same field on all
1601
1646
  three outcomes (success, wrong-password, concurrent-change).
1602
1647
 
1603
1648
  Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>`.
1604
- Options: `{max_tokens?: number | null}` defaults to `DEFAULT_MAX_TOKENS`
1605
- from `account_routes.ts`; `null` disables the cap.
1649
+ Options: `{max_tokens?: number | null, connection_closer?: ConnectionCloser | null}`.
1650
+ `max_tokens` defaults to `DEFAULT_MAX_TOKENS` from `account_routes.ts`;
1651
+ `null` disables the cap. `connection_closer` (from
1652
+ `actions/connection_closer.ts`) wires handler-side eager WS socket
1653
+ closure: `account_session_revoke` calls `close_sockets_for_session(input.session_id)`,
1654
+ `_session_revoke_all` calls `close_sockets_for_account(account.id)`,
1655
+ `account_token_revoke` calls `close_sockets_for_token(input.token_id)` —
1656
+ each fires synchronously BEFORE the audit emit so revocation lands even
1657
+ when the audit INSERT fails. Listener-based close
1658
+ (`transports_ws_auth_guard` on `audit.on_event_chain`) stays as a
1659
+ fail-safe; close primitives are idempotent. Failure outcomes
1660
+ (`revoked: false`) skip the eager close — mirrors the listener's
1661
+ `outcome === 'failure'` guard so attacker-guessable ids can't be used to
1662
+ target arbitrary sockets. `BackendWebsocketTransport` satisfies
1663
+ `ConnectionCloser` structurally — consumers pass the WS transport
1664
+ instance directly. Mirrors `zzz_server::handlers/account.rs` (landed
1665
+ 2026-05-16).
1606
1666
 
1607
1667
  `all_account_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
1608
1668
  registry of all seven specs.
@@ -1810,15 +1870,19 @@ resulting role_grant.
1810
1870
  - `db: Db` — pool-level instance (middleware uses this; route handlers
1811
1871
  get a transaction-scoped `Db` via `RouteContext`).
1812
1872
  - `log: Logger`.
1813
- - `audit: AuditEmitter` — bound emitter built once at `create_app_backend`
1814
- via `create_audit_emitter`. Closes over the pool, the
1873
+ - `audit: AuditEmitter` — bound emitter built once by the consumer's
1874
+ `audit_factory` callback on `CreateAppBackendOptions`. The factory
1875
+ runs after `create_db` resolves and migrations apply;
1876
+ `create_app_backend` invokes it with `{db, log}` and lands the
1877
+ returned emitter on `deps.audit`. The canonical body is one line
1878
+ over `create_audit_emitter` (closes over the pool, the
1815
1879
  `on_audit_event` subscriber chain, and the optional
1816
- `AuditLogConfig` so handlers reach `audit.emit(ctx, input)` /
1880
+ `AuditLogConfig`); consumers wrap or replace it for tests. Handlers
1881
+ reach `audit.emit(ctx, input)` /
1817
1882
  `audit.emit_role_grant_target(ctx, auth, input)` and never see the
1818
- pool. Pass `on_audit_event` and `audit_log_config` to
1819
- `create_app_backend` both fold into `audit`'s closure and the slot
1820
- is the single seam for SSE/WS fan-out (additional listeners append
1821
- via `audit.on_event_chain.push(...)` at server assembly).
1883
+ pool. The slot is the single seam for SSE/WS fan-out — additional
1884
+ listeners append via `audit.on_event_chain.push(...)` at server
1885
+ assembly.
1822
1886
  - **`RouteFactoryDeps = Omit<AppDeps, 'db'>`** — for route factories. Route
1823
1887
  handlers receive DB access via `RouteContext`, so factories don't capture
1824
1888
  a pool-level `Db`.
@@ -98,7 +98,7 @@ export declare const account_verify_action_spec: {
98
98
  input: z.ZodVoid;
99
99
  output: z.ZodObject<{
100
100
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
101
- username: z.ZodString;
101
+ username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
102
102
  email: z.ZodNullable<z.ZodEmail>;
103
103
  email_verified: z.ZodBoolean;
104
104
  created_at: z.ZodString;
@@ -23,6 +23,7 @@
23
23
  * @module
24
24
  */
25
25
  import { type RpcAction } from '../actions/action_rpc.js';
26
+ import type { ConnectionCloser } from '../actions/connection_closer.js';
26
27
  import type { RouteFactoryDeps } from './deps.js';
27
28
  /** Options for `create_account_actions`. */
28
29
  export interface AccountActionOptions {
@@ -33,6 +34,18 @@ export interface AccountActionOptions {
33
34
  * `DEFAULT_MAX_TOKENS`; pass `null` to disable the cap.
34
35
  */
35
36
  max_tokens?: number | null;
37
+ /**
38
+ * Live-connection closer — when set, `account_session_revoke` /
39
+ * `_session_revoke_all` / `account_token_revoke` handlers eagerly close
40
+ * affected WebSocket sockets BEFORE emitting the corresponding audit
41
+ * event. Closes the audit-failure-leaks-WS surface: the listener-based
42
+ * close (`transports_ws_auth_guard`) only fires after the audit INSERT
43
+ * succeeds, so a DB error would leave live sockets stale. `BackendWebsocketTransport`
44
+ * satisfies this interface structurally; consumers pass their transport
45
+ * instance directly. When absent, only the listener-based close runs.
46
+ * Mirrors `zzz_server`'s handler-side `close_sockets_for_*` calls.
47
+ */
48
+ connection_closer?: ConnectionCloser | null;
36
49
  }
37
50
  /**
38
51
  * Create the self-service account RPC actions.
@@ -1 +1 @@
1
- {"version":3,"file":"account_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAqC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAe5F,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAwBhD,4CAA4C;AAC5C,MAAM,WAAW,oBAAoB;IACpC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,IAAI,CAAC,gBAAgB,EAAE,KAAK,GAAG,OAAO,CAAC,EAC7C,UAAS,oBAAyB,KAChC,KAAK,CAAC,SAAS,CA2GjB,CAAC"}
1
+ {"version":3,"file":"account_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAqC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5F,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,iCAAiC,CAAC;AAetE,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAwBhD,4CAA4C;AAC5C,MAAM,WAAW,oBAAoB;IACpC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B;;;;;;;;;;OAUG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;CAC5C;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,IAAI,CAAC,gBAAgB,EAAE,KAAK,GAAG,OAAO,CAAC,EAC7C,UAAS,oBAAyB,KAChC,KAAK,CAAC,SAAS,CAyIjB,CAAC"}
@@ -39,7 +39,7 @@ import { account_verify_action_spec, account_session_list_action_spec, account_s
39
39
  * @returns the `RpcAction` array to spread into a `create_rpc_endpoint` call
40
40
  */
41
41
  export const create_account_actions = (deps, options = {}) => {
42
- const { max_tokens = DEFAULT_MAX_TOKENS } = options;
42
+ const { max_tokens = DEFAULT_MAX_TOKENS, connection_closer = null } = options;
43
43
  const verify_handler = (_input, ctx) => {
44
44
  return to_session_account(ctx.auth.account);
45
45
  };
@@ -49,6 +49,21 @@ export const create_account_actions = (deps, options = {}) => {
49
49
  };
50
50
  const session_revoke_handler = async (input, ctx) => {
51
51
  const revoked = await query_session_revoke_for_account(ctx, input.session_id, ctx.auth.account.id);
52
+ // Handler-side belt+suspenders: close the live WS socket bound to this
53
+ // session BEFORE the audit emit, so revocation lands even if the audit
54
+ // INSERT fails. The real ordering invariant is "before the transaction
55
+ // commits": this handler runs inside the dispatcher's transaction
56
+ // (side_effects: true), so any throw between this close and the return
57
+ // would roll back the DB revoke while leaving the socket severed. That
58
+ // is benign — the session is still valid, the client reconnects — but
59
+ // don't introduce a throw here without acknowledging the trade.
60
+ // Only fire on success — failure carries an attacker-guessable
61
+ // session_id and the listener-based close already ignores failure
62
+ // outcomes for the same reason. Idempotent — the audit listener runs a
63
+ // second close on success but matches no sockets the second time.
64
+ if (revoked && connection_closer) {
65
+ connection_closer.close_sockets_for_session(input.session_id);
66
+ }
52
67
  deps.audit.emit(ctx, {
53
68
  event_type: 'session_revoke',
54
69
  outcome: revoked ? 'success' : 'failure',
@@ -61,6 +76,17 @@ export const create_account_actions = (deps, options = {}) => {
61
76
  };
62
77
  const session_revoke_all_handler = async (_input, ctx) => {
63
78
  const count = await query_session_revoke_all_for_account(ctx, ctx.auth.account.id);
79
+ // Handler-side belt+suspenders — see session_revoke_handler comment.
80
+ // Close fires regardless of `count` (today `count >= 1` always — the
81
+ // caller is using the session they're revoking; future bearer / daemon-
82
+ // token-credentialed callers may hit `count: 0`). Symmetric with the
83
+ // admin revoke-all handlers in `admin_actions.ts`, where `count: 0` is
84
+ // a real outcome (target account had no live sessions/tokens) and the
85
+ // eager close still fires to scrub sockets that the audit listener
86
+ // would otherwise miss when the INSERT fails. Idempotent at all counts.
87
+ if (connection_closer) {
88
+ connection_closer.close_sockets_for_account(ctx.auth.account.id);
89
+ }
64
90
  deps.audit.emit(ctx, {
65
91
  event_type: 'session_revoke_all',
66
92
  account_id: ctx.auth.account.id,
@@ -93,6 +119,10 @@ export const create_account_actions = (deps, options = {}) => {
93
119
  };
94
120
  const token_revoke_handler = async (input, ctx) => {
95
121
  const revoked = await query_revoke_api_token_for_account(ctx, input.token_id, ctx.auth.account.id);
122
+ // Handler-side belt+suspenders — see session_revoke_handler comment.
123
+ if (revoked && connection_closer) {
124
+ connection_closer.close_sockets_for_token(input.token_id);
125
+ }
96
126
  deps.audit.emit(ctx, {
97
127
  event_type: 'token_revoke',
98
128
  outcome: revoked ? 'success' : 'failure',
@@ -26,6 +26,7 @@ import type { SessionOptions } from './session_cookie.js';
26
26
  import { type RouteSpec } from '../http/route_spec.js';
27
27
  import { type RateLimiter } from '../rate_limiter.js';
28
28
  import type { RouteFactoryDeps } from './deps.js';
29
+ import type { ConnectionCloser } from '../actions/connection_closer.js';
29
30
  /** Input for `GET /api/account/status`. No parameters — caller is the subject. */
30
31
  export declare const AccountStatusInput: z.ZodNull;
31
32
  export type AccountStatusInput = z.infer<typeof AccountStatusInput>;
@@ -41,7 +42,7 @@ export type AccountStatusInput = z.infer<typeof AccountStatusInput>;
41
42
  export declare const AccountStatusOutput: z.ZodObject<{
42
43
  account: z.ZodObject<{
43
44
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
44
- username: z.ZodString;
45
+ username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
45
46
  email: z.ZodNullable<z.ZodEmail>;
46
47
  email_verified: z.ZodBoolean;
47
48
  created_at: z.ZodString;
@@ -143,10 +144,19 @@ export interface AccountRouteOptions extends AuthSessionRouteOptions {
143
144
  * jitter while keeping the floor. Default `DEFAULT_LOGIN_FAIL_JITTER_MS`.
144
145
  */
145
146
  login_fail_jitter_ms?: number;
147
+ /**
148
+ * Live-connection closer — when set, the `logout` and `password` handlers
149
+ * eagerly close affected WebSocket sockets for the account BEFORE
150
+ * emitting the corresponding audit event. Mirrors the self-service
151
+ * action surface (see `AccountActionOptions.connection_closer`). When
152
+ * absent, only the listener-based close (`transports_ws_auth_guard` on
153
+ * `audit.on_event_chain`) runs.
154
+ */
155
+ connection_closer?: ConnectionCloser | null;
146
156
  }
147
157
  /** Input for `POST /login`. Accepts a username or email in the `username` field. */
148
158
  export declare const LoginInput: z.ZodObject<{
149
- username: z.ZodString;
159
+ username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
150
160
  password: z.ZodString;
151
161
  }, z.core.$strict>;
152
162
  export type LoginInput = z.infer<typeof LoginInput>;
@@ -1 +1 @@
1
- {"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,qBAAqB,CAAC;AA2BxD,OAAO,EAAkB,KAAK,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAEtE,OAAO,EAA+B,KAAK,WAAW,EAAC,MAAM,oBAAoB,CAAC;AAElF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAQhD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAAI,UAAU,oBAAoB,KAAG,SAmFhF,CAAC;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;CACxC;AAED,4CAA4C;AAC5C,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAQ/C;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,kFAAkF;IAClF,eAAe,EAAE,WAAW,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE,4FAA4F;IAC5F,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,2FAA2F;IAC3F,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAID,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,wFAAwF;AACxF,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,gBAAgB,EACtB,SAAS,mBAAmB,KAC1B,KAAK,CAAC,SAAS,CA8PjB,CAAC"}
1
+ {"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,qBAAqB,CAAC;AA2BxD,OAAO,EAAkB,KAAK,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAEtE,OAAO,EAA+B,KAAK,WAAW,EAAC,MAAM,oBAAoB,CAAC;AAElF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAChD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,iCAAiC,CAAC;AAQtE,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAAI,UAAU,oBAAoB,KAAG,SAmFhF,CAAC;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;CACxC;AAED,4CAA4C;AAC5C,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAQ/C;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,kFAAkF;IAClF,eAAe,EAAE,WAAW,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE,4FAA4F;IAC5F,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,2FAA2F;IAC3F,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;CAC5C;AAID,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,wFAAwF;AACxF,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,gBAAgB,EACtB,SAAS,mBAAmB,KAC1B,KAAK,CAAC,SAAS,CA+SjB,CAAC"}
@@ -217,7 +217,7 @@ export const PasswordChangeOutput = z.strictObject({
217
217
  */
218
218
  export const create_account_route_specs = (deps, options) => {
219
219
  const { keyring, password } = deps;
220
- const { session_options, ip_rate_limiter, login_account_rate_limiter, max_sessions = DEFAULT_MAX_SESSIONS, login_fail_floor_ms = DEFAULT_LOGIN_FAIL_FLOOR_MS, login_fail_jitter_ms = DEFAULT_LOGIN_FAIL_JITTER_MS, } = options;
220
+ const { session_options, ip_rate_limiter, login_account_rate_limiter, max_sessions = DEFAULT_MAX_SESSIONS, login_fail_floor_ms = DEFAULT_LOGIN_FAIL_FLOOR_MS, login_fail_jitter_ms = DEFAULT_LOGIN_FAIL_JITTER_MS, connection_closer = null, } = options;
221
221
  return [
222
222
  {
223
223
  method: 'GET',
@@ -254,8 +254,12 @@ export const create_account_route_specs = (deps, options) => {
254
254
  return rate_limit_exceeded_response(c, check.retry_after);
255
255
  }
256
256
  }
257
- const { username: raw_username, password: pw } = get_route_input(c);
258
- const username = raw_username.trim().toLowerCase();
257
+ // `UsernameProvided` canonicalizes via `.trim().toLowerCase()` at
258
+ // parse time — the validated value lands canonical in
259
+ // `c.var.validated_input`, so the per-account rate-limit key,
260
+ // DB lookup, and audit metadata see one form. See
261
+ // `primitive_schemas.ts` for the schema-layer canonicalization.
262
+ const { username, password: pw } = get_route_input(c);
259
263
  // DB lookup first so we can key the per-account rate limit by a canonical value
260
264
  // (account.id) rather than the submitted identifier. Otherwise an attacker could
261
265
  // alternate between username and email to double the per-account bucket.
@@ -339,6 +343,21 @@ export const create_account_route_specs = (deps, options) => {
339
343
  if (session_token) {
340
344
  const token_hash = hash_session_token(session_token);
341
345
  await query_session_revoke_by_hash_unscoped(route, token_hash);
346
+ // Handler-side belt+suspenders: close the live WS bound to
347
+ // this session BEFORE the audit emit so revocation lands
348
+ // even if the audit INSERT fails. Same transaction-commit
349
+ // trade as `password` / RPC `session_revoke` below — a
350
+ // throw between this close and the response rolls back the
351
+ // DB revoke while leaving the socket severed; benign
352
+ // (client reconnects, session still valid) but don't
353
+ // introduce a throw here without acknowledging the trade.
354
+ // The audit listener (`create_ws_logout_closer`) runs an
355
+ // account-wide close on the logout event afterward —
356
+ // broader than this targeted close, but both layers are
357
+ // idempotent. Mirrors `zzz_server::account::logout_inner`.
358
+ if (connection_closer) {
359
+ connection_closer.close_sockets_for_session(token_hash);
360
+ }
342
361
  }
343
362
  clear_session_cookie(c, session_options);
344
363
  // Account-grain operation — no `actor_id` (which actor was
@@ -401,11 +420,10 @@ export const create_account_route_specs = (deps, options) => {
401
420
  });
402
421
  return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
403
422
  }
404
- // successful verificationreset rate limiters
405
- if (ip_rate_limiter && ip)
406
- ip_rate_limiter.reset(ip);
407
- if (login_account_rate_limiter)
408
- login_account_rate_limiter.reset(ctx.account.id);
423
+ // Verify succeededdo the throw-y operations FIRST so a fault
424
+ // (Argon2 OOM, native binding error, DB outage on the UPDATE)
425
+ // can't wipe the rate-limit history of a caller observing 500s.
426
+ // Resets happen below, after both calls have settled.
409
427
  const new_hash = await password.hash_password(new_password);
410
428
  // Conditional UPDATE keyed on the verified hash: closes the
411
429
  // verify-write race with a concurrent password change that
@@ -413,6 +431,19 @@ export const create_account_route_specs = (deps, options) => {
413
431
  // operation — `updated_by` stays null (the per-request actor is
414
432
  // incidental; password is account-level state).
415
433
  const updated = await query_update_account_password(route, ctx.account.id, new_hash, null, ctx.account.password_hash);
434
+ // Verify-success contract — the caller proved knowledge, so wipe
435
+ // their failure history. The race-loser branch below re-records
436
+ // one on top of the wiped slate so net cost stays 1 (mirrors the
437
+ // verify-fail branch above's `record`-from-prior+1 outcome when
438
+ // prior was 0; for prior > 0 the race-loser pays exactly 1,
439
+ // matching the OLD pre-S1 behavior). Deferring from "after
440
+ // verify" to "after UPDATE settled" is what closes the S1
441
+ // bypass — a throw between reset and the UPDATE could have
442
+ // wiped an attacker's budget.
443
+ if (ip_rate_limiter && ip)
444
+ ip_rate_limiter.reset(ip);
445
+ if (login_account_rate_limiter)
446
+ login_account_rate_limiter.reset(ctx.account.id);
416
447
  if (!updated) {
417
448
  // A concurrent password change committed first — our
418
449
  // `current_password` was correct at read-time but the row's
@@ -437,6 +468,22 @@ export const create_account_route_specs = (deps, options) => {
437
468
  // revoke all sessions and API tokens (force re-auth everywhere)
438
469
  const sessions_revoked = await query_session_revoke_all_for_account(route, ctx.account.id);
439
470
  const tokens_revoked = await query_revoke_all_api_tokens_for_account(route, ctx.account.id);
471
+ // Handler-side belt+suspenders — close every live WS socket on
472
+ // this account BEFORE the audit emit so the revoke-all cascade
473
+ // lands even if the audit INSERT fails. The real ordering
474
+ // invariant is "before the transaction commits": this route
475
+ // runs with the default `transaction: true`, so a throw between
476
+ // this close and the response would roll back the password
477
+ // update + session/token revokes while leaving sockets severed.
478
+ // Benign — affected clients reconnect with their still-valid
479
+ // session — but don't introduce a throw here without
480
+ // acknowledging the trade. Listener-based close
481
+ // (`transports_ws_auth_guard` on the `password_change` event)
482
+ // runs the same close afterward; idempotent on the second pass.
483
+ // Mirrors `zzz_server::account::password_inner`.
484
+ if (connection_closer) {
485
+ connection_closer.close_sockets_for_account(ctx.account.id);
486
+ }
440
487
  clear_session_cookie(c, session_options);
441
488
  // Account-grain operation — no `actor_id`. The password is
442
489
  // account-level state; which per-request actor was resolved
@@ -106,7 +106,7 @@ export interface ApiToken {
106
106
  /** Zod schema for `SessionAccount` — account without sensitive fields. */
107
107
  export declare const SessionAccountJson: z.ZodObject<{
108
108
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
109
- username: z.ZodString;
109
+ username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
110
110
  email: z.ZodNullable<z.ZodEmail>;
111
111
  email_verified: z.ZodBoolean;
112
112
  created_at: z.ZodString;
@@ -152,7 +152,7 @@ export type ActorSummaryJson = z.infer<typeof ActorSummaryJson>;
152
152
  /** Zod schema for admin-facing account data — extends `SessionAccountJson` with audit fields. */
153
153
  export declare const AdminAccountJson: z.ZodObject<{
154
154
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
155
- username: z.ZodString;
155
+ username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
156
156
  email: z.ZodNullable<z.ZodEmail>;
157
157
  email_verified: z.ZodBoolean;
158
158
  created_at: z.ZodString;
@@ -188,7 +188,7 @@ export type PendingOfferSummaryJson = z.infer<typeof PendingOfferSummaryJson>;
188
188
  export declare const AdminAccountEntryJson: z.ZodObject<{
189
189
  account: z.ZodObject<{
190
190
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
191
- username: z.ZodString;
191
+ username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
192
192
  email: z.ZodNullable<z.ZodEmail>;
193
193
  email_verified: z.ZodBoolean;
194
194
  created_at: z.ZodString;
@@ -35,7 +35,7 @@ export declare const AdminAccountListOutput: z.ZodObject<{
35
35
  accounts: z.ZodArray<z.ZodObject<{
36
36
  account: z.ZodObject<{
37
37
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
38
- username: z.ZodString;
38
+ username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
39
39
  email: z.ZodNullable<z.ZodEmail>;
40
40
  email_verified: z.ZodBoolean;
41
41
  created_at: z.ZodString;
@@ -183,7 +183,7 @@ export type AuditLogRoleGrantHistoryOutput = z.infer<typeof AuditLogRoleGrantHis
183
183
  /** Input for `invite_create`. At least one of `email` / `username` must be provided. */
184
184
  export declare const InviteCreateInput: z.ZodObject<{
185
185
  email: z.ZodOptional<z.ZodNullable<z.ZodEmail>>;
186
- username: z.ZodOptional<z.ZodNullable<z.ZodString>>;
186
+ username: z.ZodOptional<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>;
187
187
  acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
188
188
  }, z.core.$strict>;
189
189
  export type InviteCreateInput = z.infer<typeof InviteCreateInput>;
@@ -193,7 +193,7 @@ export declare const InviteCreateOutput: z.ZodObject<{
193
193
  invite: z.ZodObject<{
194
194
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
195
195
  email: z.ZodNullable<z.ZodEmail>;
196
- username: z.ZodNullable<z.ZodString>;
196
+ username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
197
197
  claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
198
198
  claimed_at: z.ZodNullable<z.ZodString>;
199
199
  created_at: z.ZodString;
@@ -211,7 +211,7 @@ export declare const InviteListOutput: z.ZodObject<{
211
211
  invites: z.ZodArray<z.ZodObject<{
212
212
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
213
213
  email: z.ZodNullable<z.ZodEmail>;
214
- username: z.ZodNullable<z.ZodString>;
214
+ username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
215
215
  claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
216
216
  claimed_at: z.ZodNullable<z.ZodString>;
217
217
  created_at: z.ZodString;
@@ -289,7 +289,7 @@ export declare const admin_account_list_action_spec: {
289
289
  accounts: z.ZodArray<z.ZodObject<{
290
290
  account: z.ZodObject<{
291
291
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
292
- username: z.ZodString;
292
+ username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
293
293
  email: z.ZodNullable<z.ZodEmail>;
294
294
  email_verified: z.ZodBoolean;
295
295
  created_at: z.ZodString;
@@ -512,7 +512,7 @@ export declare const invite_create_action_spec: {
512
512
  side_effects: true;
513
513
  input: z.ZodObject<{
514
514
  email: z.ZodOptional<z.ZodNullable<z.ZodEmail>>;
515
- username: z.ZodOptional<z.ZodNullable<z.ZodString>>;
515
+ username: z.ZodOptional<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>;
516
516
  acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
517
517
  }, z.core.$strict>;
518
518
  output: z.ZodObject<{
@@ -520,7 +520,7 @@ export declare const invite_create_action_spec: {
520
520
  invite: z.ZodObject<{
521
521
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
522
522
  email: z.ZodNullable<z.ZodEmail>;
523
- username: z.ZodNullable<z.ZodString>;
523
+ username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
524
524
  claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
525
525
  claimed_at: z.ZodNullable<z.ZodString>;
526
526
  created_at: z.ZodString;
@@ -554,7 +554,7 @@ export declare const invite_list_action_spec: {
554
554
  invites: z.ZodArray<z.ZodObject<{
555
555
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
556
556
  email: z.ZodNullable<z.ZodEmail>;
557
- username: z.ZodNullable<z.ZodString>;
557
+ username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
558
558
  claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
559
559
  claimed_at: z.ZodNullable<z.ZodString>;
560
560
  created_at: z.ZodString;
@@ -28,6 +28,7 @@
28
28
  * @module
29
29
  */
30
30
  import { type RpcAction } from '../actions/action_rpc.js';
31
+ import type { ConnectionCloser } from '../actions/connection_closer.js';
31
32
  import { type RoleSchemaResult } from './role_schema.js';
32
33
  import { type AppSettings } from './app_settings_schema.js';
33
34
  import type { RouteFactoryDeps } from './deps.js';
@@ -49,6 +50,16 @@ export interface AdminActionOptions {
49
50
  * handler and RPC dispatch returns `method_not_found`.
50
51
  */
51
52
  app_settings?: AppSettings;
53
+ /**
54
+ * Live-connection closer — when set, `admin_session_revoke_all` and
55
+ * `admin_token_revoke_all` handlers eagerly close affected WebSocket
56
+ * sockets for the target account BEFORE emitting the corresponding
57
+ * audit event. Mirrors the self-service surface (see
58
+ * `AccountActionOptions.connection_closer`). `BackendWebsocketTransport`
59
+ * satisfies this interface structurally. When absent, only the
60
+ * listener-based close (`transports_ws_auth_guard`) runs.
61
+ */
62
+ connection_closer?: ConnectionCloser | null;
52
63
  }
53
64
  /**
54
65
  * Create the admin-only RPC actions.
@@ -1 +1 @@
1
- {"version":3,"file":"admin_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/admin_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAsC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAE7F,OAAO,EAGN,KAAK,gBAAgB,EACrB,MAAM,kBAAkB,CAAC;AAuB1B,OAAO,EAAC,KAAK,WAAW,EAAC,MAAM,0BAA0B,CAAC;AAK1D,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AA6ChD,0CAA0C;AAC1C,MAAM,WAAW,kBAAkB;IAClC;;;;;OAKG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,IAAI,CAAC,gBAAgB,EAAE,KAAK,GAAG,OAAO,CAAC,EAC7C,UAAS,kBAAuB,KAC9B,KAAK,CAAC,SAAS,CA0PjB,CAAC"}
1
+ {"version":3,"file":"admin_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/admin_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAsC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC7F,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,iCAAiC,CAAC;AAEtE,OAAO,EAGN,KAAK,gBAAgB,EACrB,MAAM,kBAAkB,CAAC;AAuB1B,OAAO,EAAC,KAAK,WAAW,EAAC,MAAM,0BAA0B,CAAC;AAK1D,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AA6ChD,0CAA0C;AAC1C,MAAM,WAAW,kBAAkB;IAClC;;;;;OAKG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,WAAW,CAAC;IAC3B;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;CAC5C;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,IAAI,CAAC,gBAAgB,EAAE,KAAK,GAAG,OAAO,CAAC,EAC7C,UAAS,kBAAuB,KAC9B,KAAK,CAAC,SAAS,CAmRjB,CAAC"}
@@ -56,6 +56,7 @@ import { admin_account_list_action_spec, admin_session_list_action_spec, admin_s
56
56
  export const create_admin_actions = (deps, options = {}) => {
57
57
  const role_specs = options.roles?.role_specs ?? builtin_role_specs_by_name;
58
58
  const grantable_roles = list_roles_with_grant_path(role_specs, GRANT_PATH_ADMIN);
59
+ const connection_closer = options.connection_closer ?? null;
59
60
  const account_list_handler = async (input, ctx) => {
60
61
  const accounts = await query_admin_account_list(ctx, {
61
62
  limit: input.limit,
@@ -88,6 +89,23 @@ export const create_admin_actions = (deps, options = {}) => {
88
89
  throw jsonrpc_errors.not_found('account', { reason: ERROR_ACCOUNT_NOT_FOUND });
89
90
  }
90
91
  const count = await query_session_revoke_all_for_account(ctx, input.account_id);
92
+ // Handler-side belt+suspenders — close the target account's live WS
93
+ // sockets BEFORE the audit emit so revocation lands even if the audit
94
+ // INSERT fails. Listener-based close (`transports_ws_auth_guard` on
95
+ // `audit.on_event_chain`) stays as a fail-safe for out-of-band emit
96
+ // sites. Idempotent — see `account_actions.ts::session_revoke_handler`.
97
+ if (connection_closer) {
98
+ connection_closer.close_sockets_for_account(input.account_id);
99
+ }
100
+ // TOCTOU window — admin B hard-deletes `input.account_id` between the
101
+ // pre-check above and this emit; the FK rejects the row, the audit
102
+ // emitter logs + swallows, and the operation goes unaudited. Bounded
103
+ // by the audit emitter's failure logging (operator-visible) and by
104
+ // the rarity of concurrent admin hard-deletes. Not switching to the
105
+ // failure-shape (`target_account_id: null + metadata.attempted_account_id`)
106
+ // because the FK linkage powers the username-join in
107
+ // `audit_log_list_with_usernames`; losing it on every success row
108
+ // to harden a corner case isn't worth the query-shape change.
91
109
  deps.audit.emit(ctx, {
92
110
  event_type: 'session_revoke_all',
93
111
  account_id: auth.account.id,
@@ -117,6 +135,13 @@ export const create_admin_actions = (deps, options = {}) => {
117
135
  throw jsonrpc_errors.not_found('account', { reason: ERROR_ACCOUNT_NOT_FOUND });
118
136
  }
119
137
  const count = await query_revoke_all_api_tokens_for_account(ctx, input.account_id);
138
+ // Handler-side belt+suspenders — see `session_revoke_all_handler`.
139
+ if (connection_closer) {
140
+ connection_closer.close_sockets_for_account(input.account_id);
141
+ }
142
+ // TOCTOU window — see `session_revoke_all_handler` for the rationale on
143
+ // keeping `target_account_id` populated rather than switching to the
144
+ // failure-shape.
120
145
  deps.audit.emit(ctx, {
121
146
  event_type: 'token_revoke_all',
122
147
  account_id: auth.account.id,