@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.
- package/dist/actions/CLAUDE.md +124 -11
- package/dist/actions/connection_closer.d.ts +68 -0
- package/dist/actions/connection_closer.d.ts.map +1 -0
- package/dist/actions/connection_closer.js +41 -0
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +23 -2
- package/dist/actions/register_ws_endpoint.d.ts +11 -9
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +5 -5
- package/dist/actions/transports_ws_auth_guard.d.ts +24 -8
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/actions/transports_ws_auth_guard.js +23 -7
- package/dist/actions/ws_endpoint_spec.d.ts +119 -0
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -0
- package/dist/actions/ws_endpoint_spec.js +13 -0
- package/dist/auth/CLAUDE.md +79 -15
- package/dist/auth/account_action_specs.d.ts +1 -1
- package/dist/auth/account_actions.d.ts +13 -0
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +31 -1
- package/dist/auth/account_routes.d.ts +12 -2
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +55 -8
- package/dist/auth/account_schema.d.ts +3 -3
- package/dist/auth/admin_action_specs.d.ts +8 -8
- package/dist/auth/admin_actions.d.ts +11 -0
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +25 -0
- package/dist/auth/audit_emitter.d.ts +56 -12
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +38 -12
- package/dist/auth/audit_log_schema.d.ts +5 -3
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +5 -3
- package/dist/auth/bootstrap_routes.d.ts +1 -1
- package/dist/auth/invite_schema.d.ts +2 -2
- package/dist/auth/signup_routes.d.ts +1 -1
- package/dist/auth/standard_rpc_actions.d.ts +1 -0
- package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
- package/dist/auth/standard_rpc_actions.js +1 -0
- package/dist/http/CLAUDE.md +26 -10
- package/dist/http/ip_canonical.d.ts +99 -0
- package/dist/http/ip_canonical.d.ts.map +1 -0
- package/dist/http/ip_canonical.js +191 -0
- package/dist/http/origin.d.ts +13 -5
- package/dist/http/origin.d.ts.map +1 -1
- package/dist/http/origin.js +13 -31
- package/dist/http/pending_effects.d.ts +1 -1
- package/dist/http/pending_effects.js +1 -1
- package/dist/http/proxy.d.ts +13 -5
- package/dist/http/proxy.d.ts.map +1 -1
- package/dist/http/proxy.js +15 -23
- package/dist/http/surface.d.ts +50 -0
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +27 -1
- package/dist/primitive_schemas.d.ts +20 -4
- package/dist/primitive_schemas.d.ts.map +1 -1
- package/dist/primitive_schemas.js +25 -4
- package/dist/realtime/sse_auth_guard.d.ts +16 -4
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +15 -3
- package/dist/server/app_backend.d.ts +66 -19
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +57 -34
- package/dist/server/app_server.d.ts +60 -0
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +95 -2
- package/dist/server/startup.d.ts.map +1 -1
- package/dist/server/startup.js +12 -0
- package/dist/testing/CLAUDE.md +64 -28
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +4 -5
- package/dist/testing/adversarial_headers.d.ts +6 -0
- package/dist/testing/adversarial_headers.d.ts.map +1 -1
- package/dist/testing/adversarial_headers.js +13 -5
- package/dist/testing/app_server.d.ts +33 -32
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +4 -13
- package/dist/testing/attack_surface.d.ts +8 -7
- package/dist/testing/attack_surface.d.ts.map +1 -1
- package/dist/testing/attack_surface.js +12 -8
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +3 -5
- package/dist/testing/audit_drift_guard.d.ts +116 -0
- package/dist/testing/audit_drift_guard.d.ts.map +1 -0
- package/dist/testing/audit_drift_guard.js +134 -0
- package/dist/testing/connection_closer_helpers.d.ts +44 -0
- package/dist/testing/connection_closer_helpers.d.ts.map +1 -0
- package/dist/testing/connection_closer_helpers.js +48 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +7 -9
- package/dist/testing/rate_limiting.js +4 -4
- package/dist/testing/rpc_helpers.d.ts +2 -1
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.js +6 -8
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +12 -6
- package/dist/testing/stubs.d.ts +11 -0
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +4 -0
- package/dist/testing/surface_invariants.d.ts +66 -1
- package/dist/testing/surface_invariants.d.ts.map +1 -1
- package/dist/testing/surface_invariants.js +103 -1
- package/dist/ui/SurfaceExplorer.svelte +161 -2
- package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
- 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 {};
|
package/dist/auth/CLAUDE.md
CHANGED
|
@@ -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
|
|
379
|
-
—
|
|
380
|
-
|
|
381
|
-
`
|
|
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 `
|
|
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
|
|
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
|
|
1605
|
-
from `account_routes.ts`;
|
|
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
|
|
1814
|
-
|
|
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`
|
|
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.
|
|
1819
|
-
|
|
1820
|
-
|
|
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;
|
|
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;
|
|
1
|
+
{"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|
-
|
|
258
|
-
|
|
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
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
login_account_rate_limiter.reset(ctx.account.id);
|
|
423
|
+
// Verify succeeded — do 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;
|
|
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,
|