@fuzdev/fuz_app 0.106.1 → 0.108.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 +42 -23
- package/dist/actions/action_bridge.d.ts +34 -3
- package/dist/actions/action_bridge.d.ts.map +1 -1
- package/dist/actions/action_bridge.js +32 -1
- package/dist/actions/action_rpc.d.ts +5 -4
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +4 -3
- package/dist/actions/action_spec.d.ts +8 -2
- package/dist/actions/action_spec.d.ts.map +1 -1
- package/dist/actions/action_spec.js +5 -2
- package/dist/actions/compile_action_registry.d.ts +26 -14
- package/dist/actions/compile_action_registry.d.ts.map +1 -1
- package/dist/actions/compile_action_registry.js +29 -14
- package/dist/actions/perform_action.d.ts +26 -16
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +69 -44
- package/dist/actions/register_action_ws.d.ts +4 -4
- package/dist/actions/register_action_ws.js +5 -5
- package/dist/actions/register_ws_endpoint.d.ts +13 -5
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +15 -6
- package/dist/auth/CLAUDE.md +16 -9
- package/dist/auth/account_route_schema.d.ts +10 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +10 -1
- package/dist/auth/account_routes.d.ts +18 -4
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +32 -29
- package/dist/auth/api_token_queries.d.ts +4 -0
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +5 -1
- package/dist/auth/audit_log_route_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_route_schema.js +11 -1
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +4 -9
- package/dist/auth/auth_guard_resolver.d.ts +48 -21
- package/dist/auth/auth_guard_resolver.d.ts.map +1 -1
- package/dist/auth/auth_guard_resolver.js +60 -26
- package/dist/auth/bearer_auth.d.ts +10 -7
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +13 -22
- package/dist/auth/bootstrap_routes.d.ts +12 -2
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +15 -9
- package/dist/auth/middleware.d.ts +0 -3
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +11 -10
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +24 -0
- package/dist/auth/request_context.d.ts +54 -24
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +117 -39
- package/dist/auth/session_queries.d.ts +13 -2
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +14 -3
- package/dist/auth/signup_routes.d.ts +10 -0
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +14 -11
- package/dist/auth/token_scope.d.ts +109 -21
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +106 -21
- package/dist/http/CLAUDE.md +23 -11
- package/dist/http/auth_shape.d.ts +58 -3
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +63 -5
- package/dist/http/error_schemas.d.ts +25 -2
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +37 -15
- package/dist/http/route_spec.d.ts +29 -26
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +12 -16
- package/dist/http/surface_query.d.ts +2 -2
- package/dist/http/surface_query.js +2 -2
- package/dist/rate_limiter.d.ts +36 -3
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +36 -3
- package/dist/server/app_server.d.ts +27 -10
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +30 -20
- package/dist/server/app_server_context.d.ts +10 -2
- package/dist/server/app_server_context.d.ts.map +1 -1
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +12 -10
- package/dist/testing/CLAUDE.md +60 -11
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +6 -4
- package/dist/testing/cross_backend/conformance_case.js +1 -1
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +9 -8
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +59 -0
- package/dist/testing/cross_backend/session_cap.d.ts +19 -0
- package/dist/testing/cross_backend/session_cap.d.ts.map +1 -0
- package/dist/testing/cross_backend/session_cap.js +93 -0
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +6 -5
- package/dist/testing/cross_backend/token_scope_surface.d.ts +9 -0
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -0
- package/dist/testing/cross_backend/token_scope_surface.js +170 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +3 -2
- package/dist/testing/middleware.d.ts +2 -5
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +5 -5
- package/dist/testing/rate_limiting.d.ts +10 -16
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +11 -95
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.js +4 -2
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +3 -1
- package/dist/testing/testing_rate_limiter.d.ts +7 -1
- package/dist/testing/testing_rate_limiter.d.ts.map +1 -1
- package/dist/testing/testing_rate_limiter.js +7 -1
- package/package.json +1 -1
package/dist/auth/migrations.js
CHANGED
|
@@ -186,6 +186,30 @@ export const auth_migrations = [
|
|
|
186
186
|
WHERE scope IS NULL`);
|
|
187
187
|
await db.query('ALTER TABLE api_token ALTER COLUMN scope SET NOT NULL');
|
|
188
188
|
}
|
|
189
|
+
},
|
|
190
|
+
// Index the column every bearer request looks up.
|
|
191
|
+
//
|
|
192
|
+
// `full_auth_schema` indexes `api_token(account_id)` and nothing else, so
|
|
193
|
+
// `query_validate_api_token`'s `WHERE token_hash = $1` — the lookup on the
|
|
194
|
+
// hot path of *every* bearer-authenticated request, valid or not — has been
|
|
195
|
+
// a sequential scan since the table shipped. The asymmetry is accidental:
|
|
196
|
+
// `auth_session` gets the same access pattern for free because its session
|
|
197
|
+
// hash *is* the primary key.
|
|
198
|
+
//
|
|
199
|
+
// UNIQUE rather than a plain index, because both spines resolve a token with
|
|
200
|
+
// an at-most-one read (`query_one` / `query_opt`). Two rows sharing a hash
|
|
201
|
+
// would mean the two impls could silently disagree about which credential
|
|
202
|
+
// answered; the constraint makes that unrepresentable instead of merely
|
|
203
|
+
// improbable.
|
|
204
|
+
//
|
|
205
|
+
// The name must match the Rust twin's `api_token_hash_unique_index` byte for
|
|
206
|
+
// byte or the `_testing_migration_tracker` parity gate fails and the
|
|
207
|
+
// swap-freely invariant breaks.
|
|
208
|
+
{
|
|
209
|
+
name: 'api_token_hash_unique_index',
|
|
210
|
+
up: async (db) => {
|
|
211
|
+
await db.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_api_token_hash ON api_token(token_hash)');
|
|
212
|
+
}
|
|
189
213
|
}
|
|
190
214
|
];
|
|
191
215
|
/** Pre-composed migration namespace for auth tables. */
|
|
@@ -38,7 +38,6 @@ import type { Context, MiddlewareHandler } from 'hono';
|
|
|
38
38
|
import type { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
39
39
|
import { type Account, type Actor, type RoleGrant } from './account_schema.js';
|
|
40
40
|
import type { QueryDeps } from '../db/query_deps.js';
|
|
41
|
-
import { type TokenSurface } from './token_scope.js';
|
|
42
41
|
import type { RouteSpec } from '../http/route_spec.js';
|
|
43
42
|
import { type RouteAuth } from '../http/auth_shape.js';
|
|
44
43
|
import { ERROR_ACTOR_REQUIRED, ERROR_ACTOR_NOT_ON_ACCOUNT, ERROR_NO_ACTORS_ON_ACCOUNT, ERROR_ACCOUNT_VANISHED } from '../http/error_schemas.js';
|
|
@@ -231,22 +230,48 @@ export declare const resolve_acting_actor: (deps: QueryDeps, account_id: string,
|
|
|
231
230
|
*/
|
|
232
231
|
export declare const create_request_context_middleware: (deps: QueryDeps, log: Logger, session_context_key?: string) => MiddlewareHandler;
|
|
233
232
|
/**
|
|
234
|
-
* Refuse a non-RPC
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* `upgradeWebSocket`'s callback
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
233
|
+
* Refuse a non-RPC surface to a narrowed token — rule 3, for callers that are
|
|
234
|
+
* not route specs. Under the Rust twin's name
|
|
235
|
+
* (`token_scope_surface_denied_response`).
|
|
236
|
+
*
|
|
237
|
+
* The WS upgrade is the spine's one such caller: `upgradeWebSocket`'s callback
|
|
238
|
+
* must return `WSEvents` and so cannot produce a denial, which is why the gate
|
|
239
|
+
* runs as pre-upgrade middleware calling this rather than as a route-spec
|
|
240
|
+
* declaration. A consumer hand-rolling its own upgrade is in the same position
|
|
241
|
+
* and reaches for the same function.
|
|
242
|
+
*
|
|
243
|
+
* Takes the whole capability string, not a bare surface name — one spelling
|
|
244
|
+
* across route-spec declarations and direct calls alike, and the twin of the
|
|
245
|
+
* Rust signature. It needs no parse: the surface arm's identifier is pure label,
|
|
246
|
+
* since rule 3 never asks *which* surface. And it is deliberately not narrowed
|
|
247
|
+
* to the spine's four — the name decides nothing, so there is nothing for a
|
|
248
|
+
* closed set to protect, while a consumer surface would have nothing to pass.
|
|
249
|
+
* fuz_app's own uses are pinned two ways by the surface census: the exact call
|
|
250
|
+
* site, and the scan asserting every `'surface:<name>'` literal in `src/lib`
|
|
251
|
+
* names a surface the spine actually mounts.
|
|
244
252
|
*
|
|
245
253
|
* @param c - the request context, after the auth middleware chain
|
|
246
|
-
* @param
|
|
254
|
+
* @param capability - the `surface:<name>` this route demands; names itself in the denial
|
|
247
255
|
* @returns the 403 to return, or `null` when the caller may proceed
|
|
248
256
|
*/
|
|
249
|
-
export declare const token_scope_surface_denial: (c: Context,
|
|
257
|
+
export declare const token_scope_surface_denial: (c: Context, capability: string) => Response | null;
|
|
258
|
+
/**
|
|
259
|
+
* Create middleware refusing a route to a credential whose scope does not admit
|
|
260
|
+
* `capability`.
|
|
261
|
+
*
|
|
262
|
+
* The route-spec form, mounted by `fuz_auth_guard_resolver` from
|
|
263
|
+
* `auth.required_scope`. Lands in the `pre_authorization` phase, so a narrowed
|
|
264
|
+
* token is told about its scope rather than about the role it also lacks — and
|
|
265
|
+
* learns nothing about the route's input shape on the way.
|
|
266
|
+
*
|
|
267
|
+
* Parses at construction, so a malformed capability is a registration-time
|
|
268
|
+
* throw rather than a guard that mounts and reports nonsense.
|
|
269
|
+
*
|
|
270
|
+
* @param capability - the capability string this route demands, e.g. `surface:audit_stream`
|
|
271
|
+
* @returns middleware that 403s a credential whose scope lacks it
|
|
272
|
+
* @throws Error if `capability` is not a well-formed `rpc:<method>` / `surface:<name>` string
|
|
273
|
+
*/
|
|
274
|
+
export declare const require_token_scope: (capability: string) => MiddlewareHandler;
|
|
250
275
|
/**
|
|
251
276
|
* Middleware that requires authentication.
|
|
252
277
|
*
|
|
@@ -284,6 +309,12 @@ export declare const require_role: (roles: ReadonlyArray<string>) => MiddlewareH
|
|
|
284
309
|
* Returns 401 if unauthenticated, 403 with
|
|
285
310
|
* `ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types` echoing
|
|
286
311
|
* the spec's allowlist when the wire-side credential isn't in it.
|
|
312
|
+
*
|
|
313
|
+
* Reads only `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY`, both set by the auth
|
|
314
|
+
* middleware — never the resolved `RequestContext`. That is why
|
|
315
|
+
* `fuz_auth_guard_resolver` mounts it in the `pre_authorization` phase: a
|
|
316
|
+
* wrong channel is refused before the route resolves an actor for it, so the
|
|
317
|
+
* denial costs no DB work and discloses no account state.
|
|
287
318
|
* Body shape is symmetric with the role gate (`ERROR_INSUFFICIENT_PERMISSIONS` +
|
|
288
319
|
* `required_roles`) and matches what the RPC dispatcher's post-auth
|
|
289
320
|
* gate emits for the same condition. Today's only credential gate is
|
|
@@ -403,8 +434,8 @@ export type AuthorizationResult = {
|
|
|
403
434
|
* Apply the dispatcher's authorization phase against the flat-record
|
|
404
435
|
* `RouteAuth` shape. Shared by the route-spec wrapper, the HTTP RPC
|
|
405
436
|
* dispatcher, and the per-message WS dispatcher. Phase order:
|
|
406
|
-
* pre-
|
|
407
|
-
*
|
|
437
|
+
* pre-authorization 401 → authorization phase → post-authorization 403 →
|
|
438
|
+
* input validation 400.
|
|
408
439
|
*
|
|
409
440
|
* Pure data — the function does not touch a Hono context. Each transport
|
|
410
441
|
* passes `account_id` (extracted from its own credential surface) and
|
|
@@ -419,7 +450,7 @@ export type AuthorizationResult = {
|
|
|
419
450
|
* never see a `RequestContext`.
|
|
420
451
|
* - `account_id == null` on any non-public route → same null
|
|
421
452
|
* `request_context`. The `'required'` callers were already rejected at
|
|
422
|
-
* the pre-
|
|
453
|
+
* the pre-authorization gate in the dispatcher; only genuine anonymous
|
|
423
454
|
* access on an `'optional'` axis lands here.
|
|
424
455
|
* - `actor === 'none'` → builds account-only context via
|
|
425
456
|
* `build_account_context`. Null lookup → `account_vanished` 500 failure.
|
|
@@ -437,16 +468,15 @@ export declare const apply_authorization_phase: (deps: QueryDeps, account_id: st
|
|
|
437
468
|
/**
|
|
438
469
|
* Create the route-spec authorization handler used by `apply_route_specs`.
|
|
439
470
|
*
|
|
440
|
-
* Reads `acting`
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* skip the phase entirely.
|
|
471
|
+
* Reads the `acting` selector via `read_route_acting` — `c.var.validated_query`
|
|
472
|
+
* on GETs, the raw body on mutations, since input validation now runs after
|
|
473
|
+
* the authority gates. Public routes (`auth.account === 'none' &&
|
|
474
|
+
* auth.actor === 'none'`) skip the phase entirely.
|
|
445
475
|
*
|
|
446
476
|
* Per registry-time invariant 2, `auth.actor !== 'none'` ⟺ the input
|
|
447
|
-
* (or query) schema declares `acting?: ActingActor` — so
|
|
448
|
-
*
|
|
449
|
-
*
|
|
477
|
+
* (or query) schema declares `acting?: ActingActor` — so the selector is
|
|
478
|
+
* present on exactly the specs that read it, and input validation is what
|
|
479
|
+
* rejects a malformed one.
|
|
450
480
|
*
|
|
451
481
|
* Resolved contexts land on `REQUEST_CONTEXT_KEY` so the post-authorization
|
|
452
482
|
* REST middleware (`require_role`, `require_credential_types`) reads the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AACvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,EACN,KAAK,OAAO,EACZ,KAAK,KAAK,EAEV,KAAK,SAAS,EACd,MAAM,qBAAqB,CAAC;AAY7B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"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"}
|
|
@@ -39,8 +39,9 @@ import { hash_session_token, session_touch_fire_and_forget, query_session_get_va
|
|
|
39
39
|
import { query_account_by_id, query_actor_by_id, query_active_actors_by_account } from './account_queries.js';
|
|
40
40
|
import { query_role_grant_find_active_for_actor } from './role_grant_queries.js';
|
|
41
41
|
import { ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY, TOKEN_SCOPE_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
|
|
42
|
-
import {
|
|
43
|
-
import { is_public_auth, needs_actor } from '../http/auth_shape.js';
|
|
42
|
+
import { parse_token_scope_capability, token_scope_admits_capability, token_scope_denied_body, token_scope_full } from './token_scope.js';
|
|
43
|
+
import { is_public_auth, needs_actor, parse_acting } from '../http/auth_shape.js';
|
|
44
|
+
import { is_null_schema } from '../http/schema_helpers.js';
|
|
44
45
|
import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, ERROR_CREDENTIAL_TYPE_REQUIRED, ERROR_ACTOR_REQUIRED, ERROR_ACTOR_NOT_ON_ACCOUNT, ERROR_NO_ACTORS_ON_ACCOUNT, ERROR_ACCOUNT_VANISHED } from '../http/error_schemas.js';
|
|
45
46
|
/** Hono context variable name for the request context. */
|
|
46
47
|
export const REQUEST_CONTEXT_KEY = 'request_context';
|
|
@@ -240,27 +241,78 @@ export const create_request_context_middleware = (deps, log, session_context_key
|
|
|
240
241
|
};
|
|
241
242
|
};
|
|
242
243
|
/**
|
|
243
|
-
* Refuse a
|
|
244
|
+
* Refuse a capability the calling credential's scope does not admit.
|
|
244
245
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* `
|
|
246
|
+
* The decision every token-scope gate outside the action dispatcher shares.
|
|
247
|
+
* Private because the two exported forms below are the whole surface: a route
|
|
248
|
+
* spec declares `auth.required_scope` and gets `require_token_scope`; anything
|
|
249
|
+
* that is not a route spec calls `token_scope_surface_denial`.
|
|
248
250
|
*
|
|
249
251
|
* An unset `TOKEN_SCOPE_KEY` is the anonymous caller, who holds no credential to
|
|
250
252
|
* narrow, so it passes. Sharing that branch is the point — hand-rolled per
|
|
251
253
|
* surface it is one `&&` from either refusing every anonymous read or admitting
|
|
252
254
|
* an authenticated caller whose middleware forgot to set the key.
|
|
255
|
+
*/
|
|
256
|
+
const token_scope_denial = (c, capability) => {
|
|
257
|
+
const scope = c.get(TOKEN_SCOPE_KEY);
|
|
258
|
+
if (scope && !token_scope_admits_capability(scope, capability)) {
|
|
259
|
+
return c.json(token_scope_denied_body(capability.capability), 403);
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Refuse a non-RPC surface to a narrowed token — rule 3, for callers that are
|
|
265
|
+
* not route specs. Under the Rust twin's name
|
|
266
|
+
* (`token_scope_surface_denied_response`).
|
|
267
|
+
*
|
|
268
|
+
* The WS upgrade is the spine's one such caller: `upgradeWebSocket`'s callback
|
|
269
|
+
* must return `WSEvents` and so cannot produce a denial, which is why the gate
|
|
270
|
+
* runs as pre-upgrade middleware calling this rather than as a route-spec
|
|
271
|
+
* declaration. A consumer hand-rolling its own upgrade is in the same position
|
|
272
|
+
* and reaches for the same function.
|
|
273
|
+
*
|
|
274
|
+
* Takes the whole capability string, not a bare surface name — one spelling
|
|
275
|
+
* across route-spec declarations and direct calls alike, and the twin of the
|
|
276
|
+
* Rust signature. It needs no parse: the surface arm's identifier is pure label,
|
|
277
|
+
* since rule 3 never asks *which* surface. And it is deliberately not narrowed
|
|
278
|
+
* to the spine's four — the name decides nothing, so there is nothing for a
|
|
279
|
+
* closed set to protect, while a consumer surface would have nothing to pass.
|
|
280
|
+
* fuz_app's own uses are pinned two ways by the surface census: the exact call
|
|
281
|
+
* site, and the scan asserting every `'surface:<name>'` literal in `src/lib`
|
|
282
|
+
* names a surface the spine actually mounts.
|
|
253
283
|
*
|
|
254
284
|
* @param c - the request context, after the auth middleware chain
|
|
255
|
-
* @param
|
|
285
|
+
* @param capability - the `surface:<name>` this route demands; names itself in the denial
|
|
256
286
|
* @returns the 403 to return, or `null` when the caller may proceed
|
|
257
287
|
*/
|
|
258
|
-
export const token_scope_surface_denial = (c,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
288
|
+
export const token_scope_surface_denial = (c, capability) => token_scope_denial(c, { kind: 'surface', capability });
|
|
289
|
+
/**
|
|
290
|
+
* Create middleware refusing a route to a credential whose scope does not admit
|
|
291
|
+
* `capability`.
|
|
292
|
+
*
|
|
293
|
+
* The route-spec form, mounted by `fuz_auth_guard_resolver` from
|
|
294
|
+
* `auth.required_scope`. Lands in the `pre_authorization` phase, so a narrowed
|
|
295
|
+
* token is told about its scope rather than about the role it also lacks — and
|
|
296
|
+
* learns nothing about the route's input shape on the way.
|
|
297
|
+
*
|
|
298
|
+
* Parses at construction, so a malformed capability is a registration-time
|
|
299
|
+
* throw rather than a guard that mounts and reports nonsense.
|
|
300
|
+
*
|
|
301
|
+
* @param capability - the capability string this route demands, e.g. `surface:audit_stream`
|
|
302
|
+
* @returns middleware that 403s a credential whose scope lacks it
|
|
303
|
+
* @throws Error if `capability` is not a well-formed `rpc:<method>` / `surface:<name>` string
|
|
304
|
+
*/
|
|
305
|
+
export const require_token_scope = (capability) => {
|
|
306
|
+
const parsed = parse_token_scope_capability(capability);
|
|
307
|
+
if (parsed === null) {
|
|
308
|
+
throw new Error(`auth.required_scope "${capability}" is not a valid token-scope capability \u2014 expected 'rpc:<method>' or 'surface:<name>', where a surface name is lowercase letters and underscores`);
|
|
262
309
|
}
|
|
263
|
-
return
|
|
310
|
+
return async (c, next) => {
|
|
311
|
+
const denied = token_scope_denial(c, parsed);
|
|
312
|
+
if (denied)
|
|
313
|
+
return denied;
|
|
314
|
+
await next();
|
|
315
|
+
};
|
|
264
316
|
};
|
|
265
317
|
/**
|
|
266
318
|
* Middleware that requires authentication.
|
|
@@ -315,6 +367,12 @@ export const require_role = (roles) => {
|
|
|
315
367
|
* Returns 401 if unauthenticated, 403 with
|
|
316
368
|
* `ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types` echoing
|
|
317
369
|
* the spec's allowlist when the wire-side credential isn't in it.
|
|
370
|
+
*
|
|
371
|
+
* Reads only `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY`, both set by the auth
|
|
372
|
+
* middleware — never the resolved `RequestContext`. That is why
|
|
373
|
+
* `fuz_auth_guard_resolver` mounts it in the `pre_authorization` phase: a
|
|
374
|
+
* wrong channel is refused before the route resolves an actor for it, so the
|
|
375
|
+
* denial costs no DB work and discloses no account state.
|
|
318
376
|
* Body shape is symmetric with the role gate (`ERROR_INSUFFICIENT_PERMISSIONS` +
|
|
319
377
|
* `required_roles`) and matches what the RPC dispatcher's post-auth
|
|
320
378
|
* gate emits for the same condition. Today's only credential gate is
|
|
@@ -421,8 +479,8 @@ export const build_account_context = async (deps, account_id) => {
|
|
|
421
479
|
* Apply the dispatcher's authorization phase against the flat-record
|
|
422
480
|
* `RouteAuth` shape. Shared by the route-spec wrapper, the HTTP RPC
|
|
423
481
|
* dispatcher, and the per-message WS dispatcher. Phase order:
|
|
424
|
-
* pre-
|
|
425
|
-
*
|
|
482
|
+
* pre-authorization 401 → authorization phase → post-authorization 403 →
|
|
483
|
+
* input validation 400.
|
|
426
484
|
*
|
|
427
485
|
* Pure data — the function does not touch a Hono context. Each transport
|
|
428
486
|
* passes `account_id` (extracted from its own credential surface) and
|
|
@@ -437,7 +495,7 @@ export const build_account_context = async (deps, account_id) => {
|
|
|
437
495
|
* never see a `RequestContext`.
|
|
438
496
|
* - `account_id == null` on any non-public route → same null
|
|
439
497
|
* `request_context`. The `'required'` callers were already rejected at
|
|
440
|
-
* the pre-
|
|
498
|
+
* the pre-authorization gate in the dispatcher; only genuine anonymous
|
|
441
499
|
* access on an `'optional'` axis lands here.
|
|
442
500
|
* - `actor === 'none'` → builds account-only context via
|
|
443
501
|
* `build_account_context`. Null lookup → `account_vanished` 500 failure.
|
|
@@ -457,7 +515,7 @@ export const apply_authorization_phase = async (deps, account_id, auth, acting_v
|
|
|
457
515
|
if (account_id == null) {
|
|
458
516
|
// Optional-auth route hit without a credential — leave `RequestContext`
|
|
459
517
|
// null so the handler can branch on it. `'required'` callers already
|
|
460
|
-
// got rejected at the pre-
|
|
518
|
+
// got rejected at the pre-authorization gate.
|
|
461
519
|
return { ok: true, request_context: null };
|
|
462
520
|
}
|
|
463
521
|
if (!needs_actor(auth)) {
|
|
@@ -496,16 +554,15 @@ export const apply_authorization_phase = async (deps, account_id, auth, acting_v
|
|
|
496
554
|
/**
|
|
497
555
|
* Create the route-spec authorization handler used by `apply_route_specs`.
|
|
498
556
|
*
|
|
499
|
-
* Reads `acting`
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
* skip the phase entirely.
|
|
557
|
+
* Reads the `acting` selector via `read_route_acting` — `c.var.validated_query`
|
|
558
|
+
* on GETs, the raw body on mutations, since input validation now runs after
|
|
559
|
+
* the authority gates. Public routes (`auth.account === 'none' &&
|
|
560
|
+
* auth.actor === 'none'`) skip the phase entirely.
|
|
504
561
|
*
|
|
505
562
|
* Per registry-time invariant 2, `auth.actor !== 'none'` ⟺ the input
|
|
506
|
-
* (or query) schema declares `acting?: ActingActor` — so
|
|
507
|
-
*
|
|
508
|
-
*
|
|
563
|
+
* (or query) schema declares `acting?: ActingActor` — so the selector is
|
|
564
|
+
* present on exactly the specs that read it, and input validation is what
|
|
565
|
+
* rejects a malformed one.
|
|
509
566
|
*
|
|
510
567
|
* Resolved contexts land on `REQUEST_CONTEXT_KEY` so the post-authorization
|
|
511
568
|
* REST middleware (`require_role`, `require_credential_types`) reads the
|
|
@@ -523,7 +580,7 @@ export const create_fuz_authorization_handler = (deps) => {
|
|
|
523
580
|
return;
|
|
524
581
|
if (is_public_auth(spec.auth))
|
|
525
582
|
return;
|
|
526
|
-
const acting_value = needs_actor(spec.auth) ?
|
|
583
|
+
const acting_value = needs_actor(spec.auth) ? await read_route_acting(c, spec) : undefined;
|
|
527
584
|
const account_id = c.get(ACCOUNT_ID_KEY) ?? null;
|
|
528
585
|
const result = await apply_authorization_phase(deps, account_id, spec.auth, acting_value);
|
|
529
586
|
if (!result.ok)
|
|
@@ -538,22 +595,43 @@ export const create_fuz_authorization_handler = (deps) => {
|
|
|
538
595
|
};
|
|
539
596
|
};
|
|
540
597
|
/**
|
|
541
|
-
* Read `acting`
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
* `
|
|
598
|
+
* Read the `acting` actor selector for the authorization phase.
|
|
599
|
+
*
|
|
600
|
+
* REST is bi-located: GETs declare `acting` on `query`, mutations on `input`.
|
|
601
|
+
* Query validation still runs ahead of the authorization phase, so the GET
|
|
602
|
+
* half reads a typed Zod field. Input validation now runs *after* the
|
|
603
|
+
* authority gates (nothing about a route's body shape reaches a caller those
|
|
604
|
+
* gates refuse), so the mutation half reads the raw body instead.
|
|
605
|
+
*
|
|
606
|
+
* The raw read mirrors `create_input_validation`'s own skip conditions exactly
|
|
607
|
+
* — GETs and null-input specs — so this never parses a body the route itself
|
|
608
|
+
* would have left alone. That matters for the routes carrying raw bytes rather
|
|
609
|
+
* than JSON (git smart-HTTP, binary uploads): they declare `input: z.null()`,
|
|
610
|
+
* so their stream reaches the handler untouched, and they carry `acting` on
|
|
611
|
+
* `query` regardless.
|
|
612
|
+
*
|
|
613
|
+
* `parse_acting` owns what counts as a selector and why a malformed one reads
|
|
614
|
+
* as omitted.
|
|
615
|
+
*
|
|
616
|
+
* @param c - the request context, after query validation
|
|
617
|
+
* @param spec - the route being dispatched; decides where `acting` can live
|
|
618
|
+
* @returns the actor id, or `undefined` when absent, malformed, or unreadable
|
|
550
619
|
*/
|
|
551
|
-
const
|
|
552
|
-
const validated_input = c.get('validated_input');
|
|
553
|
-
if (validated_input && typeof validated_input.acting === 'string')
|
|
554
|
-
return validated_input.acting;
|
|
620
|
+
const read_route_acting = async (c, spec) => {
|
|
555
621
|
const validated_query = c.get('validated_query');
|
|
556
622
|
if (validated_query && typeof validated_query.acting === 'string')
|
|
557
623
|
return validated_query.acting;
|
|
558
|
-
|
|
624
|
+
if (spec.method === 'GET' || is_null_schema(spec.input))
|
|
625
|
+
return undefined;
|
|
626
|
+
let body;
|
|
627
|
+
try {
|
|
628
|
+
body = await c.req.json();
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// Malformed body — input validation answers with the 400.
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
if (typeof body !== 'object' || body === null)
|
|
635
|
+
return undefined;
|
|
636
|
+
return parse_acting(body.acting);
|
|
559
637
|
};
|
|
@@ -113,8 +113,19 @@ export declare const query_session_list_for_account: (deps: QueryDeps, account_i
|
|
|
113
113
|
* both commit above `max_sessions`. A transaction is necessary here but not
|
|
114
114
|
* sufficient. Closing this needs one serialization point per
|
|
115
115
|
* `(account_id, credential_kind)` — a locked parent row or a transaction-scoped
|
|
116
|
-
* advisory lock — taken before count/evict/insert
|
|
117
|
-
*
|
|
116
|
+
* advisory lock — taken before count/evict/insert.
|
|
117
|
+
*
|
|
118
|
+
* Ordering is `created_at DESC, id DESC`. The `id` leg is a **stability**
|
|
119
|
+
* tie-breaker, not a recency one: `created_at` defaults to `NOW()`, the
|
|
120
|
+
* *transaction* timestamp, so two sessions born in one transaction — or in two
|
|
121
|
+
* transactions that started in the same microsecond — tie, and an untied
|
|
122
|
+
* `OFFSET` may keep a different set on two evaluations of the same rows. `id` is
|
|
123
|
+
* the blake3 token hash, so the tie-break is arbitrary but deterministic; it
|
|
124
|
+
* makes the survivors reproducible, it does not make the row just inserted a
|
|
125
|
+
* guaranteed survivor. Only the serialization point above does that.
|
|
126
|
+
*
|
|
127
|
+
* Expired-but-unreaped rows count toward the cap (the predicate is `account_id`
|
|
128
|
+
* alone). Matches the Rust twin `query_session_enforce_limit`.
|
|
118
129
|
*
|
|
119
130
|
* @param deps - query dependencies (must be transaction-scoped)
|
|
120
131
|
* @param account_id - the account to enforce the limit for
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/session_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAElE,kDAAkD;AAClD,eAAO,MAAM,wBAAwB,QAA2B,CAAC;AAEjE,yEAAyE;AACzE,eAAO,MAAM,gCAAgC,QAAsB,CAAC;AAEpE;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,MAAM,KAAG,SAElD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,QAAO,MAEzC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,EAClB,YAAY,IAAI,KACd,OAAO,CAAC,IAAI,CAMd,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GACnC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,WAAW,GAAG,SAAS,CAKjC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAU,MAAM,SAAS,EAAE,YAAY,MAAM,KAAG,OAAO,CAAC,IAAI,CAY3F,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qCAAqC,GACjD,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,IAAI,CAEd,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,KAChB,OAAO,CAAC,OAAO,CAMjB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,oCAAoC,GAChD,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,MAAM,CAMhB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAU,KACR,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAK5B,CAAC;AAEF
|
|
1
|
+
{"version":3,"file":"session_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/session_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAElE,kDAAkD;AAClD,eAAO,MAAM,wBAAwB,QAA2B,CAAC;AAEjE,yEAAyE;AACzE,eAAO,MAAM,gCAAgC,QAAsB,CAAC;AAEpE;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,MAAM,KAAG,SAElD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,QAAO,MAEzC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,EAClB,YAAY,IAAI,KACd,OAAO,CAAC,IAAI,CAMd,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GACnC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,WAAW,GAAG,SAAS,CAKjC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAU,MAAM,SAAS,EAAE,YAAY,MAAM,KAAG,OAAO,CAAC,IAAI,CAY3F,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qCAAqC,GACjD,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,IAAI,CAEd,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,KAChB,OAAO,CAAC,OAAO,CAMjB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,oCAAoC,GAChD,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,MAAM,CAMhB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAU,KACR,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAK5B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAc,MAAM,KAClB,OAAO,CAAC,MAAM,CAYhB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,cAAW,KACT,OAAO,CAAC,KAAK,CAAC,WAAW,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CASnD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B,GAAU,MAAM,SAAS,KAAG,OAAO,CAAC,MAAM,CAKnF,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,iBAAiB,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,EACjD,KAAK,MAAM,KACT,OAAO,CAAC,IAAI,CAMd,CAAC"}
|
|
@@ -143,8 +143,19 @@ export const query_session_list_for_account = async (deps, account_id, limit = 5
|
|
|
143
143
|
* both commit above `max_sessions`. A transaction is necessary here but not
|
|
144
144
|
* sufficient. Closing this needs one serialization point per
|
|
145
145
|
* `(account_id, credential_kind)` — a locked parent row or a transaction-scoped
|
|
146
|
-
* advisory lock — taken before count/evict/insert
|
|
147
|
-
*
|
|
146
|
+
* advisory lock — taken before count/evict/insert.
|
|
147
|
+
*
|
|
148
|
+
* Ordering is `created_at DESC, id DESC`. The `id` leg is a **stability**
|
|
149
|
+
* tie-breaker, not a recency one: `created_at` defaults to `NOW()`, the
|
|
150
|
+
* *transaction* timestamp, so two sessions born in one transaction — or in two
|
|
151
|
+
* transactions that started in the same microsecond — tie, and an untied
|
|
152
|
+
* `OFFSET` may keep a different set on two evaluations of the same rows. `id` is
|
|
153
|
+
* the blake3 token hash, so the tie-break is arbitrary but deterministic; it
|
|
154
|
+
* makes the survivors reproducible, it does not make the row just inserted a
|
|
155
|
+
* guaranteed survivor. Only the serialization point above does that.
|
|
156
|
+
*
|
|
157
|
+
* Expired-but-unreaped rows count toward the cap (the predicate is `account_id`
|
|
158
|
+
* alone). Matches the Rust twin `query_session_enforce_limit`.
|
|
148
159
|
*
|
|
149
160
|
* @param deps - query dependencies (must be transaction-scoped)
|
|
150
161
|
* @param account_id - the account to enforce the limit for
|
|
@@ -157,7 +168,7 @@ export const query_session_enforce_limit = async (deps, account_id, max_sessions
|
|
|
157
168
|
WHERE id IN (
|
|
158
169
|
SELECT id FROM auth_session
|
|
159
170
|
WHERE account_id = $1
|
|
160
|
-
ORDER BY created_at DESC
|
|
171
|
+
ORDER BY created_at DESC, id DESC
|
|
161
172
|
OFFSET $2
|
|
162
173
|
) RETURNING id`, [account_id, max_sessions]);
|
|
163
174
|
return rows.length;
|
|
@@ -36,6 +36,16 @@ export declare const DEFAULT_SIGNUP_FAIL_JITTER_MS = 25;
|
|
|
36
36
|
* Per-factory configuration for signup route specs.
|
|
37
37
|
*/
|
|
38
38
|
export interface SignupRouteOptions extends AuthSessionRouteOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Rate limiter for signup attempts, keyed by client IP. Pass `null` to
|
|
41
|
+
* disable. Its own instance, not login's: signup failures bound username
|
|
42
|
+
* *enumeration* (the 403/409 split), not credential guessing, and an
|
|
43
|
+
* open-signup deployment lets any unauthenticated caller spend this budget
|
|
44
|
+
* — which must not cost anyone their login attempts. Never refunded on
|
|
45
|
+
* success (see `RateLimiter.reset`). Mirrors the Rust spine's
|
|
46
|
+
* `signup_ip_rate_limiter`.
|
|
47
|
+
*/
|
|
48
|
+
signup_ip_rate_limiter: RateLimiter | null;
|
|
39
49
|
/** Rate limiter for signup attempts, keyed by submitted username. Pass `null` to disable. */
|
|
40
50
|
signup_account_rate_limiter: RateLimiter | null;
|
|
41
51
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"signup_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAEnE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAQhD;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,uBAAuB;IAClE,6FAA6F;IAC7F,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAID;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,gBAAgB,EACtB,SAAS,kBAAkB,KACzB,KAAK,CAAC,SAAS,
|
|
1
|
+
{"version":3,"file":"signup_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAEnE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAQhD;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,uBAAuB;IAClE;;;;;;;;OAQG;IACH,sBAAsB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3C,6FAA6F;IAC7F,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAID;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,gBAAgB,EACtB,SAAS,kBAAkB,KACzB,KAAK,CAAC,SAAS,CAgLjB,CAAC"}
|
|
@@ -55,7 +55,7 @@ const signup_fail_delay = (floor_ms, jitter_ms) => {
|
|
|
55
55
|
*/
|
|
56
56
|
export const create_signup_route_specs = (deps, options) => {
|
|
57
57
|
const { keyring, password } = deps;
|
|
58
|
-
const { session_options,
|
|
58
|
+
const { session_options, signup_ip_rate_limiter, signup_account_rate_limiter, signup_fail_floor_ms = DEFAULT_SIGNUP_FAIL_FLOOR_MS, signup_fail_jitter_ms = DEFAULT_SIGNUP_FAIL_JITTER_MS } = options;
|
|
59
59
|
return [
|
|
60
60
|
{
|
|
61
61
|
...create_signup_route_shape({
|
|
@@ -63,9 +63,9 @@ export const create_signup_route_specs = (deps, options) => {
|
|
|
63
63
|
}),
|
|
64
64
|
handler: async (c, route) => {
|
|
65
65
|
// Per-IP rate limit check (before any work). 429 stays fast.
|
|
66
|
-
const ip =
|
|
67
|
-
if (
|
|
68
|
-
const check =
|
|
66
|
+
const ip = signup_ip_rate_limiter ? get_client_ip(c) : null;
|
|
67
|
+
if (signup_ip_rate_limiter && ip) {
|
|
68
|
+
const check = signup_ip_rate_limiter.check(ip);
|
|
69
69
|
if (!check.allowed) {
|
|
70
70
|
return rate_limit_exceeded_response(c, check.retry_after);
|
|
71
71
|
}
|
|
@@ -157,8 +157,8 @@ export const create_signup_route_specs = (deps, options) => {
|
|
|
157
157
|
}
|
|
158
158
|
catch (e) {
|
|
159
159
|
if (e instanceof NoMatchingInviteError) {
|
|
160
|
-
if (
|
|
161
|
-
|
|
160
|
+
if (signup_ip_rate_limiter && ip)
|
|
161
|
+
signup_ip_rate_limiter.record(ip);
|
|
162
162
|
if (signup_account_rate_limiter)
|
|
163
163
|
signup_account_rate_limiter.record(account_key);
|
|
164
164
|
emit_failure_audit('no_match');
|
|
@@ -167,8 +167,8 @@ export const create_signup_route_specs = (deps, options) => {
|
|
|
167
167
|
}
|
|
168
168
|
// Unique constraint violation: username or email already exists.
|
|
169
169
|
if (is_pg_unique_violation(e)) {
|
|
170
|
-
if (
|
|
171
|
-
|
|
170
|
+
if (signup_ip_rate_limiter && ip)
|
|
171
|
+
signup_ip_rate_limiter.record(ip);
|
|
172
172
|
if (signup_account_rate_limiter)
|
|
173
173
|
signup_account_rate_limiter.record(account_key);
|
|
174
174
|
emit_failure_audit('signup_conflict');
|
|
@@ -185,9 +185,12 @@ export const create_signup_route_specs = (deps, options) => {
|
|
|
185
185
|
emit_failure_audit('internal_error');
|
|
186
186
|
throw e;
|
|
187
187
|
}
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
// Clear the account-grain bucket only — the IP aggregate stands.
|
|
189
|
+
// This is the cheapest version of the spray path `RateLimiter.reset`
|
|
190
|
+
// describes: where login at least costs the attacker a credential,
|
|
191
|
+
// an open-signup deployment let an *unauthenticated* caller zero a
|
|
192
|
+
// shared IP budget by creating throwaway accounts, and the
|
|
193
|
+
// per-account limiter never applies to the attacker's own signups.
|
|
191
194
|
if (signup_account_rate_limiter)
|
|
192
195
|
signup_account_rate_limiter.reset(account_key);
|
|
193
196
|
deps.audit.emit(route, {
|