@fuzdev/fuz_app 0.54.0 → 0.56.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 +214 -103
- package/dist/actions/action_bridge.d.ts +8 -5
- package/dist/actions/action_bridge.d.ts.map +1 -1
- package/dist/actions/action_bridge.js +1 -11
- package/dist/actions/action_codegen.d.ts +32 -0
- package/dist/actions/action_codegen.d.ts.map +1 -1
- package/dist/actions/action_codegen.js +35 -15
- package/dist/actions/action_registry.d.ts.map +1 -1
- package/dist/actions/action_registry.js +5 -2
- package/dist/actions/action_rpc.d.ts +141 -22
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +106 -187
- package/dist/actions/action_spec.d.ts +55 -16
- package/dist/actions/action_spec.d.ts.map +1 -1
- package/dist/actions/action_spec.js +16 -11
- package/dist/actions/action_types.d.ts +28 -60
- package/dist/actions/action_types.d.ts.map +1 -1
- package/dist/actions/action_types.js +13 -5
- package/dist/actions/broadcast_api.d.ts +2 -2
- package/dist/actions/broadcast_api.js +2 -2
- package/dist/actions/compile_action_registry.d.ts +50 -0
- package/dist/actions/compile_action_registry.d.ts.map +1 -0
- package/dist/actions/compile_action_registry.js +69 -0
- package/dist/actions/heartbeat.d.ts +8 -4
- package/dist/actions/heartbeat.d.ts.map +1 -1
- package/dist/actions/heartbeat.js +5 -4
- package/dist/actions/perform_action.d.ts +145 -0
- package/dist/actions/perform_action.d.ts.map +1 -0
- package/dist/actions/perform_action.js +258 -0
- package/dist/actions/register_action_ws.d.ts +46 -40
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +101 -159
- package/dist/actions/register_ws_endpoint.d.ts +15 -10
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +54 -7
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +0 -4
- package/dist/actions/transports_ws_auth_guard.d.ts +1 -1
- package/dist/actions/transports_ws_auth_guard.js +1 -1
- package/dist/actions/transports_ws_backend.d.ts +1 -1
- package/dist/actions/transports_ws_backend.js +1 -1
- package/dist/auth/CLAUDE.md +794 -410
- package/dist/auth/account_action_specs.d.ts +28 -7
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +7 -7
- package/dist/auth/account_actions.d.ts +7 -13
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +26 -35
- package/dist/auth/account_queries.d.ts +52 -16
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +87 -38
- package/dist/auth/account_routes.d.ts +9 -11
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +118 -46
- package/dist/auth/account_schema.d.ts +46 -35
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +21 -28
- package/dist/auth/admin_action_specs.d.ts +100 -32
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +64 -33
- package/dist/auth/admin_actions.d.ts +13 -19
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +37 -41
- package/dist/auth/audit_emitter.d.ts +160 -0
- package/dist/auth/audit_emitter.d.ts.map +1 -0
- package/dist/auth/audit_emitter.js +83 -0
- package/dist/auth/audit_log_queries.d.ts +17 -48
- package/dist/auth/audit_log_queries.d.ts.map +1 -1
- package/dist/auth/audit_log_queries.js +20 -56
- package/dist/auth/audit_log_routes.d.ts +1 -1
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +7 -3
- package/dist/auth/audit_log_schema.d.ts +92 -32
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +75 -46
- package/dist/auth/auth_guard_resolver.d.ts +44 -0
- package/dist/auth/auth_guard_resolver.d.ts.map +1 -0
- package/dist/auth/auth_guard_resolver.js +56 -0
- package/dist/auth/bearer_auth.d.ts +9 -7
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +13 -21
- package/dist/auth/bootstrap_account.d.ts +7 -7
- package/dist/auth/bootstrap_account.d.ts.map +1 -1
- package/dist/auth/bootstrap_account.js +7 -7
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +11 -10
- package/dist/auth/cleanup.d.ts +20 -26
- package/dist/auth/cleanup.d.ts.map +1 -1
- package/dist/auth/cleanup.js +33 -42
- package/dist/auth/credential_type_schema.d.ts +115 -0
- package/dist/auth/credential_type_schema.d.ts.map +1 -0
- package/dist/auth/credential_type_schema.js +127 -0
- package/dist/auth/daemon_token_middleware.d.ts +23 -11
- package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.js +28 -22
- package/dist/auth/ddl.d.ts +2 -2
- package/dist/auth/ddl.d.ts.map +1 -1
- package/dist/auth/ddl.js +6 -6
- package/dist/auth/deps.d.ts +7 -18
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/grant_path_schema.d.ts +117 -0
- package/dist/auth/grant_path_schema.d.ts.map +1 -0
- package/dist/auth/grant_path_schema.js +137 -0
- package/dist/auth/invite_queries.d.ts +12 -1
- package/dist/auth/invite_queries.d.ts.map +1 -1
- package/dist/auth/invite_queries.js +12 -1
- package/dist/auth/invite_schema.d.ts +1 -1
- package/dist/auth/invite_schema.d.ts.map +1 -1
- package/dist/auth/invite_schema.js +1 -1
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +9 -4
- package/dist/auth/migrations.d.ts +37 -14
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +79 -32
- package/dist/auth/request_context.d.ts +331 -61
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +378 -95
- package/dist/auth/{permit_offer_action_specs.d.ts → role_grant_offer_action_specs.d.ts} +163 -94
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -0
- package/dist/auth/role_grant_offer_action_specs.js +262 -0
- package/dist/auth/role_grant_offer_actions.d.ts +104 -0
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -0
- package/dist/auth/role_grant_offer_actions.js +473 -0
- package/dist/auth/{permit_offer_notifications.d.ts → role_grant_offer_notifications.d.ts} +90 -70
- package/dist/auth/role_grant_offer_notifications.d.ts.map +1 -0
- package/dist/auth/role_grant_offer_notifications.js +182 -0
- package/dist/auth/role_grant_offer_queries.d.ts +242 -0
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -0
- package/dist/auth/role_grant_offer_queries.js +533 -0
- package/dist/auth/role_grant_offer_schema.d.ts +150 -0
- package/dist/auth/role_grant_offer_schema.d.ts.map +1 -0
- package/dist/auth/{permit_offer_schema.js → role_grant_offer_schema.js} +60 -36
- package/dist/auth/role_grant_queries.d.ts +231 -0
- package/dist/auth/role_grant_queries.d.ts.map +1 -0
- package/dist/auth/role_grant_queries.js +320 -0
- package/dist/auth/role_schema.d.ts +150 -40
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +144 -45
- package/dist/auth/scope_kind_schema.d.ts +96 -0
- package/dist/auth/scope_kind_schema.d.ts.map +1 -0
- package/dist/auth/scope_kind_schema.js +94 -0
- package/dist/auth/self_service_role_action_specs.d.ts +6 -1
- package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
- package/dist/auth/self_service_role_action_specs.js +3 -1
- package/dist/auth/self_service_role_actions.d.ts +34 -27
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.js +68 -48
- package/dist/auth/session_cookie.d.ts +43 -6
- package/dist/auth/session_cookie.d.ts.map +1 -1
- package/dist/auth/session_cookie.js +31 -5
- package/dist/auth/session_middleware.d.ts +37 -3
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +33 -7
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +48 -19
- package/dist/auth/standard_action_specs.d.ts +2 -2
- package/dist/auth/standard_action_specs.js +4 -4
- package/dist/auth/standard_rpc_actions.d.ts +23 -19
- package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
- package/dist/auth/standard_rpc_actions.js +12 -12
- package/dist/db/migrate.d.ts +12 -8
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +10 -7
- package/dist/dev/setup.d.ts +2 -2
- package/dist/dev/setup.d.ts.map +1 -1
- package/dist/dev/setup.js +9 -7
- package/dist/env/load.d.ts +1 -1
- package/dist/env/load.js +1 -1
- package/dist/hono_context.d.ts +64 -5
- package/dist/hono_context.d.ts.map +1 -1
- package/dist/hono_context.js +38 -2
- package/dist/http/CLAUDE.md +264 -87
- package/dist/http/auth_shape.d.ts +191 -0
- package/dist/http/auth_shape.d.ts.map +1 -0
- package/dist/http/auth_shape.js +237 -0
- package/dist/http/common_routes.js +3 -3
- package/dist/http/db_routes.d.ts +4 -0
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +44 -7
- package/dist/http/error_schemas.d.ts +132 -19
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +132 -40
- package/dist/http/jsonrpc_errors.d.ts +27 -2
- package/dist/http/jsonrpc_errors.d.ts.map +1 -1
- package/dist/http/jsonrpc_errors.js +26 -2
- package/dist/http/pending_effects.d.ts +71 -18
- package/dist/http/pending_effects.d.ts.map +1 -1
- package/dist/http/pending_effects.js +87 -18
- package/dist/http/proxy.d.ts +52 -5
- package/dist/http/proxy.d.ts.map +1 -1
- package/dist/http/proxy.js +92 -14
- package/dist/http/route_spec.d.ts +113 -41
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +130 -52
- package/dist/http/schema_helpers.d.ts +3 -2
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +9 -2
- package/dist/http/surface.d.ts +2 -1
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +1 -2
- package/dist/http/surface_query.d.ts +39 -35
- package/dist/http/surface_query.d.ts.map +1 -1
- package/dist/http/surface_query.js +79 -36
- package/dist/primitive_schemas.d.ts +39 -0
- package/dist/primitive_schemas.d.ts.map +1 -0
- package/dist/primitive_schemas.js +40 -0
- package/dist/realtime/sse_auth_guard.d.ts +5 -5
- package/dist/realtime/sse_auth_guard.js +9 -9
- package/dist/runtime/mock.d.ts +1 -1
- package/dist/runtime/mock.js +1 -1
- package/dist/server/app_backend.d.ts +14 -11
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +12 -8
- package/dist/server/app_server.d.ts +7 -7
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +36 -31
- package/dist/server/validate_nginx.d.ts +1 -1
- package/dist/server/validate_nginx.js +1 -1
- package/dist/testing/CLAUDE.md +73 -55
- package/dist/testing/admin_integration.d.ts +5 -6
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +100 -96
- package/dist/testing/adversarial_headers.js +1 -1
- package/dist/testing/app_server.d.ts +11 -14
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +18 -17
- package/dist/testing/assertions.d.ts.map +1 -1
- package/dist/testing/assertions.js +2 -1
- package/dist/testing/attack_surface.d.ts.map +1 -1
- package/dist/testing/attack_surface.js +15 -9
- package/dist/testing/audit_completeness.d.ts +2 -2
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +53 -39
- package/dist/testing/auth_apps.d.ts +5 -4
- package/dist/testing/auth_apps.d.ts.map +1 -1
- package/dist/testing/auth_apps.js +28 -22
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/data_exposure.js +5 -5
- package/dist/testing/db.d.ts +1 -1
- package/dist/testing/db.d.ts.map +1 -1
- package/dist/testing/db.js +4 -4
- package/dist/testing/db_entities.d.ts +22 -0
- package/dist/testing/db_entities.d.ts.map +1 -0
- package/dist/testing/db_entities.js +28 -0
- package/dist/testing/entities.d.ts +10 -8
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +22 -18
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +13 -14
- package/dist/testing/integration_helpers.d.ts +8 -6
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +29 -23
- package/dist/testing/middleware.d.ts +15 -11
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +75 -32
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.js +40 -24
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.js +3 -1
- package/dist/testing/rpc_round_trip.d.ts +1 -1
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.js +14 -13
- package/dist/testing/sse_round_trip.d.ts +3 -4
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +7 -11
- package/dist/testing/standard.d.ts +1 -1
- package/dist/testing/stubs.d.ts +25 -0
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +43 -2
- package/dist/testing/surface_invariants.d.ts +2 -2
- package/dist/testing/ws_round_trip.d.ts +12 -13
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +24 -12
- package/dist/ui/AdminAccounts.svelte +23 -20
- package/dist/ui/AdminOverview.svelte +15 -13
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/{AdminPermitHistory.svelte → AdminRoleGrantHistory.svelte} +12 -12
- package/dist/ui/AdminRoleGrantHistory.svelte.d.ts +4 -0
- package/dist/ui/AdminRoleGrantHistory.svelte.d.ts.map +1 -0
- package/dist/ui/BootstrapForm.svelte +1 -1
- package/dist/ui/CLAUDE.md +65 -59
- package/dist/ui/{PermitOfferForm.svelte → RoleGrantOfferForm.svelte} +37 -22
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts +20 -0
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts.map +1 -0
- package/dist/ui/{PermitOfferHistory.svelte → RoleGrantOfferHistory.svelte} +12 -12
- package/dist/ui/{PermitOfferHistory.svelte.d.ts → RoleGrantOfferHistory.svelte.d.ts} +4 -4
- package/dist/ui/RoleGrantOfferHistory.svelte.d.ts.map +1 -0
- package/dist/ui/{PermitOfferInbox.svelte → RoleGrantOfferInbox.svelte} +14 -14
- package/dist/ui/{PermitOfferInbox.svelte.d.ts → RoleGrantOfferInbox.svelte.d.ts} +4 -4
- package/dist/ui/RoleGrantOfferInbox.svelte.d.ts.map +1 -0
- package/dist/ui/SignupForm.svelte +1 -1
- package/dist/ui/SurfaceExplorer.svelte +35 -15
- package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.d.ts +2 -3
- package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.js +2 -3
- package/dist/ui/admin_accounts_state.svelte.d.ts +25 -18
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +28 -17
- package/dist/ui/admin_rpc_adapters.d.ts +20 -20
- package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
- package/dist/ui/admin_rpc_adapters.js +17 -17
- package/dist/ui/admin_sessions_state.svelte.d.ts +2 -2
- package/dist/ui/admin_sessions_state.svelte.js +2 -2
- package/dist/ui/audit_log_state.svelte.d.ts +7 -7
- package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
- package/dist/ui/audit_log_state.svelte.js +6 -6
- package/dist/ui/auth_state.svelte.d.ts +3 -3
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +6 -6
- package/dist/ui/format_scope.d.ts +2 -2
- package/dist/ui/format_scope.js +2 -2
- package/dist/ui/{permit_offers_state.svelte.d.ts → role_grant_offers_state.svelte.d.ts} +39 -31
- package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -0
- package/dist/ui/{permit_offers_state.svelte.js → role_grant_offers_state.svelte.js} +25 -19
- package/dist/ui/ui_format.js +2 -2
- package/package.json +3 -3
- package/dist/auth/permit_offer_action_specs.d.ts.map +0 -1
- package/dist/auth/permit_offer_action_specs.js +0 -227
- package/dist/auth/permit_offer_actions.d.ts +0 -110
- package/dist/auth/permit_offer_actions.d.ts.map +0 -1
- package/dist/auth/permit_offer_actions.js +0 -452
- package/dist/auth/permit_offer_notifications.d.ts.map +0 -1
- package/dist/auth/permit_offer_notifications.js +0 -182
- package/dist/auth/permit_offer_queries.d.ts +0 -183
- package/dist/auth/permit_offer_queries.d.ts.map +0 -1
- package/dist/auth/permit_offer_queries.js +0 -408
- package/dist/auth/permit_offer_schema.d.ts +0 -103
- package/dist/auth/permit_offer_schema.d.ts.map +0 -1
- package/dist/auth/permit_queries.d.ts +0 -210
- package/dist/auth/permit_queries.d.ts.map +0 -1
- package/dist/auth/permit_queries.js +0 -294
- package/dist/auth/require_keeper.d.ts +0 -20
- package/dist/auth/require_keeper.d.ts.map +0 -1
- package/dist/auth/require_keeper.js +0 -35
- package/dist/auth/route_guards.d.ts +0 -21
- package/dist/auth/route_guards.d.ts.map +0 -1
- package/dist/auth/route_guards.js +0 -32
- package/dist/auth/session_lifecycle.d.ts +0 -37
- package/dist/auth/session_lifecycle.d.ts.map +0 -1
- package/dist/auth/session_lifecycle.js +0 -29
- package/dist/ui/AdminPermitHistory.svelte.d.ts +0 -4
- package/dist/ui/AdminPermitHistory.svelte.d.ts.map +0 -1
- package/dist/ui/PermitOfferForm.svelte.d.ts +0 -14
- package/dist/ui/PermitOfferForm.svelte.d.ts.map +0 -1
- package/dist/ui/PermitOfferHistory.svelte.d.ts.map +0 -1
- package/dist/ui/PermitOfferInbox.svelte.d.ts.map +0 -1
- package/dist/ui/permit_offers_state.svelte.d.ts.map +0 -1
package/dist/auth/CLAUDE.md
CHANGED
|
@@ -18,15 +18,15 @@ as their first arg.
|
|
|
18
18
|
Pure, I/O-free operations. Framework-dependent middleware lives in later
|
|
19
19
|
sections.
|
|
20
20
|
|
|
21
|
-
| Module | Exports
|
|
22
|
-
| ---------------------- |
|
|
23
|
-
| `keyring.ts` | `Keyring`, `create_keyring`, `validate_keyring`, `create_validated_keyring`, `ValidatedKeyringResult`
|
|
24
|
-
| `session_cookie.ts` | `SessionOptions<T>`, `SessionCookieOptions`, `SESSION_COOKIE_OPTIONS`, `SESSION_AGE_MAX`, `ParsedSession`, `ProcessSessionResult`, `parse_session`, `create_session_cookie_value`, `process_session_cookie`, `create_session_config`, `fuz_session_config` |
|
|
25
|
-
| `password.ts` | `Password`, `PasswordProvided`, `PasswordHashDeps`, `PASSWORD_LENGTH_MIN` (12, OWASP), `PASSWORD_LENGTH_MAX` (300)
|
|
26
|
-
| `password_argon2.ts` | `hash_password`, `verify_password`, `verify_dummy`, `argon2_password_deps`
|
|
27
|
-
| `api_token.ts` | `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token`
|
|
28
|
-
| `daemon_token.ts` | `DaemonToken` (Zod), `DAEMON_TOKEN_HEADER` (`X-Daemon-Token`), `generate_daemon_token`, `validate_daemon_token`, `DaemonTokenState`
|
|
29
|
-
| `bootstrap_account.ts` | `bootstrap_account`, `BootstrapAccountDeps`, `BootstrapAccountInput`, `BootstrapAccountSuccess`, `BootstrapAccountFailure`, `BootstrapAccountResult`
|
|
21
|
+
| Module | Exports |
|
|
22
|
+
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| `keyring.ts` | `Keyring`, `create_keyring`, `validate_keyring`, `create_validated_keyring`, `ValidatedKeyringResult` |
|
|
24
|
+
| `session_cookie.ts` | `SessionOptions<T>`, `SessionCookieOptions`, `SESSION_COOKIE_OPTIONS`, `SESSION_AGE_MAX`, `SESSION_REFRESH_THRESHOLD_S`, `ParsedSession`, `ProcessSessionResult`, `parse_session`, `create_session_cookie_value`, `process_session_cookie`, `create_session_config`, `fuz_session_config` |
|
|
25
|
+
| `password.ts` | `Password`, `PasswordProvided`, `PasswordHashDeps`, `PASSWORD_LENGTH_MIN` (12, OWASP), `PASSWORD_LENGTH_MAX` (300) |
|
|
26
|
+
| `password_argon2.ts` | `hash_password`, `verify_password`, `verify_dummy`, `argon2_password_deps` |
|
|
27
|
+
| `api_token.ts` | `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token` |
|
|
28
|
+
| `daemon_token.ts` | `DaemonToken` (Zod), `DAEMON_TOKEN_HEADER` (`X-Daemon-Token`), `generate_daemon_token`, `validate_daemon_token`, `DaemonTokenState` |
|
|
29
|
+
| `bootstrap_account.ts` | `bootstrap_account`, `BootstrapAccountDeps`, `BootstrapAccountInput`, `BootstrapAccountSuccess`, `BootstrapAccountFailure`, `BootstrapAccountResult` |
|
|
30
30
|
|
|
31
31
|
Design notes:
|
|
32
32
|
|
|
@@ -41,7 +41,13 @@ Design notes:
|
|
|
41
41
|
`string` for session-id references (server-side sessions, per-session
|
|
42
42
|
revocation), `number` for direct account-id references (no server state).
|
|
43
43
|
The canonical fuz pattern is `SessionOptions<string>` via
|
|
44
|
-
`create_session_config(name)`.
|
|
44
|
+
`create_session_config(name)`. `SessionOptions.max_age` is the single
|
|
45
|
+
source of truth for cookie lifetime — drives both the signed `expires_at`
|
|
46
|
+
and the HTTP `Max-Age` attribute. `process_session_cookie` re-signs on
|
|
47
|
+
key rotation **or** when within `refresh_threshold_seconds` (default
|
|
48
|
+
`SESSION_REFRESH_THRESHOLD_S` = 1 day) of expiry, mirroring the DB-side
|
|
49
|
+
`AUTH_SESSION_EXTEND_THRESHOLD_MS` so a continuously-active user's
|
|
50
|
+
cookie tracks their server session.
|
|
45
51
|
- **Password** has two schemas deliberately. `Password` enforces the current
|
|
46
52
|
length policy (used at account creation and password change);
|
|
47
53
|
`PasswordProvided` is minimal (`min(1)`) for login / verification so a
|
|
@@ -61,7 +67,7 @@ Design notes:
|
|
|
61
67
|
- **Bootstrap account** is one-shot; protected by the `bootstrap_lock` table
|
|
62
68
|
via atomic `UPDATE ... WHERE id = 1 AND bootstrapped = false RETURNING id`.
|
|
63
69
|
Token read + password hash happen outside the transaction (CPU + I/O);
|
|
64
|
-
lock acquisition + account + actor + two
|
|
70
|
+
lock acquisition + account + actor + two role_grants (`keeper` and `admin`)
|
|
65
71
|
happen inside. On commit, the token file is deleted — if that fails,
|
|
66
72
|
`token_file_deleted: false` is returned and the caller is expected to
|
|
67
73
|
surface an error (the `/bootstrap` handler throws so the operator gets a
|
|
@@ -70,23 +76,26 @@ Design notes:
|
|
|
70
76
|
|
|
71
77
|
## Schemas, types, and DDL
|
|
72
78
|
|
|
73
|
-
| Module
|
|
74
|
-
|
|
|
75
|
-
| `account_schema.ts`
|
|
76
|
-
| `role_schema.ts`
|
|
77
|
-
| `ddl.ts`
|
|
78
|
-
| `invite_schema.ts`
|
|
79
|
-
| `app_settings_schema.ts`
|
|
80
|
-
| `audit_log_schema.ts`
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
79
|
+
| Module | What's inside |
|
|
80
|
+
| ----------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
81
|
+
| `account_schema.ts` | Runtime types + client-safe Zod schemas for identity entities |
|
|
82
|
+
| `role_schema.ts` | Role vocabulary and extensibility |
|
|
83
|
+
| `ddl.ts` | Raw `CREATE TABLE` / index / seed SQL strings |
|
|
84
|
+
| `invite_schema.ts` | `Invite`, `InviteJson`, `InviteWithUsernamesJson`, `CreateInviteInput` |
|
|
85
|
+
| `app_settings_schema.ts` | `AppSettings`, `AppSettingsJson`, `AppSettingsWithUsernameJson`, `UpdateAppSettingsInput` |
|
|
86
|
+
| `audit_log_schema.ts` | Event-type enum, per-type metadata schemas, table DDL |
|
|
87
|
+
| `role_grant_offer_schema.ts` | Role grant offer DDL, types, and client-safe schemas |
|
|
88
|
+
| `role_grant_offer_notifications.ts` | WS notification specs for the consentful-role-grant lifecycle |
|
|
83
89
|
|
|
84
90
|
### Identity entities (`account_schema.ts`)
|
|
85
91
|
|
|
86
92
|
- `Account` (primary identity, holds `password_hash`), `Actor` (the entity
|
|
87
|
-
that acts — owns cells, holds
|
|
88
|
-
|
|
89
|
-
actor
|
|
93
|
+
that acts — owns cells, holds role_grants, appears in audit trails; an account
|
|
94
|
+
may host one or more actors, with the dispatcher's authorization phase
|
|
95
|
+
resolving the acting actor per-request via `acting?: ActingActor` on
|
|
96
|
+
inputs), `RoleGrant` (time-bounded, revocable grant of a role to an
|
|
97
|
+
actor — carries `scope_kind` + `scope_id` paired-null,
|
|
98
|
+
`source_offer_id`, `revoked_reason`),
|
|
90
99
|
`AuthSession` (server-side, keyed by blake3), `ApiToken`.
|
|
91
100
|
- Every `id` / `*_id` field on entity interfaces, `*Json` schemas, and
|
|
92
101
|
`*Input` types is branded `Uuid` (from `@fuzdev/fuz_util/uuid.js`), except
|
|
@@ -96,44 +105,137 @@ Design notes:
|
|
|
96
105
|
`UsernameProvided`: `min(1).max(255)` — permissive for login/lookup so
|
|
97
106
|
tightening creation rules won't lock out existing users.
|
|
98
107
|
- `Email`: `z.email()`.
|
|
99
|
-
- `
|
|
100
|
-
and the `
|
|
108
|
+
- `ROLE_GRANT_REVOKED_REASON_LENGTH_MAX = 500` — bounds both the admin input
|
|
109
|
+
and the `role_grant_revoke` WS payload.
|
|
101
110
|
- Client-safe Zod schemas (every exported schema has a same-named `z.infer`
|
|
102
111
|
type export):
|
|
103
112
|
- `SessionAccountJson` — strips sensitive fields from `Account`
|
|
104
113
|
- `AuthSessionJson` — `id` is the blake3 hash (safe for client)
|
|
105
114
|
- `ClientApiTokenJson` — excludes `token_hash`
|
|
106
|
-
- `
|
|
115
|
+
- `RoleGrantSummaryJson` — the client-safe role_grant shape carried by
|
|
107
116
|
`GET /api/account/status` and the admin account listing; includes
|
|
108
|
-
`scope_id` so clients can make
|
|
117
|
+
`scope_kind` + `scope_id` (paired-null) so clients can make
|
|
118
|
+
per-scope auth decisions. Excludes
|
|
109
119
|
`revoked_at` / `revoked_by` / `revoked_reason` because the callers
|
|
110
|
-
that return it already filter to active
|
|
120
|
+
that return it already filter to active role_grants.
|
|
111
121
|
- `ActorSummaryJson`
|
|
112
122
|
- `AdminAccountJson` extends `SessionAccountJson` with `updated_at` / `updated_by`
|
|
113
|
-
- `PendingOfferSummaryJson` — narrower than `
|
|
123
|
+
- `PendingOfferSummaryJson` — narrower than `RoleGrantOfferJson`; omits
|
|
114
124
|
`message` and `decline_reason` so cross-admin visibility of the listing
|
|
115
125
|
does not expose grantor-authored text beyond what the audit log
|
|
116
126
|
discloses. `from_username` is resolved server-side so admins can see
|
|
117
127
|
whose pending offer is blocking a "+ role" button.
|
|
118
|
-
- `AdminAccountEntryJson` — composes `{account, actor,
|
|
128
|
+
- `AdminAccountEntryJson` — composes `{account, actor, role_grants, pending_offers}`
|
|
119
129
|
- Converters: `to_session_account(account)`, `to_admin_account(account)`,
|
|
120
|
-
`
|
|
121
|
-
- Input types: `CreateAccountInput`, `
|
|
122
|
-
`scope_id`, `source_offer_id`
|
|
130
|
+
`is_role_grant_active(p, now?)`.
|
|
131
|
+
- Input types: `CreateAccountInput`, `CreateRoleGrantInput` (with optional
|
|
132
|
+
`scope_kind`, `scope_id`, `source_offer_id` — `scope_kind` paired-null
|
|
133
|
+
with `scope_id` per the `role_grant_scope_kind_paired` CHECK).
|
|
134
|
+
|
|
135
|
+
### Scope-kind system (`scope_kind_schema.ts`)
|
|
136
|
+
|
|
137
|
+
Open string registry tagging the polymorphic `role_grant.scope_id` /
|
|
138
|
+
`role_grant_offer.scope_id` with a machine-readable kind. Mirrors the open
|
|
139
|
+
registry pattern used for `RoleName` / `AuditEventTypeName` /
|
|
140
|
+
`CredentialType`.
|
|
141
|
+
|
|
142
|
+
- `SCOPE_KIND_NAME_REGEX` / `ScopeKindName`: lowercase letters and
|
|
143
|
+
underscores (`^[a-z][a-z_]*[a-z]$|^[a-z]$`), no leading/trailing
|
|
144
|
+
underscore. Same shape as `RoleName`. Uppercase `'GLOBAL'` is
|
|
145
|
+
structurally rejected — it appears only as an index-side token in
|
|
146
|
+
`COALESCE(scope_kind, 'GLOBAL')` inside the partial unique indexes,
|
|
147
|
+
never as a column value.
|
|
148
|
+
- `ScopeKindMeta`: `{description?: string}` — admin-UI-facing copy.
|
|
149
|
+
Open shape so v2 can extend without breaking change.
|
|
150
|
+
- `create_scope_kind_schema(consumer_kinds: Record<string, ScopeKindMeta>)`
|
|
151
|
+
→ `{ScopeKind, scope_kinds: ReadonlyMap}`. No builtins. Construction-
|
|
152
|
+
time guards: regex on every name, duplicate detection. Empty registry
|
|
153
|
+
returns `z.never()` — every parse fails. Pass the result into
|
|
154
|
+
`create_role_schema` to validate `RoleSpec.applicable_scope_kinds`
|
|
155
|
+
entries (informative-only in v1; INSERT-time `(role, scope_kind)`
|
|
156
|
+
enforcement reserved for v2).
|
|
157
|
+
- Encoding: paired-null with `scope_id`. Both null = global, both
|
|
158
|
+
non-null = scoped, mismatch rejected by the
|
|
159
|
+
`role_grant_scope_kind_paired` / `role_grant_offer_scope_kind_paired` CHECK
|
|
160
|
+
constraints.
|
|
161
|
+
|
|
162
|
+
### Credential-type system (`credential_type_schema.ts`)
|
|
163
|
+
|
|
164
|
+
Open string registry over the credential types that can authenticate a
|
|
165
|
+
request. Three builtins (`session`, `api_token`, `daemon_token`); the
|
|
166
|
+
wire-validated `CredentialType` Zod enum in `hono_context.ts` mirrors
|
|
167
|
+
those three. Mirrors the open-registry pattern used for `RoleName` /
|
|
168
|
+
`ScopeKindName` / `GrantPathName` / `AuditEventTypeName`.
|
|
169
|
+
|
|
170
|
+
- `CREDENTIAL_TYPE_NAME_REGEX` / `CredentialTypeName`: lowercase letters
|
|
171
|
+
and underscores. Same shape as `RoleName`.
|
|
172
|
+
- `CREDENTIAL_TYPE_SESSION` / `CREDENTIAL_TYPE_API_TOKEN` /
|
|
173
|
+
`CREDENTIAL_TYPE_DAEMON_TOKEN` — the three builtin literals. The
|
|
174
|
+
constant is named `_API_TOKEN` (not `_BEARER`) so wire literal and
|
|
175
|
+
the `api_token` storage table stay in lockstep.
|
|
176
|
+
- `BUILTIN_CREDENTIAL_TYPES` const tuple, `BuiltinCredentialType` Zod
|
|
177
|
+
enum, `BUILTIN_CREDENTIAL_TYPE_META` admin-UI-facing descriptions.
|
|
178
|
+
- `create_credential_type_schema(consumer_types?)`
|
|
179
|
+
→ `{CredentialType, credential_types: ReadonlyMap}`. Builtins always
|
|
180
|
+
present; consumer collisions / regex failures / duplicates throw at
|
|
181
|
+
construction. Pass the result into `create_role_schema`'s optional
|
|
182
|
+
`credential_types` parameter to validate every
|
|
183
|
+
`RoleSpec.required_credential_types` entry at construction time.
|
|
184
|
+
|
|
185
|
+
### Grant-path system (`grant_path_schema.ts`)
|
|
186
|
+
|
|
187
|
+
Open string registry over the surfaces through which a role can be
|
|
188
|
+
granted. Four builtins (`admin`, `self_service`, `system`, `bootstrap`).
|
|
189
|
+
|
|
190
|
+
- `GRANT_PATH_NAME_REGEX` / `GrantPathName`: lowercase letters and
|
|
191
|
+
underscores, mirrors `RoleName`.
|
|
192
|
+
- `GRANT_PATH_ADMIN` / `_SELF_SERVICE` / `_SYSTEM` / `_BOOTSTRAP` —
|
|
193
|
+
builtin literal constants.
|
|
194
|
+
- `BUILTIN_GRANT_PATHS` const tuple, `BuiltinGrantPath` Zod enum,
|
|
195
|
+
`BUILTIN_GRANT_PATH_META` descriptions.
|
|
196
|
+
- `create_grant_path_schema(consumer_paths?)`
|
|
197
|
+
→ `{GrantPath, grant_paths: ReadonlyMap}`. Same construction-time
|
|
198
|
+
guards as the credential-type schema. Pass the result into
|
|
199
|
+
`create_role_schema`'s optional `grant_paths` parameter to validate
|
|
200
|
+
every `RoleSpec.grant_paths` entry at construction time.
|
|
201
|
+
|
|
202
|
+
Drives downstream defaults:
|
|
203
|
+
|
|
204
|
+
- `admin_actions.grantable_roles` ⊇ `{role : 'admin' ∈ grant_paths}`.
|
|
205
|
+
- `self_service_role_actions` default eligibility ⊇
|
|
206
|
+
`{role : 'self_service' ∈ grant_paths}`.
|
|
123
207
|
|
|
124
208
|
### Role system (`role_schema.ts`)
|
|
125
209
|
|
|
210
|
+
`RoleSpec` is the structured per-role configuration that replaced the
|
|
211
|
+
flat `RoleOptions` shape (no `requires_daemon_token` / `web_grantable`
|
|
212
|
+
booleans). Each role declares the credential types its holders must
|
|
213
|
+
use, the scope kinds it applies to, and the grant paths through which
|
|
214
|
+
it can be granted; the factory validates every cross-axis field
|
|
215
|
+
against the corresponding open registries at construction time.
|
|
216
|
+
|
|
126
217
|
- `RoleName`: lowercase letters + underscores, no leading/trailing
|
|
127
218
|
underscore.
|
|
128
|
-
- `ROLE_KEEPER = 'keeper'`
|
|
129
|
-
|
|
130
|
-
- `
|
|
131
|
-
- `
|
|
132
|
-
|
|
133
|
-
- `
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
219
|
+
- `ROLE_KEEPER = 'keeper'` — bootstrap-only via daemon token; `grant_paths: ['bootstrap']`,
|
|
220
|
+
`required_credential_types: ['daemon_token']`.
|
|
221
|
+
- `ROLE_ADMIN = 'admin'` — admin-grantable; `grant_paths: ['admin']`.
|
|
222
|
+
- `BUILTIN_ROLES`, `BuiltinRole` (Zod enum), `BUILTIN_ROLE_SPECS_BY_NAME`
|
|
223
|
+
(`ReadonlyMap<string, RoleSpec>`) — not overridable by consumers.
|
|
224
|
+
- `RoleSpec`: `{name, description?, required_credential_types?, applicable_scope_kinds?, grant_paths?}`
|
|
225
|
+
— every cross-axis field is an open-registry string array. Empty
|
|
226
|
+
arrays carry meaning (`grant_paths: []` ⇒ role unreachable through
|
|
227
|
+
any registered path; `applicable_scope_kinds: []` ⇒ global only).
|
|
228
|
+
- `create_role_schema(consumer_roles, options?)` — call once at startup;
|
|
229
|
+
returns `{Role, role_specs}`. Construction-time guards: name regex,
|
|
230
|
+
duplicate detection, builtin-collision rejection, registry-membership
|
|
231
|
+
check on every `required_credential_types` / `applicable_scope_kinds` /
|
|
232
|
+
`grant_paths` entry when the corresponding registry is supplied via
|
|
233
|
+
`options.{credential_types, scope_kinds, grant_paths}`. Omitting a
|
|
234
|
+
registry skips its membership check (incremental adoption hatch).
|
|
235
|
+
- `role_has_grant_path(role_specs, role, path)` /
|
|
236
|
+
`list_roles_with_grant_path(role_specs, path)` — predicate /
|
|
237
|
+
filter helpers used by `admin_actions` and
|
|
238
|
+
`self_service_role_actions` to derive their default eligibility.
|
|
137
239
|
|
|
138
240
|
### Raw DDL (`ddl.ts`)
|
|
139
241
|
|
|
@@ -143,8 +245,13 @@ Separated from runtime types to isolate DDL concerns. Consumed by
|
|
|
143
245
|
- `ACCOUNT_SCHEMA` (plus `ACCOUNT_EMAIL_INDEX`, `ACCOUNT_USERNAME_CI_INDEX`
|
|
144
246
|
— both case-insensitive partial uniques)
|
|
145
247
|
- `ACTOR_SCHEMA`, `ACTOR_INDEX`
|
|
146
|
-
- `
|
|
147
|
-
which is replaced in v1 with the scope-aware
|
|
248
|
+
- `ROLE_GRANT_SCHEMA`, `ROLE_GRANT_INDEXES` — v0 has `role_grant_actor_role_active_unique`
|
|
249
|
+
which is replaced in v1 with the scope-aware
|
|
250
|
+
`role_grant_actor_role_scope_active_unique` keyed on
|
|
251
|
+
`(actor_id, role, COALESCE(scope_kind, 'GLOBAL'), COALESCE(scope_id, sentinel))`.
|
|
252
|
+
v1 also adds `scope_kind TEXT NULL` (paired-null with `scope_id` via
|
|
253
|
+
the `role_grant_scope_kind_paired` CHECK; idempotent DO-block guards
|
|
254
|
+
re-runs).
|
|
148
255
|
- `AUTH_SESSION_SCHEMA`, `AUTH_SESSION_INDEXES`
|
|
149
256
|
- `API_TOKEN_SCHEMA`, `API_TOKEN_INDEX`
|
|
150
257
|
- `BOOTSTRAP_LOCK_SCHEMA`, `BOOTSTRAP_LOCK_SEED` — seeded as `bootstrapped`
|
|
@@ -159,58 +266,102 @@ Separated from runtime types to isolate DDL concerns. Consumed by
|
|
|
159
266
|
|
|
160
267
|
#### Audit event types
|
|
161
268
|
|
|
162
|
-
`AUDIT_EVENT_TYPES` — 21 events covering auth +
|
|
163
|
-
settings mutations. Offer lifecycle: `
|
|
269
|
+
`AUDIT_EVENT_TYPES` — 21 events covering auth + role_grant + offer + invite +
|
|
270
|
+
settings mutations. Offer lifecycle: `role_grant_offer_create` / `_accept` /
|
|
164
271
|
`_decline` / `_retract` / `_expire` / `_supersede`. `AuditEventType` is the
|
|
165
272
|
Zod enum; `AuditOutcome` is `'success' | 'failure'`.
|
|
166
273
|
|
|
167
|
-
| Event type
|
|
168
|
-
|
|
|
169
|
-
| `login`
|
|
170
|
-
| `logout`
|
|
171
|
-
| `bootstrap`
|
|
172
|
-
| `signup`
|
|
173
|
-
| `password_change`
|
|
174
|
-
| `session_revoke`
|
|
175
|
-
| `session_revoke_all`
|
|
176
|
-
| `token_create`
|
|
177
|
-
| `token_revoke`
|
|
178
|
-
| `token_revoke_all`
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
181
|
-
| `
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `invite_create`
|
|
188
|
-
| `invite_delete`
|
|
189
|
-
| `app_settings_update`
|
|
274
|
+
| Event type |
|
|
275
|
+
| ---------------------------- |
|
|
276
|
+
| `login` |
|
|
277
|
+
| `logout` |
|
|
278
|
+
| `bootstrap` |
|
|
279
|
+
| `signup` |
|
|
280
|
+
| `password_change` |
|
|
281
|
+
| `session_revoke` |
|
|
282
|
+
| `session_revoke_all` |
|
|
283
|
+
| `token_create` |
|
|
284
|
+
| `token_revoke` |
|
|
285
|
+
| `token_revoke_all` |
|
|
286
|
+
| `role_grant_create` |
|
|
287
|
+
| `role_grant_revoke` |
|
|
288
|
+
| `role_grant_offer_create` |
|
|
289
|
+
| `role_grant_offer_accept` |
|
|
290
|
+
| `role_grant_offer_decline` |
|
|
291
|
+
| `role_grant_offer_retract` |
|
|
292
|
+
| `role_grant_offer_expire` |
|
|
293
|
+
| `role_grant_offer_supersede` |
|
|
294
|
+
| `invite_create` |
|
|
295
|
+
| `invite_delete` |
|
|
296
|
+
| `app_settings_update` |
|
|
190
297
|
|
|
191
298
|
#### Metadata schemas
|
|
192
299
|
|
|
193
300
|
- `AUDIT_METADATA_SCHEMAS` — per-type `z.looseObject`. Notable shapes:
|
|
194
|
-
- `
|
|
195
|
-
omit —
|
|
301
|
+
- `role_grant_create` — `scope_id`, optional `role_grant_id` (failed grants
|
|
302
|
+
omit — admin-grant-path denial never produces a row), optional
|
|
196
303
|
`source_offer_id`, optional `self_service` (set by
|
|
197
304
|
`self_service_role_actions.ts`; declared on the schema rather than
|
|
198
305
|
riding on `z.looseObject` so the field is part of the documented surface).
|
|
199
|
-
- `
|
|
306
|
+
- `role_grant_revoke` — `scope_id`, optional `reason`, optional
|
|
200
307
|
`self_service` (same self-service toggle).
|
|
201
|
-
- `
|
|
202
|
-
- `
|
|
203
|
-
plus `cause_id` (accepted offer id, revoked
|
|
308
|
+
- `role_grant_offer_create` — optional `offer_id` (failed creates omit).
|
|
309
|
+
- `role_grant_offer_supersede` — `reason: 'sibling_accepted' | 'role_grant_revoked' | 'scope_destroyed'`
|
|
310
|
+
plus `cause_id` (accepted offer id, revoked role_grant id, or destroyed
|
|
204
311
|
parent scope row id respectively). The `scope_destroyed` variant is
|
|
205
|
-
emitted by callers of `
|
|
312
|
+
emitted by callers of `query_role_grant_revoke_for_scope` when a polymorphic
|
|
206
313
|
parent scope row is deleted.
|
|
207
314
|
- `AuditLogEvent` (row); `AuditLogInput<T extends string = AuditEventType>`
|
|
208
315
|
(narrow metadata when `T` is builtin, generic record otherwise);
|
|
209
316
|
`AuditLogListOptions` (supports `since_seq` for SSE reconnection gap fill);
|
|
210
317
|
`AUDIT_LOG_DEFAULT_LIMIT = 50` (default page size, lives on the schema
|
|
211
318
|
side so client codegen can import it without dragging in the query layer).
|
|
319
|
+
`target_actor_id` lives parallel to `target_account_id` on both row
|
|
320
|
+
and input. **Rule** — `target_actor_id` is populated when the event
|
|
321
|
+
subject is bound to a specific actor. Concretely: `role_grant_revoke`
|
|
322
|
+
and `role_grant_create` (admin direct-grant, self-service toggle, and
|
|
323
|
+
in-tx accept all populate both target columns — the grantee is the
|
|
324
|
+
subject regardless of initiator), in-tx `role_grant_offer_accept` on
|
|
325
|
+
accept, and `role_grant_offer_decline` always populate both target
|
|
326
|
+
columns (decline joins `from_account_id` into the RETURNING so the
|
|
327
|
+
"both populated → same account" invariant holds uniformly).
|
|
328
|
+
Offer-shape events (`role_grant_offer_create`, `_expire`, `_retract`,
|
|
329
|
+
`_supersede`) populate `target_actor_id` when the offer was
|
|
330
|
+
actor-targeted at create time (`role_grant_offer.to_actor_id` set),
|
|
331
|
+
null when the offer was account-grain (any actor on
|
|
332
|
+
`to_account_id` may accept). Account-shape events (login, logout,
|
|
333
|
+
signup, bootstrap, password change, session/token revoke,
|
|
334
|
+
app_settings update, invite events) stay account-grain on both
|
|
335
|
+
`target_actor_id` **and** `actor_id` — the operation is performed
|
|
336
|
+
by the account, and a multi-actor user must be able to log out
|
|
337
|
+
(or change password, or revoke sessions) without first picking an
|
|
338
|
+
acting actor. Role-grant/admin/offer events keep recording the
|
|
339
|
+
initiator's actor in `actor_id`.
|
|
340
|
+
SSE/WS socket-close keys on `target_account_id ?? account_id`
|
|
341
|
+
(sessions stay account-grain at the routing layer even though
|
|
342
|
+
they bind to a specific actor at request-context resolution time —
|
|
343
|
+
see request_context.ts).
|
|
344
|
+
- **Actor-targetable offers** — `role_grant_offer.to_actor_id` is the
|
|
345
|
+
optional column that flips an offer from account-grain (null,
|
|
346
|
+
default) to actor-grain (non-null). `query_role_grant_offer_create`
|
|
347
|
+
validates the actor↔account binding in one SELECT and rejects with
|
|
348
|
+
`RoleGrantOfferActorAccountMismatchError` when the supplied actor isn't
|
|
349
|
+
on `to_account_id`. `query_accept_offer` rejects wrong-actor accepts
|
|
350
|
+
on actor-targeted offers with `RoleGrantOfferActorMismatchError` —
|
|
351
|
+
surfaced to RPC callers as `role_grant_offer_actor_mismatch`. Closes the
|
|
352
|
+
audit hole where offer-shape events left `target_actor_id` null even
|
|
353
|
+
when the recipient binding was known at offer time.
|
|
354
|
+
- **`AuditEmitter.emit_role_grant_target` method** — the canonical entry
|
|
355
|
+
point for role-grant-shape audit emissions. Takes
|
|
356
|
+
`(ctx, auth, {event_type, target_account_id, target_actor_id, metadata, outcome?})`
|
|
357
|
+
and lifts the `actor_id` / `account_id` / `ip` boilerplate that every
|
|
358
|
+
`role_grant_*` audit emit site repeats. Use this instead of
|
|
359
|
+
`deps.audit.emit` for any event populating one of the
|
|
360
|
+
`target_*_id` columns; reach for the lower-level `emit` only when the
|
|
361
|
+
event is non-role-grant-shape (e.g., `app_settings_update`, bootstrap,
|
|
362
|
+
signup).
|
|
212
363
|
- Client-safe: `AuditLogEventJson`, `AuditLogEventWithUsernamesJson`,
|
|
213
|
-
`
|
|
364
|
+
`RoleGrantHistoryEventJson`, `AdminSessionJson`.
|
|
214
365
|
- `get_audit_metadata(event)` type-narrows after checking `event_type`.
|
|
215
366
|
- DDL: `AUDIT_LOG_SCHEMA` (includes monotonically-increasing `seq SERIAL`
|
|
216
367
|
for cursor-based gap fill), `AUDIT_LOG_INDEXES`.
|
|
@@ -218,14 +369,15 @@ Zod enum; `AuditOutcome` is `'success' | 'failure'`.
|
|
|
218
369
|
builds an `AuditLogConfig` merging builtins with consumer event-type
|
|
219
370
|
strings keyed to a Zod schema (validates metadata) or `null` (registers
|
|
220
371
|
without validation). Pass the result to `create_app_backend({audit_log_config})`
|
|
221
|
-
— it
|
|
222
|
-
|
|
372
|
+
— it gets captured inside the bound `AppDeps.audit` emitter, and every
|
|
373
|
+
call to `audit.emit` validates against it (defaults to
|
|
223
374
|
`BUILTIN_AUDIT_LOG_CONFIG` when absent). `query_audit_log` still accepts
|
|
224
375
|
the trailing `config` positional arg for in-transaction emit sites that
|
|
225
|
-
|
|
226
|
-
format failures throw at construction. The DB
|
|
227
|
-
(no enum), so consumer types round-trip
|
|
228
|
-
`audit_log_list` RPC, and SSE identically to
|
|
376
|
+
hold a transaction-scoped DB only. Builtin collisions and
|
|
377
|
+
`AuditEventTypeName` format failures throw at construction. The DB
|
|
378
|
+
column is `TEXT NOT NULL` (no enum), so consumer types round-trip
|
|
379
|
+
through list queries, the `audit_log_list` RPC, and SSE identically to
|
|
380
|
+
builtins.
|
|
229
381
|
`AuditLogEvent.event_type` (row interface), `AuditLogEventJson.event_type`,
|
|
230
382
|
and the `audit_log_list` filter input are all `AuditEventTypeName`
|
|
231
383
|
(regex-validated string) — widened from the closed enum so consumer rows
|
|
@@ -244,67 +396,76 @@ Zod enum; `AuditOutcome` is `'success' | 'failure'`.
|
|
|
244
396
|
accidental mutation (bugs, test cross-contamination, cast escapes)
|
|
245
397
|
into loud TypeErrors — not a security boundary.
|
|
246
398
|
|
|
247
|
-
###
|
|
399
|
+
### Role grant offer (`role_grant_offer_schema.ts`)
|
|
248
400
|
|
|
249
|
-
The consentful-
|
|
401
|
+
The consentful-role-grants surface. Key constants:
|
|
250
402
|
|
|
251
|
-
- `
|
|
403
|
+
- `ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID = '00000000-…'` — all-zeros UUID used
|
|
252
404
|
inside `COALESCE(scope_id, sentinel)` in partial unique indexes to collapse
|
|
253
405
|
NULL scopes into a comparable value. Without this, Postgres's NULL-in-
|
|
254
406
|
unique-index quirk would allow duplicate global pending offers.
|
|
255
|
-
- `
|
|
256
|
-
|
|
407
|
+
- `ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN = 'GLOBAL'` — index-side token
|
|
408
|
+
for the global case in the partial unique indexes. Uppercase, so it
|
|
409
|
+
cannot collide with consumer-declared `ScopeKindName` values
|
|
410
|
+
(lowercase by regex). Never a column value — both null encodes
|
|
411
|
+
global at the row level.
|
|
412
|
+
- `ROLE_GRANT_OFFER_MESSAGE_LENGTH_MAX = 500`.
|
|
413
|
+
- `ROLE_GRANT_OFFER_DEFAULT_TTL_MS` = 30 days (GitHub org-invite parity).
|
|
257
414
|
|
|
258
415
|
DDL:
|
|
259
416
|
|
|
260
|
-
- `
|
|
417
|
+
- `ROLE_GRANT_OFFER_SCHEMA` carries four nullable terminal timestamps:
|
|
261
418
|
`accepted_at`, `declined_at`, `retracted_at`, **`superseded_at`** (fourth
|
|
262
|
-
terminal — obsoleted by sibling accept or revoke of the resulting
|
|
263
|
-
|
|
264
|
-
- `
|
|
265
|
-
- `
|
|
266
|
-
- `
|
|
267
|
-
- `
|
|
268
|
-
|
|
419
|
+
terminal — obsoleted by sibling accept or revoke of the resulting role_grant).
|
|
420
|
+
Four CHECK constraints:
|
|
421
|
+
- `role_grant_offer_single_terminal` — at most one terminal timestamp set.
|
|
422
|
+
- `role_grant_offer_role_grant_iff_accepted` — `(accepted_at IS NOT NULL) = (resulting_role_grant_id IS NOT NULL)`.
|
|
423
|
+
- `role_grant_offer_reason_iff_declined` — `decline_reason` only on declined rows.
|
|
424
|
+
- `role_grant_offer_scope_kind_paired` — `(scope_kind IS NULL) = (scope_id IS NULL)`
|
|
425
|
+
(both null = global, both non-null = scoped, mismatch rejected).
|
|
426
|
+
- `ROLE_GRANT_OFFER_PENDING_UNIQUE_INDEX` — partial unique on
|
|
427
|
+
`(to_account_id, role, COALESCE(scope_kind, 'GLOBAL'), COALESCE(scope_id, sentinel), from_actor_id)`
|
|
269
428
|
where all four terminal timestamps are null. Including `from_actor_id`
|
|
270
429
|
lets multiple grantors coexist (teacher A and B can both offer the same
|
|
271
430
|
student role). A same-grantor re-offer upserts the pending row. The
|
|
272
|
-
`ON CONFLICT` target in `
|
|
273
|
-
expression literally
|
|
274
|
-
|
|
431
|
+
`ON CONFLICT` target in `query_role_grant_offer_create` must match this
|
|
432
|
+
expression literally; the paired-null CHECK keeps the two COALESCE
|
|
433
|
+
expressions in lockstep so global rows collide identically whether the
|
|
434
|
+
scope columns are written or omitted.
|
|
435
|
+
- `ROLE_GRANT_OFFER_INBOX_INDEX` — `(to_account_id, expires_at)` partial on
|
|
275
436
|
pending rows, soonest-expiry first.
|
|
276
437
|
|
|
277
438
|
Types:
|
|
278
439
|
|
|
279
|
-
- `
|
|
280
|
-
via `actor` — carried so callers fan out `
|
|
440
|
+
- `RoleGrantOffer` (row), `SupersededOffer` (row + `from_account_id` joined
|
|
441
|
+
via `actor` — carried so callers fan out `role_grant_offer_supersede`
|
|
281
442
|
notifications without a second round trip).
|
|
282
|
-
- `
|
|
443
|
+
- `CreateRoleGrantOfferInput` (`expires_at` is required — query layer applies
|
|
283
444
|
no default).
|
|
284
|
-
- `
|
|
285
|
-
with `
|
|
445
|
+
- `RoleGrantOfferJson` (with `.meta({description})` on every field) paired
|
|
446
|
+
with `to_role_grant_offer_json(offer)`.
|
|
286
447
|
|
|
287
|
-
### WS notifications (`
|
|
448
|
+
### WS notifications (`role_grant_offer_notifications.ts`)
|
|
288
449
|
|
|
289
450
|
Six `RemoteNotificationActionSpec`s fan notifications to affected sockets:
|
|
290
451
|
|
|
291
|
-
| Method
|
|
292
|
-
|
|
|
293
|
-
| `
|
|
294
|
-
| `
|
|
295
|
-
| `
|
|
296
|
-
| `
|
|
297
|
-
| `
|
|
298
|
-
| `
|
|
452
|
+
| Method | Fires to | Payload |
|
|
453
|
+
| ---------------------------- | -------------------------------------- | ------------------------------------------------------------------------ |
|
|
454
|
+
| `role_grant_offer_received` | Recipient | `{offer: RoleGrantOfferJson}` |
|
|
455
|
+
| `role_grant_offer_retracted` | Recipient | `{offer: RoleGrantOfferJson}` |
|
|
456
|
+
| `role_grant_offer_accepted` | Grantor | `{offer: RoleGrantOfferJson}` |
|
|
457
|
+
| `role_grant_offer_declined` | Grantor | `{offer: RoleGrantOfferJson}` (decline reason on `offer.decline_reason`) |
|
|
458
|
+
| `role_grant_offer_supersede` | Grantor (sibling / revoked-role_grant) | `{offer, reason: 'sibling_accepted' \| 'role_grant_revoked', cause_id}` |
|
|
459
|
+
| `role_grant_revoke` | Revokee | `{role_grant_id, role, scope_id, reason?}` |
|
|
299
460
|
|
|
300
|
-
Method constants: `
|
|
461
|
+
Method constants: `ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD`,
|
|
301
462
|
`_RETRACTED_`, `_ACCEPTED_`, `_DECLINED_`, `_SUPERSEDE_`,
|
|
302
|
-
`
|
|
303
|
-
exports: `
|
|
304
|
-
`_DeclinedParams`, `_SupersedeParams`, `
|
|
305
|
-
builders: `
|
|
463
|
+
`ROLE_GRANT_REVOKE_NOTIFICATION_METHOD`. Zod params schemas with inferred type
|
|
464
|
+
exports: `RoleGrantOfferReceivedParams`, `_RetractedParams`, `_AcceptedParams`,
|
|
465
|
+
`_DeclinedParams`, `_SupersedeParams`, `RoleGrantRevokeParams`. Notification
|
|
466
|
+
builders: `build_role_grant_offer_received_notification(params)` etc.
|
|
306
467
|
|
|
307
|
-
`
|
|
468
|
+
`ROLE_GRANT_OFFER_NOTIFICATION_SPECS: Array<EventSpec>` — pass to
|
|
308
469
|
`create_app_server`'s `event_specs` so the attack surface reflects them
|
|
309
470
|
and DEV-mode `create_validated_broadcaster` catches payload drift.
|
|
310
471
|
|
|
@@ -313,7 +474,7 @@ and DEV-mode `create_validated_broadcaster` catches payload drift.
|
|
|
313
474
|
structurally satisfies it (its signature accepts the broader
|
|
314
475
|
`JsonrpcMessageFromServerToClient`, contravariantly compatible). Target
|
|
315
476
|
account travels via the send argument, not the payload — `revoked_by` is
|
|
316
|
-
deliberately not in the `
|
|
477
|
+
deliberately not in the `role_grant_revoke` payload (the revokee doesn't need
|
|
317
478
|
to learn the admin's identity).
|
|
318
479
|
|
|
319
480
|
## Queries
|
|
@@ -331,106 +492,131 @@ CRUD + listing:
|
|
|
331
492
|
indexes).
|
|
332
493
|
- `query_account_by_username_or_email(deps, input)` — if `@` in input, tries
|
|
333
494
|
email first; else username first. Single login field accepting either.
|
|
334
|
-
- `query_update_account_password
|
|
335
|
-
|
|
495
|
+
- `query_update_account_password(deps, id, new_hash, updated_by, expected_hash) → boolean` —
|
|
496
|
+
conditional UPDATE keyed on `password_hash = expected_hash`; closes the
|
|
497
|
+
verify-write race where two concurrent password changes both verify
|
|
498
|
+
against the pre-update hash (loaded by the auth phase outside the
|
|
499
|
+
txn). Returns `false` when the racer already moved the row.
|
|
500
|
+
- `query_delete_account` — cascades to actors, role_grants, sessions, tokens.
|
|
336
501
|
- `query_account_has_any` — used by bootstrap for belt-and-suspenders check.
|
|
337
|
-
- `
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
502
|
+
- `query_actors_by_account` — list every actor on an account, ordered
|
|
503
|
+
by `created_at`. Used by `resolve_acting_actor` to pick the unique
|
|
504
|
+
actor on single-actor accounts or surface `actor_required` when the
|
|
505
|
+
account has multiple actors.
|
|
506
|
+
- `query_actor_by_id` — direct lookup by id; preferred when the caller
|
|
507
|
+
already has an actor id in scope.
|
|
508
|
+
- `query_admin_account_list(deps, options?)` — composes accounts + actors +
|
|
509
|
+
active role_grants + pending inbound offers. Paged (`limit` defaults to
|
|
510
|
+
`ADMIN_ACCOUNT_LIST_DEFAULT_LIMIT`; pass `limit: null` for unbounded
|
|
511
|
+
internal use). Two round-trips: 1 (account page) → 3 parallel scoped to
|
|
512
|
+
`account_ids`. The role_grants and offers queries push the page bound
|
|
513
|
+
through to the DB via `actor_id IN (SELECT id FROM actor WHERE
|
|
514
|
+
account_id = ANY(...))` so `actor.id`s never round-trip back to the
|
|
515
|
+
application. Pending offers exclude `message` on purpose (cross-admin
|
|
516
|
+
visibility). Returns `Array<AdminAccountEntryJson>`, sorted by
|
|
517
|
+
`created_at`.
|
|
518
|
+
|
|
519
|
+
### `role_grant_queries.ts`
|
|
520
|
+
|
|
521
|
+
- `query_create_role_grant` — idempotent; `ON CONFLICT` target and fallback
|
|
346
522
|
`SELECT` both use `COALESCE(scope_id, sentinel)`. The fallback `SELECT`
|
|
347
523
|
uses `IS NOT DISTINCT FROM` (plain `=` would miss the NULL-scope conflict
|
|
348
524
|
case).
|
|
349
|
-
- `
|
|
350
|
-
actor-scoped read, so IDOR protection is consistent with revoke.
|
|
351
|
-
`{role}`
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
525
|
+
- `query_role_grant_find_active_role_for_actor(deps, role_grant_id, actor_id)` —
|
|
526
|
+
actor-scoped read, so IDOR protection is consistent with revoke.
|
|
527
|
+
Returns `{role, account_id}` (the actor's `account_id` joined in) or
|
|
528
|
+
`null`. The `account_id` flows into the audit envelope's
|
|
529
|
+
`target_account_id` and the SSE/WS socket-close fan-out target —
|
|
530
|
+
collapsing what used to be a second `query_actor_by_id` round-trip in
|
|
531
|
+
the revoke handler into one read closes the small TOCTOU window
|
|
532
|
+
where the actor row could be deleted between the IDOR check and the
|
|
533
|
+
actor lookup.
|
|
534
|
+
- **`query_revoke_role_grant(deps, role_grant_id, actor_id, revoked_by, reason?)`** —
|
|
535
|
+
actor-scoped IDOR guard (returns `null` if the role_grant belongs to a
|
|
536
|
+
different actor). Supersedes pending offers for the revoked role_grant's
|
|
355
537
|
`(to_account, role, scope)` in the **same transaction** via a CTE that
|
|
356
538
|
joins `actor` to surface each sibling's `from_account_id`. Returns
|
|
357
|
-
`
|
|
539
|
+
`RevokeRoleGrantResult = {id, role, scope_id, superseded_offers}`. Closes the
|
|
358
540
|
"accept a pre-revoke offer to bypass the revoke" path — the stale offer
|
|
359
541
|
becomes terminal at revoke time.
|
|
360
|
-
- `
|
|
361
|
-
- `
|
|
542
|
+
- `query_role_grant_find_active_for_actor`, `query_role_grant_list_for_actor`.
|
|
543
|
+
- `query_role_grant_has_role(deps, actor_id, role, scope_id?)` — `IS NOT DISTINCT FROM`
|
|
362
544
|
handles the NULL case. Omitted scope matches `scope_id IS NULL` (pre-scope
|
|
363
545
|
callers keep semantics). Use only when checking an arbitrary `actor_id`
|
|
364
546
|
that isn't the request actor (e.g., post-mutation verification, scripts,
|
|
365
547
|
audit-time checks). For the request actor, prefer `has_scoped_role` /
|
|
366
|
-
`has_any_scoped_role` on the in-memory `auth.
|
|
367
|
-
- `
|
|
368
|
-
|
|
548
|
+
`has_any_scoped_role` on the in-memory `auth.role_grants` snapshot.
|
|
549
|
+
- `query_role_grant_find_account_id_for_role(deps, role)` — joins
|
|
550
|
+
role_grant → actor → account, returns first match. Used by daemon token
|
|
369
551
|
middleware to resolve the keeper account.
|
|
370
|
-
- `
|
|
371
|
-
active
|
|
552
|
+
- `query_role_grant_revoke_role(deps, actor_id, role, ...)` — revokes every
|
|
553
|
+
active role_grant for `(actor, role)` across all scopes and supersedes all
|
|
372
554
|
matching pending offers. Returns `RevokeRoleResult = {revoked, superseded_offers}`.
|
|
373
|
-
- **`
|
|
555
|
+
- **`query_role_grant_revoke_for_scope(deps, scope_id, revoked_by, reason?)`** —
|
|
374
556
|
parent-scope cascade for polymorphic `scope_id` consumers. Revokes every
|
|
375
|
-
active
|
|
557
|
+
active role_grant at `scope_id` (role-agnostic) and supersedes every pending
|
|
376
558
|
offer at `scope_id` (tuple-matched and orphan, undifferentiated) in the
|
|
377
559
|
caller's transaction. Returns `RevokeForScopeResult = {revoked, superseded_offers}`
|
|
378
|
-
— `revoked` carries `
|
|
379
|
-
`
|
|
380
|
-
`
|
|
560
|
+
— `revoked` carries both `actor_id` (drives `target_actor_id` audit
|
|
561
|
+
envelopes) and `account_id` (drives `target_account_id` for socket-close
|
|
562
|
+
fan-out); `superseded_offers` carries `from_account_id`. Caller emits
|
|
563
|
+
`role_grant_offer_supersede` audits with `reason: 'scope_destroyed'` and
|
|
381
564
|
`cause_id: <destroyed scope row id>` per superseded offer (the cause is
|
|
382
|
-
the scope deletion, not any individual
|
|
383
|
-
consumer's parent-row delete handler when `
|
|
384
|
-
`
|
|
565
|
+
the scope deletion, not any individual role_grant revoke). Use from a
|
|
566
|
+
consumer's parent-row delete handler when `role_grant.scope_id` /
|
|
567
|
+
`role_grant_offer.scope_id` reference rows in a polymorphic table the
|
|
385
568
|
consumer is about to drop.
|
|
386
569
|
|
|
387
|
-
### `
|
|
570
|
+
### `role_grant_offer_queries.ts`
|
|
388
571
|
|
|
389
572
|
Error classes (all extend `Error` with stable `.name` — never use
|
|
390
573
|
`instanceof` against plain messages):
|
|
391
574
|
|
|
392
|
-
- `
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
-
|
|
575
|
+
- `RoleGrantOfferSelfTargetError` — grantor offered themselves. Enforced
|
|
576
|
+
via a single SELECT on the grantor's `actor.account_id` in
|
|
577
|
+
`query_role_grant_offer_create` (resolving from the grantor side keeps
|
|
578
|
+
the check multi-actor-correct — the grantor → account binding stays
|
|
579
|
+
1:1 by definition of `actor`, while the recipient account may host
|
|
580
|
+
many actors under multi-actor).
|
|
581
|
+
- `RoleGrantOfferAlreadyTerminalError` — offer exists for the caller but is
|
|
396
582
|
accepted / declined / retracted / superseded.
|
|
397
|
-
- `
|
|
583
|
+
- `RoleGrantOfferExpiredError` — pending but past `expires_at` (distinct from
|
|
398
584
|
terminal; different user-facing story: "ask the grantor to re-send").
|
|
399
|
-
- `
|
|
585
|
+
- `RoleGrantOfferNotFoundError` — not found or belongs to a different recipient
|
|
400
586
|
(standard 404-over-403 IDOR mask; callers never reveal which).
|
|
401
587
|
|
|
402
588
|
Queries:
|
|
403
589
|
|
|
404
|
-
- `
|
|
590
|
+
- `query_role_grant_offer_create` — INSERT with upsert-on-pending keyed by
|
|
405
591
|
`(to_account, role, scope, from_actor)`. Same-grantor re-offer refreshes
|
|
406
592
|
`message` + `expires_at` only. A terminal-state row with the same tuple
|
|
407
593
|
does not block a fresh INSERT.
|
|
408
|
-
- `
|
|
594
|
+
- `query_role_grant_offer_decline(deps, id, to_account_id, reason)` — IDOR
|
|
409
595
|
guarded by `to_account_id`. `resolve_terminal_or_missing` helper
|
|
410
596
|
distinguishes "not found / different recipient" from "already terminal".
|
|
411
|
-
- `
|
|
597
|
+
- `query_role_grant_offer_retract(deps, id, from_actor_id)` — IDOR guarded by
|
|
412
598
|
grantor actor.
|
|
413
|
-
- `
|
|
599
|
+
- `query_role_grant_offer_list(deps, to_account_id)` — pending + non-expired +
|
|
414
600
|
non-superseded, soonest expiry first.
|
|
415
|
-
- `
|
|
601
|
+
- `query_role_grant_offer_history_for_account(deps, account_id, limit?, offset?)` —
|
|
416
602
|
both directions (recipient or grantor), includes terminal rows, newest
|
|
417
603
|
first.
|
|
418
|
-
- `
|
|
419
|
-
- `
|
|
420
|
-
`expires_at`; the caller emits `
|
|
604
|
+
- `query_role_grant_offer_find_pending`.
|
|
605
|
+
- `query_role_grant_offer_sweep_expired` — returns pending offers past
|
|
606
|
+
`expires_at`; the caller emits `role_grant_offer_expire` audit events
|
|
421
607
|
per-row (no tombstone — caller is responsible for idempotency).
|
|
422
608
|
- **`query_accept_offer(deps, input)`** — atomic, must run inside a
|
|
423
609
|
transaction. Row-locks with `SELECT ... FOR UPDATE` (concurrent callers
|
|
424
610
|
block until commit / rollback, then branch idempotently). Inserts the
|
|
425
|
-
|
|
426
|
-
`accepted_at` + `
|
|
427
|
-
`
|
|
611
|
+
role_grant with normal idempotency (`ON CONFLICT DO NOTHING`), stamps
|
|
612
|
+
`accepted_at` + `resulting_role_grant_id` in one UPDATE (satisfying the
|
|
613
|
+
`role_grant_offer_role_grant_iff_accepted` CHECK), supersedes sibling pending
|
|
428
614
|
offers for `(to_account, role, scope)` via CTE joined to `actor` for
|
|
429
|
-
grantor `account_id`, and emits `
|
|
430
|
-
- one `
|
|
431
|
-
pre-existing
|
|
432
|
-
/ `audit_events`. Error map: `
|
|
433
|
-
`
|
|
615
|
+
grantor `account_id`, and emits `role_grant_offer_accept` + `role_grant_create`
|
|
616
|
+
- one `role_grant_offer_supersede` per sibling. On race, returns the
|
|
617
|
+
pre-existing role_grant with `created: false` and empty `superseded_offers`
|
|
618
|
+
/ `audit_events`. Error map: `RoleGrantOfferNotFoundError`,
|
|
619
|
+
`RoleGrantOfferAlreadyTerminalError`, `RoleGrantOfferExpiredError`. Sibling
|
|
434
620
|
supersede is what forecloses the "accept a pre-revoke sibling later to
|
|
435
621
|
get the role back" path.
|
|
436
622
|
|
|
@@ -486,8 +672,15 @@ Server-side sessions, keyed by blake3 hash of the session token:
|
|
|
486
672
|
- `query_invite_find_unclaimed_match(deps, email, username)` — three scoping
|
|
487
673
|
modes: email-only invite needs signup-email match; username-only invite
|
|
488
674
|
needs signup-username match; both-field invite requires both to match.
|
|
489
|
-
-
|
|
490
|
-
unclaimed. Return is a boolean for race-detection.
|
|
675
|
+
- **`query_invite_claim_unscoped`** — sets `claimed_by` + `claimed_at` only
|
|
676
|
+
if still unclaimed. Return is a boolean for race-detection. The
|
|
677
|
+
`_unscoped` suffix is the safety signal — the SQL only checks the row
|
|
678
|
+
state, not whether the claiming account's email/username matches the
|
|
679
|
+
invite. Production scoping is enforced upstream in `signup_routes.ts`
|
|
680
|
+
via `query_invite_find_unclaimed_match`. Mirrors the
|
|
681
|
+
`query_session_revoke_by_hash_unscoped` precedent — there is no scoped
|
|
682
|
+
sibling because scoping is provided by a separate find query, not an
|
|
683
|
+
alternate variant of this query.
|
|
491
684
|
- `query_invite_list_all`, `query_invite_list_all_with_usernames` (joins to
|
|
492
685
|
`actor` for `created_by_username` and `account` for `claimed_by_username`).
|
|
493
686
|
- `query_invite_delete_unclaimed` — IDOR not a concern (admin-only surface),
|
|
@@ -515,36 +708,80 @@ run'` if the seed somehow missed (defensive — migrations always seed).
|
|
|
515
708
|
- `query_audit_log_list(deps, options?)` — supports `event_type`,
|
|
516
709
|
`event_type_in`, `account_id` (matches `account_id` OR
|
|
517
710
|
`target_account_id`), `outcome`, `since_seq`, `limit`, `offset`.
|
|
518
|
-
|
|
519
|
-
-
|
|
520
|
-
|
|
711
|
+
`target_actor_id` filtering is not yet exposed; will land alongside
|
|
712
|
+
the admin-viewer's actor-grain forensics pass.
|
|
713
|
+
- `query_audit_log_list_with_usernames` — joins twice to `account`
|
|
714
|
+
(chains `target_account_id` for the `target_username` field).
|
|
715
|
+
`target_actor_id` is on the row but not currently joined to actor
|
|
716
|
+
for a name; the admin viewer will resolve via `actor_lookup` /
|
|
717
|
+
`actor.name` when the actor-grain forensics pass lands.
|
|
718
|
+
- `query_audit_log_list_role_grant_history` (filters to `role_grant_create` / `role_grant_revoke`).
|
|
521
719
|
- `query_audit_log_cleanup_before`.
|
|
522
|
-
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
720
|
+
- **Audit fan-out runs through `AppDeps.audit`** (the bound emitter built
|
|
721
|
+
by `create_audit_emitter` at backend assembly — see §`audit_emitter.ts`).
|
|
722
|
+
`audit.emit(ctx, input)` writes via the captured pool, so audit entries
|
|
723
|
+
persist even when the request transaction rolls back. The emitter
|
|
724
|
+
closes over `on_audit_event` + `audit_log_config` so handlers can never
|
|
725
|
+
silently fall back to the builtin config or a stale callback. Write
|
|
726
|
+
failures and listener-callback failures are logged separately. Pushes
|
|
727
|
+
onto `ctx.pending_effects` for test flushing.
|
|
728
|
+
|
|
729
|
+
### `audit_emitter.ts`
|
|
730
|
+
|
|
731
|
+
`AuditEmitter` is the bound capability that lives on `AppDeps.audit`,
|
|
732
|
+
built once at `create_app_backend` time.
|
|
733
|
+
|
|
734
|
+
Four methods:
|
|
735
|
+
|
|
736
|
+
- `emit(ctx, input)` — fire-and-forget pool write. Pushes the in-flight
|
|
737
|
+
promise onto `ctx.pending_effects`; errors logged, never thrown.
|
|
738
|
+
Returns `void` (the promise handle is already on `pending_effects`).
|
|
739
|
+
- `emit_role_grant_target(ctx, auth, input)` — wrapper that lifts
|
|
740
|
+
`actor_id` / `account_id` / `ip` boilerplate. Use for any event
|
|
741
|
+
populating one of the `target_*_id` columns; reach for `emit` only on
|
|
742
|
+
non-role-grant-shape events (`app_settings_update`, bootstrap, signup).
|
|
743
|
+
- `emit_pool(input)` — awaitable pool write for code paths without a
|
|
744
|
+
`pending_effects` queue (cleanup sweeps, ad-hoc maintenance scripts).
|
|
745
|
+
Same write-then-notify semantics as `emit`; errors logged + swallowed.
|
|
746
|
+
- `notify(event)` — fan out an already-written audit row to the listener
|
|
747
|
+
chain. Used by `query_accept_offer`'s in-transaction audit batch (see
|
|
748
|
+
the role-grant-offer accept handler) — the row is already in the DB,
|
|
749
|
+
this just walks the chain.
|
|
750
|
+
|
|
751
|
+
Per-call `ctx` shape:
|
|
752
|
+
|
|
753
|
+
- `emit` requires `{pending_effects: Array<Promise<void>>}` — the eager
|
|
754
|
+
queue only. Both `RouteContext` and `ActionContext` satisfy this
|
|
755
|
+
structurally; `audit.emit` pushes its in-flight pool-write promise
|
|
756
|
+
onto the eager queue. See `../http/CLAUDE.md` §Pending Effects for
|
|
757
|
+
the eager / deferred split.
|
|
758
|
+
- `emit_role_grant_target` adds `client_ip: string` (also on `ActionContext`;
|
|
759
|
+
REST handlers pass `{pending_effects, client_ip: get_client_ip(c)}`).
|
|
760
|
+
|
|
761
|
+
`on_event_chain` is the mutable subscriber list. `create_app_server`
|
|
762
|
+
appends `audit_sse.on_audit_event` here when `audit_log_sse` is enabled,
|
|
763
|
+
without rebuilding `AppDeps`.
|
|
533
764
|
|
|
534
765
|
### `migrations.ts`
|
|
535
766
|
|
|
536
|
-
- `AUTH_MIGRATION_NAMESPACE = 'fuz_auth'`, `AUTH_MIGRATION_NS` (pre-composed).
|
|
767
|
+
- `AUTH_MIGRATION_NAMESPACE = 'fuz_auth'`, `AUTH_MIGRATION_NS` (pre-composed), `RESERVED_MIGRATION_NAMESPACES: ReadonlyArray<string>` (membership list `create_app_backend` rejects on; consumer-discoverable instead of probing the runtime throw).
|
|
537
768
|
- `AUTH_MIGRATIONS`:
|
|
538
769
|
- **v0 `full_auth_schema`** — every table + index + seed for the v1
|
|
539
|
-
identity system (account, actor,
|
|
770
|
+
identity system (account, actor, role_grant, auth_session, api_token,
|
|
540
771
|
audit_log, bootstrap_lock, invite, app_settings). All
|
|
541
772
|
`IF NOT EXISTS` — idempotent replay.
|
|
542
|
-
- **v1 `
|
|
543
|
-
plus its two partial indexes; adds `
|
|
544
|
-
`
|
|
545
|
-
`
|
|
546
|
-
`
|
|
547
|
-
`
|
|
773
|
+
- **v1 `role_grant_offer_and_scoped_role_grants`** — adds `role_grant_offer` table
|
|
774
|
+
plus its two partial indexes; adds `role_grant.scope_id` /
|
|
775
|
+
`role_grant.scope_kind` / `role_grant.source_offer_id` /
|
|
776
|
+
`role_grant.revoked_reason`; installs the
|
|
777
|
+
`role_grant_scope_kind_paired` CHECK (DO-block guarded for re-runs
|
|
778
|
+
since Postgres has no `ADD CONSTRAINT IF NOT EXISTS` for CHECKs);
|
|
779
|
+
drops `role_grant_actor_role_active_unique` (and the prior
|
|
780
|
+
`role_grant_actor_role_scope_active_unique` if present) and installs the
|
|
781
|
+
scope-kind-aware variant keyed on
|
|
782
|
+
`(actor_id, role, COALESCE(scope_kind, 'GLOBAL'), COALESCE(scope_id, sentinel))`.
|
|
783
|
+
`role_grant_offer` is created with `scope_kind` already in the CREATE
|
|
784
|
+
TABLE (its CHECK + index are inline, not ALTERed).
|
|
548
785
|
- Forward-only (no down). Migrations are `{name, up}` objects; the name
|
|
549
786
|
surfaces in error messages.
|
|
550
787
|
|
|
@@ -566,11 +803,20 @@ by `sequence`, then enforces:
|
|
|
566
803
|
3. **Run the pending tail** (`code[applied.length..]`) inside a single
|
|
567
804
|
chain transaction; each `INSERT` uses `sequence = max(sequence) + 1`.
|
|
568
805
|
|
|
569
|
-
**
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
806
|
+
**Schema is not stabilized yet — append-only is NOT the rule today.**
|
|
807
|
+
While fuz_app is pre-stable, migration bodies, names, and positions can
|
|
808
|
+
change freely between versions and consumers upgrading across a schema
|
|
809
|
+
change are expected to drop and re-bootstrap their dev/test databases.
|
|
810
|
+
**No consumer has a stable production DB at the time of writing** —
|
|
811
|
+
vissiones, zap, mageguild, undying, and fuz_template are all dev-mode
|
|
812
|
+
only. The pre-stable contract assumes this; once a consumer ships a
|
|
813
|
+
production DB, the upgrade story changes shape (operator-side
|
|
814
|
+
migrations, double-emit windows, etc.) and the schema-stability
|
|
815
|
+
declaration becomes load-bearing. Bias toward editing existing
|
|
816
|
+
migration entries rather than appending patch migrations until that
|
|
817
|
+
declaration lands. Once the schema is declared stable, a hard
|
|
818
|
+
append-only-after-publish rule will apply (with the cliff called out in
|
|
819
|
+
that release's notes).
|
|
574
820
|
|
|
575
821
|
`MigrationError` is the only error class thrown from `run_migrations` /
|
|
576
822
|
`baseline`; branch on `.kind` (never on message text). Kinds:
|
|
@@ -637,47 +883,140 @@ consciously violate the contract.
|
|
|
637
883
|
|
|
638
884
|
## Middleware
|
|
639
885
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
**
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
886
|
+
See the root `../../../CLAUDE.md` §Middleware Ordering for the canonical
|
|
887
|
+
assembly order. Two-phase identity:
|
|
888
|
+
|
|
889
|
+
- **Authentication** runs in middleware (session / bearer / daemon
|
|
890
|
+
token). Sets `c.var.account_id` + `CREDENTIAL_TYPE_KEY` on a valid
|
|
891
|
+
credential. Account-only — never loads actor or role_grants, never
|
|
892
|
+
populates `REQUEST_CONTEXT_KEY`. **Production-middleware invariant**:
|
|
893
|
+
no production middleware on the auth path (session / bearer / daemon
|
|
894
|
+
token) populates `REQUEST_CONTEXT_KEY`; identity-related context vars
|
|
895
|
+
it does set are `ACCOUNT_ID_KEY`, `CREDENTIAL_TYPE_KEY`, and (for
|
|
896
|
+
sessions / bearer) `AUTH_SESSION_TOKEN_HASH_KEY` /
|
|
897
|
+
`AUTH_API_TOKEN_ID_KEY`. Other middleware (proxy, app server,
|
|
898
|
+
session-cookie parser) sets unrelated vars like `client_ip`,
|
|
899
|
+
`pending_effects`, and the session-token slot keyed by
|
|
900
|
+
`session_options.context_key` (default `auth_session_id`) — those
|
|
901
|
+
are out of scope for this invariant. Test harnesses pre-populate
|
|
902
|
+
`REQUEST_CONTEXT_KEY` + `TEST_CONTEXT_PRESET_KEY` to bypass DB-backed
|
|
903
|
+
actor resolution; production code that consults
|
|
904
|
+
`REQUEST_CONTEXT_KEY` is reading test escape-hatch state, never live
|
|
905
|
+
middleware output.
|
|
906
|
+
- **Authorization** runs after input validation (matches the dispatcher's
|
|
907
|
+
401 → 400 → 403 order so unauthenticated callers don't leak
|
|
908
|
+
`invalid_params` for methods with required input, and the authorization
|
|
909
|
+
phase reads `acting` as a typed Zod field rather than the raw body).
|
|
910
|
+
When the route's input declares `acting?: ActingActor` or its auth
|
|
911
|
+
requires role_grants (`role` / `credential_types`), the authorization
|
|
912
|
+
phase calls `resolve_acting_actor` over the validated `acting` value
|
|
913
|
+
and builds the actor-bound `RequestContext`. Account-grain routes
|
|
914
|
+
skip resolution and run with `RequestContext.actor: null`.
|
|
915
|
+
`apply_authorization_phase` is pure data — it takes
|
|
916
|
+
`account_id: string | null` and returns `AuthorizationResult`
|
|
917
|
+
(`{ok: true, request_context: RequestContext | null} | {ok: false, status, body}`)
|
|
918
|
+
without touching the Hono context.
|
|
919
|
+
Public actions and the unauthenticated-optional axis collapse to
|
|
920
|
+
`request_context: null`; resolved actor / account-only contexts set it
|
|
921
|
+
non-null. The REST wrapper (`create_fuz_authorization_handler`) sets
|
|
922
|
+
`REQUEST_CONTEXT_KEY` when `request_context !== null` for downstream
|
|
923
|
+
`require_role` / `require_credential_types`; the HTTP RPC and WS
|
|
924
|
+
dispatchers consume the resolved context directly via `perform_action`.
|
|
925
|
+
Resolution failures surface as `{ok: false, status, body}` — the auth
|
|
926
|
+
domain stops short of constructing a `Response` so each transport
|
|
927
|
+
binds the same failure to its wire shape: REST emits
|
|
928
|
+
`c.json(body, status)`; the WS upgrade does the same; the RPC + WS
|
|
929
|
+
dispatchers fold it into a JSON-RPC envelope inside `perform_action`
|
|
930
|
+
(`{jsonrpc, id, error: {code, message, data}}`) with `error.message`
|
|
931
|
+
carrying the reason string and `error.data: {reason, ...rest}`
|
|
932
|
+
flattening any diagnostic fields (e.g. `available[]` for
|
|
933
|
+
`actor_required`). The 500 reasons stay distinct in `body.error`:
|
|
934
|
+
`no_actors_on_account` (signup invariant violation —
|
|
935
|
+
`resolve_acting_actor` enumerated zero actors); `account_vanished`
|
|
936
|
+
(torn-read race — `build_request_context` / `build_account_context`
|
|
937
|
+
returned null after a successful resolve, meaning the account or
|
|
938
|
+
actor row was deleted between credential validation and the
|
|
939
|
+
follow-up read). The named per-error shape `AuthorizationFailureBody`
|
|
940
|
+
is still exported for callers that want to bind the failure body
|
|
941
|
+
by type. See the root `../../../CLAUDE.md` § Cleanest architecture
|
|
942
|
+
takes priority for the rationale.
|
|
943
|
+
|
|
944
|
+
Session parsing is separate from auth enforcement — login / bootstrap
|
|
945
|
+
participate in cookie refresh without being blocked. `require_auth`,
|
|
946
|
+
`require_role(roles)`, and `require_credential_types(types)` are the
|
|
947
|
+
gates; the keeper case composes the credential-type gate with the role
|
|
948
|
+
gate (no dedicated `require_keeper` helper — see `request_context.ts`).
|
|
648
949
|
|
|
649
950
|
### `request_context.ts`
|
|
650
951
|
|
|
651
|
-
- `RequestContext = {account, actor,
|
|
952
|
+
- `RequestContext = {account, actor: Actor | null, role_grants}`. `actor`
|
|
953
|
+
is null on account-grain routes (no `acting`, no role_grant-requiring
|
|
954
|
+
auth); `role_grants` is empty in that case.
|
|
652
955
|
- `REQUEST_CONTEXT_KEY` — Hono context variable name.
|
|
653
956
|
- **`AUTH_SESSION_TOKEN_HASH_KEY`** — holds the blake3 session hash. Set on
|
|
654
957
|
successful session lookup; `null` for unauthenticated or non-session
|
|
655
958
|
credentials. Exposed so SSE endpoints can scope per-session resource
|
|
656
959
|
identity (the audit-log SSE uses this to close only the revoked session's
|
|
657
960
|
stream on `session_revoke`).
|
|
658
|
-
- `get_request_context(c)`, `require_request_context(c)` (throws on
|
|
659
|
-
—
|
|
660
|
-
- **In-memory
|
|
961
|
+
- `get_request_context(c)`, `require_request_context(c)` (throws on
|
|
962
|
+
misuse — handler ran without authorization phase wiring).
|
|
963
|
+
- **In-memory role_grant predicates** — `has_role(ctx, role, now?)`,
|
|
661
964
|
`has_scoped_role(ctx, role, scope_id, now?)`,
|
|
662
965
|
`has_any_scoped_role(ctx, roles, scope_id, now?)`. All three take
|
|
663
|
-
`RequestContext | null`
|
|
664
|
-
`
|
|
665
|
-
|
|
666
|
-
`
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
- `
|
|
674
|
-
|
|
966
|
+
`RequestContext | null` and return `false` for null ctx and for
|
|
967
|
+
account-grain ctx (`actor: null`, empty `role_grants`); they drop into
|
|
968
|
+
public (`{account: 'none', actor: 'none'}`) and account-grain
|
|
969
|
+
(`{account: 'required', actor: 'none'}`) handlers without a manual
|
|
970
|
+
narrow.
|
|
971
|
+
`scope_id === null` matches global role_grants only; UUID matches that
|
|
972
|
+
exact scope. Empty `roles` short-circuits `has_any_scoped_role` to
|
|
973
|
+
`false`. Decide-time predicates only — the predicate / mutation
|
|
974
|
+
race window is the same as the SQL `query_role_grant_has_role` style and
|
|
975
|
+
only a transactional re-check inside the UPDATE/INSERT closes it.
|
|
976
|
+
- `build_request_context(deps, account_id, actor_id)` — loads
|
|
977
|
+
`account` + the named `actor` + active role_grants. Verifies
|
|
978
|
+
`actor.account_id === account.id`; returns `null` when the account
|
|
979
|
+
or actor is missing, or when they don't bind to each other. Called
|
|
980
|
+
by the authorization phase after `resolve_acting_actor` succeeds —
|
|
981
|
+
a null return there is a torn read (account/actor deleted mid-request)
|
|
982
|
+
rather than the missing-actor invariant `resolve_acting_actor` would
|
|
983
|
+
have caught upstream, so the phase surfaces `ERROR_ACCOUNT_VANISHED`
|
|
984
|
+
on null. Not called from middleware.
|
|
985
|
+
- `resolve_acting_actor(deps, account_id, acting_actor_id)` — uniform
|
|
986
|
+
resolver. Resolves to `{ok: true, actor_id}` for 1 actor (any
|
|
987
|
+
`acting`) or matching supplied id; `actor_required` with the
|
|
988
|
+
available list when multi-actor and `acting` is missing;
|
|
989
|
+
`actor_not_on_account` when supplied id doesn't belong; `no_actors`
|
|
990
|
+
defensively.
|
|
991
|
+
- `refresh_role_grants(ctx, deps)` — reloads role_grants without mutating the
|
|
992
|
+
original (concurrent-safe). Useful for long-lived WebSocket
|
|
993
|
+
connections that have an acting actor.
|
|
675
994
|
- `create_request_context_middleware(deps, log, session_context_key?)` —
|
|
676
|
-
|
|
677
|
-
`CREDENTIAL_TYPE_KEY = 'session'
|
|
678
|
-
-
|
|
679
|
-
- `
|
|
680
|
-
|
|
995
|
+
validates the session and sets `c.var.account_id` +
|
|
996
|
+
`CREDENTIAL_TYPE_KEY = 'session'` + `AUTH_SESSION_TOKEN_HASH_KEY`.
|
|
997
|
+
Touches the session fire-and-forget. Does not load actor / role_grants.
|
|
998
|
+
- `require_auth` — 401 (`ERROR_AUTHENTICATION_REQUIRED`) when
|
|
999
|
+
`account_id` is null. Does not require an acting actor.
|
|
1000
|
+
- `require_role(roles: ReadonlyArray<string>)` — 401 on no auth, 403
|
|
1001
|
+
(`ERROR_INSUFFICIENT_PERMISSIONS` + `required_roles: ReadonlyArray<string>`)
|
|
1002
|
+
when role_grants don't carry any of `roles` at **global / unscoped**
|
|
1003
|
+
scope. Implies the authorization phase ran (a role-gated route always
|
|
1004
|
+
resolves an actor). Implemented via `has_any_scoped_role(ctx, roles, null)`
|
|
1005
|
+
— a scoped role_grant (`{role: 'admin', scope_id: <uuid>}`) does **not**
|
|
1006
|
+
unlock unscoped role gates. Single-role specs pass `[role_name]`;
|
|
1007
|
+
multi-role specs pass `[r1, r2, ...]` for any-of disjunction. The
|
|
1008
|
+
same scope-aware semantics are mirrored in the HTTP RPC dispatcher
|
|
1009
|
+
(`actions/action_rpc.ts`), the WS dispatcher
|
|
1010
|
+
(`actions/register_action_ws.ts`), and the admin bypasses inside
|
|
1011
|
+
`role_grant_offer_actions.ts` so all four sites agree.
|
|
1012
|
+
- `require_credential_types(types: ReadonlyArray<string>)` — 401 on no
|
|
1013
|
+
auth, 403 (`ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types`
|
|
1014
|
+
echoing the spec's allowlist — symmetric with the role gate's
|
|
1015
|
+
`required_roles`) when `c.var.credential_type` is not in `types`.
|
|
1016
|
+
Composed with `require_role` for keeper specs (credential gate runs
|
|
1017
|
+
before role gate per `auth_guard_resolver.ts`). Replaces the deleted
|
|
1018
|
+
`require_keeper` helper — keeper is now a composable shape:
|
|
1019
|
+
`{roles: ['keeper'], credential_types: ['daemon_token']}`.
|
|
681
1020
|
|
|
682
1021
|
### `bearer_auth.ts`
|
|
683
1022
|
|
|
@@ -694,32 +1033,27 @@ without being blocked.
|
|
|
694
1033
|
- Rate limiter: `record` before async DB work to close the TOCTOU window;
|
|
695
1034
|
`reset` on valid token.
|
|
696
1035
|
|
|
697
|
-
###
|
|
698
|
-
|
|
699
|
-
Two-part type guard:
|
|
700
|
-
|
|
701
|
-
1. `credential_type` must be `'daemon_token'` (not session, not API token).
|
|
702
|
-
A session cookie from the bootstrap account still fails this check.
|
|
703
|
-
2. Active `keeper` permit.
|
|
1036
|
+
### Keeper auth (no dedicated module)
|
|
704
1037
|
|
|
705
|
-
|
|
706
|
-
`
|
|
1038
|
+
Keeper is a composable `RouteAuth` shape, not a dedicated guard:
|
|
1039
|
+
`{account: 'required', actor: 'required', roles: ['keeper'],
|
|
1040
|
+
credential_types: ['daemon_token']}`. The two-part check is
|
|
1041
|
+
`require_credential_types(['daemon_token'])` (403
|
|
1042
|
+
`ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types: ['daemon_token']`)
|
|
1043
|
+
followed by `require_role(['keeper'])` (403
|
|
1044
|
+
`ERROR_INSUFFICIENT_PERMISSIONS`).
|
|
707
1045
|
|
|
708
|
-
### `session_middleware.ts`
|
|
709
|
-
|
|
710
|
-
`session_middleware.ts`:
|
|
1046
|
+
### `session_middleware.ts`
|
|
711
1047
|
|
|
712
1048
|
- `get_session_cookie`, `set_session_cookie`, `clear_session_cookie`.
|
|
713
1049
|
- `create_session_middleware(keyring, options)` — always sets the
|
|
714
1050
|
identity on context (null when invalid/missing) for type-safe reads.
|
|
715
1051
|
Acts on `process_session_cookie`'s `action` (`'clear'` / `'refresh'` /
|
|
716
1052
|
`'none'`).
|
|
717
|
-
|
|
718
|
-
`session_lifecycle.ts` — shared by login and bootstrap:
|
|
719
|
-
|
|
720
1053
|
- `create_session_and_set_cookie({keyring, deps, c, account_id, session_options, max_sessions?})` —
|
|
721
|
-
|
|
722
|
-
per-account cap, signs
|
|
1054
|
+
shared by login, signup, and bootstrap: generates token, hashes,
|
|
1055
|
+
persists `auth_session`, optionally enforces per-account cap, signs
|
|
1056
|
+
the cookie.
|
|
723
1057
|
|
|
724
1058
|
### `daemon_token_middleware.ts`
|
|
725
1059
|
|
|
@@ -728,7 +1062,7 @@ Returns 401 on no context, 403 (`ERROR_KEEPER_REQUIRES_DAEMON_TOKEN` or
|
|
|
728
1062
|
or `null` if `$HOME` unset.
|
|
729
1063
|
- `write_daemon_token(runtime, path, token)` — atomic (temp + rename);
|
|
730
1064
|
`chmod 0600` if available.
|
|
731
|
-
- `resolve_keeper_account_id(deps)` — wraps `
|
|
1065
|
+
- `resolve_keeper_account_id(deps)` — wraps `query_role_grant_find_account_id_for_role(ROLE_KEEPER)`.
|
|
732
1066
|
- `start_daemon_token_rotation(runtime, deps, options, log)` — writes initial
|
|
733
1067
|
token, resolves keeper, sets up interval. Returns `{state, stop}`. The
|
|
734
1068
|
interval guard `writing` skips the next rotation if the prior write is
|
|
@@ -780,18 +1114,17 @@ Session-based auth route specs. Factory: `create_account_route_specs(deps, optio
|
|
|
780
1114
|
`account_verify` RPC action — that surface carries the typed
|
|
781
1115
|
`SessionAccountJson` payload.
|
|
782
1116
|
- `create_account_status_route_spec(options?)` — `GET /api/account/status`
|
|
783
|
-
returns `{account, actor,
|
|
1117
|
+
returns `{account, actor, role_grants}` on 200 or 401 with optional
|
|
784
1118
|
`bootstrap_available` flag. `actor` is the caller's own
|
|
785
1119
|
`ActorSummaryJson` so clients don't need to derive `actor_id` from
|
|
786
|
-
the
|
|
1120
|
+
the role_grant list. Lets the frontend fetch both session state
|
|
787
1121
|
and bootstrap availability in one request (eliminates a separate `/health`
|
|
788
1122
|
round trip).
|
|
789
1123
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
`
|
|
793
|
-
|
|
794
|
-
guards (IDOR via `query_session_revoke_for_account` /
|
|
1124
|
+
Session listing/revoke + revoke-all and API token CRUD live in
|
|
1125
|
+
`account_actions.ts` (see `account_session_list` / `_revoke` /
|
|
1126
|
+
`_revoke_all`, `account_token_create` / `_list` / `_revoke` below).
|
|
1127
|
+
Each keeps its guards (IDOR via `query_session_revoke_for_account` /
|
|
795
1128
|
`query_revoke_api_token_for_account`; `Blake3Hash` on session ids;
|
|
796
1129
|
`ApiTokenId` regex on token ids; `max_tokens` enforcement via
|
|
797
1130
|
`query_api_token_enforce_limit`).
|
|
@@ -828,31 +1161,34 @@ Constants:
|
|
|
828
1161
|
- `POST /signup` — `transaction: false` (manages its own). When
|
|
829
1162
|
`app_settings.open_signup` is false, requires a matching unclaimed invite.
|
|
830
1163
|
On `open_signup: true` path, no invite check.
|
|
831
|
-
- Transaction body: `query_create_account_with_actor` → `
|
|
1164
|
+
- Transaction body: `query_create_account_with_actor` → `query_invite_claim_unscoped`
|
|
832
1165
|
(if invite present; throws `SignupConflictError` on race — another claim
|
|
833
1166
|
won) → `create_session_and_set_cookie`. Catches
|
|
834
1167
|
`is_pg_unique_violation(e)` → 409 `ERROR_SIGNUP_CONFLICT` (username or
|
|
835
1168
|
email already exists).
|
|
836
1169
|
- Error shapes: 403 `ERROR_NO_MATCHING_INVITE`, 409 `ERROR_SIGNUP_CONFLICT`.
|
|
837
1170
|
|
|
838
|
-
### `
|
|
1171
|
+
### `auth_guard_resolver.ts`
|
|
839
1172
|
|
|
840
|
-
`fuz_auth_guard_resolver: AuthGuardResolver` — maps
|
|
841
|
-
|
|
1173
|
+
`fuz_auth_guard_resolver: AuthGuardResolver` — maps the four-axis
|
|
1174
|
+
`RouteAuth` shape to two-phase middleware arrays. `pre_validation`
|
|
1175
|
+
gets `require_auth` when `account === 'required'` or `actor === 'required'`;
|
|
1176
|
+
`post_authorization` gets `require_credential_types(types)` when
|
|
1177
|
+
`credential_types?.length` and `require_role(roles)` when `roles?.length`.
|
|
842
1178
|
Injected into `apply_route_specs` so the generic HTTP framework stays
|
|
843
1179
|
auth-agnostic (see `../http/CLAUDE.md` §Validation pipeline for where it plugs in).
|
|
844
1180
|
|
|
845
|
-
### `audit_log_routes.ts`
|
|
1181
|
+
### `audit_log_routes.ts`
|
|
846
1182
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1183
|
+
Audit-log list + role_grant-history reads (plus admin session listing)
|
|
1184
|
+
live on the RPC surface in `admin_actions.ts`. The REST surface this
|
|
1185
|
+
module produces is now just the optional SSE stream:
|
|
850
1186
|
|
|
851
1187
|
- **`GET /audit/stream`** — optional, wired only when
|
|
852
1188
|
`AuditLogRouteOptions.stream` is passed. Streams aren't an RPC concern.
|
|
853
1189
|
Uses `AUTH_SESSION_TOKEN_HASH_KEY` for SSE `scope` identity (so
|
|
854
1190
|
`session_revoke` can close only that session's stream); `groups: [account_id]`
|
|
855
|
-
for coarse close on `
|
|
1191
|
+
for coarse close on `role_grant_revoke` / `session_revoke_all` / `password_change`.
|
|
856
1192
|
|
|
857
1193
|
`create_audit_log_route_specs(options?)` — returns an empty array when
|
|
858
1194
|
`options.stream` is not set; `required_role` defaults to `'admin'`.
|
|
@@ -868,7 +1204,7 @@ Each surface is split across two files:
|
|
|
868
1204
|
(no per-method `*_METHOD` string constants — read `.method` off the spec),
|
|
869
1205
|
and `all_*_action_specs: Array<RequestResponseActionSpec>` codegen-ready
|
|
870
1206
|
registry. Plus any reason-string constants exported to the wire contract
|
|
871
|
-
(e.g. `
|
|
1207
|
+
(e.g. `ERROR_ROLE_GRANT_OFFER_*` for role_grant offers).
|
|
872
1208
|
- `*_actions.ts` — `create_*_actions(deps, options) => Array<RpcAction>` factory
|
|
873
1209
|
containing handler closures, the `*ActionDeps` / `*ActionOptions` interfaces,
|
|
874
1210
|
and any handler-only helpers. Imports the specs from its sibling.
|
|
@@ -879,25 +1215,29 @@ skips the handler module's transitive query-layer deps.
|
|
|
879
1215
|
|
|
880
1216
|
### `admin_action_specs.ts` + `admin_actions.ts` — eleven admin-only RPC actions
|
|
881
1217
|
|
|
882
|
-
Authorization is **spec-level**
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
|
893
|
-
|
|
|
894
|
-
| `
|
|
895
|
-
| `
|
|
896
|
-
| `
|
|
897
|
-
| `
|
|
898
|
-
| `
|
|
899
|
-
| `
|
|
900
|
-
| `
|
|
1218
|
+
Authorization is **spec-level** — every admin spec declares
|
|
1219
|
+
`auth: {account: 'required', actor: 'required', roles: ['admin']}` so
|
|
1220
|
+
the dispatcher enforces admin before the handler runs. `role_grant_revoke`
|
|
1221
|
+
in `role_grant_offer_actions.ts` uses the same spec-level gate even
|
|
1222
|
+
though its sibling methods are authenticated-but-not-admin — the
|
|
1223
|
+
dispatcher checks auth per-spec, so mixed-auth endpoints compose
|
|
1224
|
+
cleanly. Every admin input declares `acting?: ActingActor` per
|
|
1225
|
+
registry-time invariant 2 (the `actor !== 'none' ⟺ input declares
|
|
1226
|
+
acting?: ActingActor` biconditional).
|
|
1227
|
+
|
|
1228
|
+
| Spec | Side effects | Rate limit | Input | Output |
|
|
1229
|
+
| ------------------------------------------ | ------------ | ----------- | --------------------------------------------------------- | ----------------------------- |
|
|
1230
|
+
| `admin_account_list_action_spec` | false | | `{limit?, offset?}` | `{accounts, grantable_roles}` |
|
|
1231
|
+
| `admin_session_list_action_spec` | false | | `z.void()` | `{sessions}` |
|
|
1232
|
+
| `admin_session_revoke_all_action_spec` | true | `'account'` | `{account_id}` | `{ok, count}` |
|
|
1233
|
+
| `admin_token_revoke_all_action_spec` | true | `'account'` | `{account_id}` | `{ok, count}` |
|
|
1234
|
+
| `audit_log_list_action_spec` | false | | `{event_type?, account_id?, limit?, offset?, since_seq?}` | `{events}` |
|
|
1235
|
+
| `audit_log_role_grant_history_action_spec` | false | | `{limit?, offset?}` | `{events}` |
|
|
1236
|
+
| `invite_create_action_spec` | true | `'account'` | `{email?, username?}` | `{ok, invite}` |
|
|
1237
|
+
| `invite_list_action_spec` | false | | `z.void()` | `{invites}` |
|
|
1238
|
+
| `invite_delete_action_spec` | true | `'account'` | `{invite_id}` | `{ok}` |
|
|
1239
|
+
| `app_settings_get_action_spec` | false | | `z.void()` | `{settings}` |
|
|
1240
|
+
| `app_settings_update_action_spec` | true | `'account'` | `{open_signup}` | `{ok, settings}` |
|
|
901
1241
|
|
|
902
1242
|
Mutating admin specs declare `rate_limit: 'account'` — keyed on the
|
|
903
1243
|
admin's `request_context.actor.id`. The dispatcher's per-action hook
|
|
@@ -909,8 +1249,7 @@ per actor — permissive enough for any human admin workflow, slow enough
|
|
|
909
1249
|
that scripted oracles surface in audit. Tighten downstream via
|
|
910
1250
|
`AppServerOptions.action_account_rate_limiter`.
|
|
911
1251
|
|
|
912
|
-
`AUDIT_LOG_LIST_LIMIT_MAX = 200` — page size clamp
|
|
913
|
-
route).
|
|
1252
|
+
`AUDIT_LOG_LIST_LIMIT_MAX = 200` — page size clamp. `ADMIN_ACCOUNT_LIST_DEFAULT_LIMIT = 50` / `ADMIN_ACCOUNT_LIST_LIMIT_MAX = 200` — same shape on `admin_account_list`.
|
|
914
1253
|
|
|
915
1254
|
Error reasons returned via `error.data.reason`:
|
|
916
1255
|
|
|
@@ -925,9 +1264,9 @@ Audit events fired by handlers (all pass `ip: ctx.client_ip` for
|
|
|
925
1264
|
transport-uniform forensics — matches the REST convention and the
|
|
926
1265
|
self-service `account_actions.ts` surface):
|
|
927
1266
|
|
|
928
|
-
- `session_revoke_all` / `token_revoke_all` via `
|
|
929
|
-
|
|
930
|
-
|
|
1267
|
+
- `session_revoke_all` / `token_revoke_all` via `deps.audit.emit`. Both
|
|
1268
|
+
also emit an `outcome: 'failure'` row on the `ERROR_ACCOUNT_NOT_FOUND`
|
|
1269
|
+
404 path for
|
|
931
1270
|
forensic visibility — `target_account_id` is null (FK to `account`
|
|
932
1271
|
rejects references to missing ids), and the probed id is preserved
|
|
933
1272
|
under `metadata.attempted_account_id`. Metadata schema widening in
|
|
@@ -938,8 +1277,9 @@ self-service `account_actions.ts` surface):
|
|
|
938
1277
|
|
|
939
1278
|
Closure state:
|
|
940
1279
|
|
|
941
|
-
- `grantable_roles` is derived once from `options.roles?.
|
|
942
|
-
|
|
1280
|
+
- `grantable_roles` is derived once from `options.roles?.role_specs ?? BUILTIN_ROLE_SPECS_BY_NAME`
|
|
1281
|
+
via `list_roles_with_grant_path(_, GRANT_PATH_ADMIN)` and closed over
|
|
1282
|
+
by the `admin_account_list` handler.
|
|
943
1283
|
- `options.app_settings` — when provided, captured by the
|
|
944
1284
|
`app_settings_get` / `app_settings_update` handlers. Update handler
|
|
945
1285
|
**mutates the ref** (`open_signup`, `updated_at`, `updated_by`) so
|
|
@@ -951,61 +1291,79 @@ Closure state:
|
|
|
951
1291
|
`all_admin_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
|
|
952
1292
|
registry of all eleven specs (always includes the two app-settings specs).
|
|
953
1293
|
|
|
954
|
-
Deps: `
|
|
1294
|
+
Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>` — `log` for handler-side reporting, `audit` for the bound emitter (which captures `on_audit_event` + the optional `AuditLogConfig` so consumer-extended event-type metadata gets validated).
|
|
955
1295
|
|
|
956
|
-
### `
|
|
1296
|
+
### `role_grant_offer_action_specs.ts` + `role_grant_offer_actions.ts` — seven RPC actions
|
|
957
1297
|
|
|
958
|
-
> **Hazard — admin `
|
|
959
|
-
> returns `{offer}` only — no `
|
|
960
|
-
> RPC call (`
|
|
961
|
-
> a
|
|
1298
|
+
> **Hazard — admin `role_grant_offer_create` does not auto-accept.** The action
|
|
1299
|
+
> returns `{offer}` only — no `role_grant` is inserted. Acceptance is a separate
|
|
1300
|
+
> RPC call (`role_grant_offer_accept`); admin-side tests that need to materialize
|
|
1301
|
+
> a role_grant synchronously call `query_accept_offer` directly (see the
|
|
962
1302
|
> `offer_and_accept` helper in `testing/admin_integration.ts`). The CHANGELOG
|
|
963
|
-
> v0.31 entry "admin
|
|
1303
|
+
> v0.31 entry "admin create_role_grant routes emit offers instead of direct
|
|
964
1304
|
> grants" was the first signal of this two-step flow; consumers reading the
|
|
965
1305
|
> standard admin suite assume auto-accept and have to redesign their tests
|
|
966
1306
|
> when they discover otherwise. If you need direct grant for a programmatic
|
|
967
|
-
> path that already proves consent, reach for `
|
|
1307
|
+
> path that already proves consent, reach for `query_create_role_grant` rather
|
|
968
1308
|
> than the RPC action.
|
|
969
1309
|
|
|
970
|
-
Six offer-lifecycle methods plus `
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
`
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1310
|
+
Six offer-lifecycle methods plus `role_grant_revoke`. Every input
|
|
1311
|
+
declares `acting?: ActingActor` so every spec maps to
|
|
1312
|
+
`{account: 'required', actor: 'required', ...}` per registry-time
|
|
1313
|
+
invariant 2. Authorization tier is the differentiator:
|
|
1314
|
+
|
|
1315
|
+
- `role_grant_offer_create` — `auth: {account: 'required', actor: 'required'}`.
|
|
1316
|
+
The **admin-grant-path gate runs first** (the offered role's
|
|
1317
|
+
`RoleSpec.grant_paths` must include `'admin'` /
|
|
1318
|
+
`GRANT_PATH_ADMIN`), then the `RoleGrantOfferCreateAuthorize`
|
|
1319
|
+
callback (default: caller holds the offered role globally).
|
|
1320
|
+
Consumers can only tighten, never loosen past the admin-grant-path
|
|
1321
|
+
gate.
|
|
1322
|
+
- `role_grant_offer_accept` / `_decline` / `_retract` —
|
|
1323
|
+
`{account: 'required', actor: 'required'}`; IDOR guards in the
|
|
1324
|
+
`query_*` layer.
|
|
1325
|
+
- `role_grant_offer_list` / `_history` — `side_effects: false` so GET-addressable;
|
|
1326
|
+
**input-dependent elevation** — `{account: 'required', actor: 'required'}`
|
|
1327
|
+
at the spec level so any caller reaches their own inbox, then the
|
|
1328
|
+
handler requires admin when `{account_id}` refers to another account.
|
|
1329
|
+
The spec can't express this because auth runs before input parsing.
|
|
1330
|
+
`role_grant_offer_history` accepts `limit` (1–500, default 100) + `offset`.
|
|
1331
|
+
- **`role_grant_revoke`** — spec-level
|
|
1332
|
+
`auth: {account: 'required', actor: 'required', roles: ['admin']}`;
|
|
1333
|
+
the RPC dispatcher rejects non-admin callers before the handler runs.
|
|
1334
|
+
Keys on **`actor_id`, not `account_id`** — role_grants are
|
|
1335
|
+
actor-scoped and deriving actor from account collapses under
|
|
1336
|
+
multi-actor accounts.
|
|
1337
|
+
|
|
1338
|
+
Every input row below also carries the shared `acting?: ActingActor`
|
|
1339
|
+
field that the dispatcher's authorization phase reads off the raw
|
|
1340
|
+
params (omitted from the table for brevity).
|
|
1341
|
+
|
|
1342
|
+
| Spec | Input | Output |
|
|
1343
|
+
| -------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- |
|
|
1344
|
+
| `role_grant_offer_create_action_spec` | `{to_account_id, to_actor_id?, role, scope_id?, message?}` | `{offer}` |
|
|
1345
|
+
| `role_grant_offer_accept_action_spec` | `{offer_id}` | `{role_grant_id, offer, superseded_offer_ids}` |
|
|
1346
|
+
| `role_grant_offer_decline_action_spec` | `{offer_id, reason?}` | `{ok}` |
|
|
1347
|
+
| `role_grant_offer_retract_action_spec` | `{offer_id}` | `{ok}` |
|
|
1348
|
+
| `role_grant_offer_list_action_spec` | `{account_id?}` | `{offers}` |
|
|
1349
|
+
| `role_grant_offer_history_action_spec` | `{account_id?, limit?, offset?}` | `{offers}` |
|
|
1350
|
+
| `role_grant_revoke_action_spec` | `{actor_id, role_grant_id, reason?}` | `{ok, revoked}` |
|
|
998
1351
|
|
|
999
1352
|
Error reason constants (exported as `as const` literals):
|
|
1000
1353
|
|
|
1001
|
-
- `
|
|
1002
|
-
- `
|
|
1003
|
-
- `
|
|
1004
|
-
- `
|
|
1005
|
-
- `
|
|
1006
|
-
- `
|
|
1007
|
-
|
|
1008
|
-
|
|
1354
|
+
- `ERROR_ROLE_GRANT_OFFER_SELF_TARGET` (`'role_grant_offer_self_target'`)
|
|
1355
|
+
- `ERROR_ROLE_GRANT_OFFER_TERMINAL` (`'role_grant_offer_terminal'`)
|
|
1356
|
+
- `ERROR_ROLE_GRANT_OFFER_EXPIRED` (`'role_grant_offer_expired'`)
|
|
1357
|
+
- `ERROR_ROLE_GRANT_OFFER_NOT_FOUND` (`'role_grant_offer_not_found'` — 404-over-403 IDOR mask)
|
|
1358
|
+
- `ERROR_ROLE_GRANT_OFFER_ROLE_NOT_GRANTABLE` (`'role_grant_offer_role_not_grantable'`)
|
|
1359
|
+
- `ERROR_ROLE_GRANT_OFFER_NOT_AUTHORIZED` (`'role_grant_offer_not_authorized'`)
|
|
1360
|
+
- `ERROR_ROLE_GRANT_OFFER_ACTOR_ACCOUNT_MISMATCH` (`'role_grant_offer_actor_account_mismatch'` —
|
|
1361
|
+
`role_grant_offer_create` was called with a `to_actor_id` that does not
|
|
1362
|
+
belong to `to_account_id`)
|
|
1363
|
+
- `ERROR_ROLE_GRANT_OFFER_ACTOR_MISMATCH` (`'role_grant_offer_actor_mismatch'` —
|
|
1364
|
+
actor-targeted offer was accepted by an actor other than `to_actor_id`)
|
|
1365
|
+
|
|
1366
|
+
Plus re-uses from `../http/error_schemas.ts`: `ERROR_ROLE_GRANT_NOT_FOUND`,
|
|
1009
1367
|
`ERROR_ROLE_NOT_WEB_GRANTABLE`, `ERROR_INSUFFICIENT_PERMISSIONS`,
|
|
1010
1368
|
`ERROR_ACCOUNT_NOT_FOUND`.
|
|
1011
1369
|
|
|
@@ -1014,73 +1372,85 @@ Each spec declares the reason codes its handler may surface (see
|
|
|
1014
1372
|
domain reasons returned via `error.data.reason` are listed; standard
|
|
1015
1373
|
transport errors (validation, auth, rate-limit) stay implicit. Drift
|
|
1016
1374
|
between declared reasons and handler throws is caught by
|
|
1017
|
-
`../../test/auth/
|
|
1375
|
+
`../../test/auth/role_grant_offer_actions.error_reasons.test.ts`.
|
|
1018
1376
|
|
|
1019
1377
|
Failure-outcome audit events emitted (success and failure rows both carry
|
|
1020
1378
|
`ip: ctx.client_ip` — uniform with the admin and self-service surfaces):
|
|
1021
1379
|
|
|
1022
|
-
- `
|
|
1023
|
-
denial, self-target rejection
|
|
1024
|
-
audit row
|
|
1025
|
-
|
|
1380
|
+
- `role_grant_offer_create` failure — admin-grant-path denial, `authorize`
|
|
1381
|
+
denial, self-target rejection, and actor-account mismatch all emit
|
|
1382
|
+
the same audit row via `emit_create_failure_audit`. `target_account_id`
|
|
1383
|
+
carries `input.to_account_id`; `target_actor_id` echoes
|
|
1384
|
+
`input.to_actor_id` when supplied so failure rows match the
|
|
1385
|
+
success-shape envelope of actor-targeted offers (null on
|
|
1386
|
+
account-grain offers — see audit_log_schema rule).
|
|
1387
|
+
- `role_grant_revoke` failure — admin-grant-path denial after IDOR / role
|
|
1026
1388
|
lookup succeeded. The admin-role-denied path (pre-IDOR) emits no audit,
|
|
1027
|
-
matching the middleware auth-guard precedent.
|
|
1389
|
+
matching the middleware auth-guard precedent. `target_account_id` +
|
|
1390
|
+
`target_actor_id` both populated (the IDOR-passing branch resolves
|
|
1391
|
+
the target actor before the gate; the subject is an actor-bound
|
|
1392
|
+
role_grant).
|
|
1028
1393
|
|
|
1029
1394
|
WS notifications (post-commit via `emit_after_commit` from
|
|
1030
1395
|
`../http/pending_effects.js` — swallows exceptions so one failed send
|
|
1031
1396
|
can't starve others; see `../http/CLAUDE.md` §Pending Effects):
|
|
1032
1397
|
|
|
1033
|
-
- Create → `
|
|
1034
|
-
- Retract → `
|
|
1035
|
-
- Accept → `
|
|
1036
|
-
`
|
|
1037
|
-
- Decline → `
|
|
1038
|
-
- Revoke → `
|
|
1398
|
+
- Create → `role_grant_offer_received` to recipient.
|
|
1399
|
+
- Retract → `role_grant_offer_retracted` to recipient.
|
|
1400
|
+
- Accept → `role_grant_offer_accepted` to grantor + one
|
|
1401
|
+
`role_grant_offer_supersede` per superseded sibling to that sibling's grantor.
|
|
1402
|
+
- Decline → `role_grant_offer_declined` to grantor.
|
|
1403
|
+
- Revoke → `role_grant_revoke` to revokee + one `role_grant_offer_supersede` per
|
|
1039
1404
|
superseded sibling.
|
|
1040
1405
|
|
|
1041
|
-
Deps: `
|
|
1042
|
-
Notification sender is optional — when absent, WS
|
|
1043
|
-
skipped (DB-only side effects still happen).
|
|
1406
|
+
Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'> & {notification_sender?: NotificationSender | null}`
|
|
1407
|
+
inline on the param. Notification sender is optional — when absent, WS
|
|
1408
|
+
fan-out is silently skipped (DB-only side effects still happen).
|
|
1044
1409
|
|
|
1045
1410
|
Options:
|
|
1046
1411
|
|
|
1047
|
-
- `roles?: RoleSchemaResult` — drives
|
|
1048
|
-
`
|
|
1412
|
+
- `roles?: RoleSchemaResult` — drives the admin-grant-path lookup
|
|
1413
|
+
(`role_has_grant_path(_, role, GRANT_PATH_ADMIN)`); defaults to
|
|
1414
|
+
`BUILTIN_ROLE_SPECS_BY_NAME`.
|
|
1049
1415
|
- `default_ttl_ms?: number` — applied to new offers (defaults to
|
|
1050
|
-
`
|
|
1051
|
-
- `authorize?:
|
|
1052
|
-
`
|
|
1416
|
+
`ROLE_GRANT_OFFER_DEFAULT_TTL_MS`).
|
|
1417
|
+
- `authorize?: RoleGrantOfferCreateAuthorize` — custom policy for
|
|
1418
|
+
`role_grant_offer_create`. Signature:
|
|
1053
1419
|
`(auth, input: {to_account_id, role, scope_id}, deps: Pick<RouteFactoryDeps, 'log'>, ctx: ActionContext) => boolean | Promise<boolean>`.
|
|
1054
1420
|
Pre-built option: `authorize_admin_or_holder` admits any admin and
|
|
1055
1421
|
otherwise falls back to the symmetric default (caller must hold the
|
|
1056
1422
|
offered role globally). Drop into
|
|
1057
|
-
`
|
|
1423
|
+
`create_role_grant_offer_actions({authorize: authorize_admin_or_holder})`
|
|
1058
1424
|
or any factory that forwards `authorize` (e.g. `create_standard_rpc_actions`)
|
|
1059
|
-
for the common "admins offer anything
|
|
1060
|
-
they hold" pattern.
|
|
1425
|
+
for the common "admins offer anything on the admin grant path; users
|
|
1426
|
+
offer what they hold" pattern.
|
|
1061
1427
|
|
|
1062
|
-
`
|
|
1428
|
+
`all_role_grant_offer_action_specs: Array<RequestResponseActionSpec>` —
|
|
1063
1429
|
codegen-ready registry.
|
|
1064
1430
|
|
|
1065
|
-
### `standard_rpc_actions.ts` — combined admin +
|
|
1431
|
+
### `standard_rpc_actions.ts` — combined admin + role-grant-offer + account factory
|
|
1066
1432
|
|
|
1067
1433
|
`create_standard_rpc_actions(deps, options)` spreads
|
|
1068
|
-
`create_admin_actions`, `
|
|
1434
|
+
`create_admin_actions`, `create_role_grant_offer_actions`, and
|
|
1069
1435
|
`create_account_actions` into a single `Array<RpcAction>` — the
|
|
1070
1436
|
canonical fuz_app "standard" RPC surface (25 actions with
|
|
1071
1437
|
`app_settings` wired, 23 without). Consumers that want a narrower
|
|
1072
1438
|
surface drop down to the per-domain factories directly.
|
|
1073
1439
|
|
|
1074
|
-
Option routing: `roles` is shared between admin and
|
|
1440
|
+
Option routing: `roles` is shared between admin and role-grant-offer;
|
|
1075
1441
|
`app_settings` flows to admin only; `default_ttl_ms` and `authorize`
|
|
1076
|
-
flow to
|
|
1077
|
-
`notification_sender` is wired through to
|
|
1442
|
+
flow to role-grant-offer only; `max_tokens` flows to account only;
|
|
1443
|
+
`notification_sender` is wired through to role-grant-offer (admin +
|
|
1078
1444
|
account ignore it).
|
|
1079
1445
|
|
|
1080
1446
|
`StandardRpcActionsOptions` composes `AdminActionOptions` +
|
|
1081
|
-
`
|
|
1082
|
-
`StandardRpcActionsDeps
|
|
1083
|
-
|
|
1447
|
+
`RoleGrantOfferActionOptions` + `AccountActionOptions`.
|
|
1448
|
+
`StandardRpcActionsDeps extends Pick<RouteFactoryDeps, 'log' | 'audit'>`
|
|
1449
|
+
plus optional `notification_sender` consumed only by the
|
|
1450
|
+
role-grant-offer sub-factory; admin and account sub-factories ignore it.
|
|
1451
|
+
The interface is declared inline rather than aliased so future
|
|
1452
|
+
role-grant-offer-internal deps additions can't silently widen the
|
|
1453
|
+
standard surface.
|
|
1084
1454
|
|
|
1085
1455
|
Pair this with `create_app_server`'s `rpc_endpoints` factory form
|
|
1086
1456
|
(`(ctx) => Array<RpcEndpointSpec>`) so the combined action list gets
|
|
@@ -1090,7 +1460,7 @@ again in `create_route_specs`. See `../../../docs/usage.md` §Server
|
|
|
1090
1460
|
Assembly.
|
|
1091
1461
|
|
|
1092
1462
|
Pre-bundle consumers spread `create_admin_actions` and
|
|
1093
|
-
`
|
|
1463
|
+
`create_role_grant_offer_actions` separately, then also
|
|
1094
1464
|
`create_account_actions`. The bundled helper replaces all three —
|
|
1095
1465
|
bundling account actions into the "standard" surface is deliberate:
|
|
1096
1466
|
the admin integration suite exercises `account_token_create` /
|
|
@@ -1100,7 +1470,7 @@ consumer wiring the admin surface without account actions will hit
|
|
|
1100
1470
|
|
|
1101
1471
|
Frontend mirror: `all_standard_action_specs` (in
|
|
1102
1472
|
`./standard_action_specs.ts`) bundles `all_admin_action_specs +
|
|
1103
|
-
|
|
1473
|
+
all_role_grant_offer_action_specs + all_account_action_specs` into one
|
|
1104
1474
|
`ReadonlyArray<RequestResponseActionSpec>` for typed-client codegen
|
|
1105
1475
|
and `create_frontend_rpc_client({specs})` wiring. Self-service role
|
|
1106
1476
|
specs are not included (opt-in, app-specific `eligible_roles`) —
|
|
@@ -1117,8 +1487,10 @@ that was `/api/account/*` is on the RPC endpoint.
|
|
|
1117
1487
|
status-only probe, the RPC action returns `SessionAccountJson` for
|
|
1118
1488
|
programmatic callers.
|
|
1119
1489
|
|
|
1120
|
-
Authorization is **spec-level**
|
|
1121
|
-
|
|
1490
|
+
Authorization is **spec-level** —
|
|
1491
|
+
`auth: {account: 'required', actor: 'none'}` (no `acting` on input, so
|
|
1492
|
+
the actor axis stays `'none'` per registry-time invariant 2). Revoke
|
|
1493
|
+
operations are account-scoped via `query_session_revoke_for_account` /
|
|
1122
1494
|
`query_revoke_api_token_for_account` — passing another account's session
|
|
1123
1495
|
or token id returns `revoked: false` rather than revealing whether the id
|
|
1124
1496
|
exists.
|
|
@@ -1136,12 +1508,12 @@ exists.
|
|
|
1136
1508
|
`session_id` validates as `Blake3Hash`; `token_id` validates as
|
|
1137
1509
|
`ApiTokenId` (`tok_[A-Za-z0-9_-]{12}`).
|
|
1138
1510
|
|
|
1139
|
-
Audit events emitted (via `
|
|
1511
|
+
Audit events emitted (via `deps.audit.emit` with `ip: ctx.client_ip`):
|
|
1140
1512
|
`session_revoke`, `session_revoke_all`, `token_create`, `token_revoke`. The
|
|
1141
1513
|
IP is the resolved trusted-proxy value from `ActionContext.client_ip`,
|
|
1142
1514
|
matching the REST handler convention.
|
|
1143
1515
|
|
|
1144
|
-
Deps: `
|
|
1516
|
+
Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>`.
|
|
1145
1517
|
Options: `{max_tokens?: number | null}` — defaults to `DEFAULT_MAX_TOKENS`
|
|
1146
1518
|
from `account_routes.ts`; `null` disables the cap.
|
|
1147
1519
|
|
|
@@ -1157,45 +1529,50 @@ Zod schemas, the `satisfies RequestResponseActionSpec` literal, the
|
|
|
1157
1529
|
`*_actions.ts` factory imports the spec and pairs it with the handler.
|
|
1158
1530
|
|
|
1159
1531
|
One static `request_response` action — `self_service_role_set` — that
|
|
1160
|
-
takes `{role, enabled: boolean}` and toggles a global
|
|
1532
|
+
takes `{role, enabled: boolean}` and toggles a global role_grant on the
|
|
1161
1533
|
caller. Idempotent in both directions: `changed: false` when the
|
|
1162
1534
|
post-call state already matched the request (already-held when
|
|
1163
1535
|
enabling; not-held when disabling). Output is `{ok, enabled, changed}` —
|
|
1164
1536
|
`enabled` echoes the post-call state for self-describing responses.
|
|
1165
1537
|
Audit metadata carries `self_service: true` so admin reviewers can
|
|
1166
|
-
distinguish self-toggled
|
|
1167
|
-
`
|
|
1538
|
+
distinguish self-toggled role_grants from admin grants/offers. The
|
|
1539
|
+
`role_grant_create` / `role_grant_revoke` metadata schemas declare
|
|
1168
1540
|
`self_service: z.boolean().optional()` explicitly, so the field is
|
|
1169
1541
|
part of the documented surface rather than riding on `z.looseObject`
|
|
1170
1542
|
permissiveness.
|
|
1171
1543
|
|
|
1172
1544
|
Method name is static — `role` lives in the input, not the method
|
|
1173
|
-
name. Mirrors the `
|
|
1545
|
+
name. Mirrors the `role_grant_offer_create({role})` precedent. Per-role
|
|
1174
1546
|
parameterized methods would break the `satisfies RequestResponseActionSpec`
|
|
1175
1547
|
codegen invariant and grow the surface linearly per role.
|
|
1176
1548
|
|
|
1177
1549
|
`create_self_service_role_actions(deps, options)`:
|
|
1178
1550
|
|
|
1179
|
-
- `eligible_roles
|
|
1180
|
-
|
|
1551
|
+
- `eligible_roles?: ReadonlyArray<string>` — optional override
|
|
1552
|
+
allowlist. When omitted, eligibility is derived from
|
|
1553
|
+
`roles.role_specs` (or `BUILTIN_ROLE_SPECS_BY_NAME` when `roles` is
|
|
1554
|
+
also omitted) by selecting every role whose `RoleSpec.grant_paths`
|
|
1555
|
+
includes `'self_service'` (`GRANT_PATH_SELF_SERVICE`). Roles outside
|
|
1556
|
+
the eligible set are rejected with `forbidden` + reason
|
|
1181
1557
|
`role_not_self_service_eligible` (exported as
|
|
1182
1558
|
`ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE`). The eligibility check fires
|
|
1183
1559
|
before the `enabled` branch — same rejection regardless of direction.
|
|
1184
|
-
- `roles?: RoleSchemaResult` —
|
|
1185
|
-
`eligible_roles` is
|
|
1186
|
-
|
|
1560
|
+
- `roles?: RoleSchemaResult` — drives default-eligibility derivation
|
|
1561
|
+
from `RoleSpec.grant_paths`. When `eligible_roles` is also supplied,
|
|
1562
|
+
every entry is checked against `roles.role_specs` at factory time so
|
|
1563
|
+
typos throw at startup instead of at first call.
|
|
1187
1564
|
|
|
1188
1565
|
Grant branch uses `has_scoped_role(auth, role, null)` for a
|
|
1189
1566
|
benign-TOCTOU pre-check (distinguishes new grant from idempotent
|
|
1190
|
-
re-grant) — reads from the in-memory `auth.
|
|
1191
|
-
roundtrip — then `
|
|
1192
|
-
`
|
|
1567
|
+
re-grant) — reads from the in-memory `auth.role_grants` snapshot, no DB
|
|
1568
|
+
roundtrip — then `query_create_role_grant` for the actual insert. Revoke branch filters
|
|
1569
|
+
`query_role_grant_find_active_for_actor` in JS for the matching
|
|
1193
1570
|
`(actor, role, scope_id IS NULL)` row before calling
|
|
1194
|
-
`
|
|
1571
|
+
`query_revoke_role_grant`. Bundle is **not** included in
|
|
1195
1572
|
`create_standard_rpc_actions` — `eligible_roles` is app-specific, opt-in,
|
|
1196
1573
|
spread alongside the standard bundle when needed.
|
|
1197
1574
|
|
|
1198
|
-
Deps: `
|
|
1575
|
+
Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>`.
|
|
1199
1576
|
|
|
1200
1577
|
`all_self_service_role_action_specs: ReadonlyArray<RequestResponseActionSpec>` —
|
|
1201
1578
|
codegen-ready registry of the single unified spec.
|
|
@@ -1204,47 +1581,54 @@ codegen-ready registry of the single unified spec.
|
|
|
1204
1581
|
|
|
1205
1582
|
`cleanup.ts` — periodic auth maintenance:
|
|
1206
1583
|
|
|
1207
|
-
- `AuthCleanupDeps = QueryDeps & {log,
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
`
|
|
1211
|
-
|
|
1212
|
-
|
|
1584
|
+
- `AuthCleanupDeps = QueryDeps & {log, audit: AuditEmitter}`.
|
|
1585
|
+
Required — production wiring always has a bound emitter (built once
|
|
1586
|
+
at `create_app_backend`); tests that need a no-op pass
|
|
1587
|
+
`create_test_audit_emitter()` from `testing/stubs.ts`. Single slot
|
|
1588
|
+
carries both row persistence and SSE/WS fan-out.
|
|
1589
|
+
- `cleanup_expired_role_grant_offers(deps)` — wraps `query_role_grant_offer_sweep_expired`,
|
|
1590
|
+
emits one `role_grant_offer_expire` audit row per expired offer
|
|
1591
|
+
through `audit.emit_pool` (the captured pool + config + chain). Both
|
|
1592
|
+
write errors and per-listener throws are logged + swallowed inside
|
|
1593
|
+
`emit_pool`, so a single bad row never starves sibling sweeps.
|
|
1213
1594
|
- `run_auth_cleanup(deps)` — one-shot consumer entry point: expired
|
|
1214
1595
|
sessions + expired offers. Returns `{expired_sessions, expired_offers}`.
|
|
1215
1596
|
**Re-throws sweep errors** so the caller's scheduler can log / alert.
|
|
1216
1597
|
Call from `setInterval` / cron / similar.
|
|
1217
1598
|
|
|
1218
|
-
Idempotency: the audit log has no tombstone on `
|
|
1599
|
+
Idempotency: the audit log has no tombstone on `role_grant_offer_expire`, so
|
|
1219
1600
|
concurrent sweep runs double-audit. Deploy a single scheduled invocation
|
|
1220
1601
|
per instance — matches `query_session_cleanup_expired`'s expected pattern.
|
|
1221
1602
|
Expired offer rows are **preserved** (not deleted) — they carry audit value
|
|
1222
1603
|
for the history view, and accepted rows are the provenance for the
|
|
1223
|
-
resulting
|
|
1604
|
+
resulting role_grant.
|
|
1224
1605
|
|
|
1225
1606
|
## Deps
|
|
1226
1607
|
|
|
1227
1608
|
`deps.ts` defines:
|
|
1228
1609
|
|
|
1229
|
-
- **`AppDeps`** — the stateless capabilities bundle.
|
|
1610
|
+
- **`AppDeps`** — the stateless capabilities bundle. Seven members:
|
|
1230
1611
|
- `stat`, `read_text_file`, `delete_file` — filesystem.
|
|
1231
1612
|
- `keyring: Keyring` — HMAC-SHA256 signing.
|
|
1232
1613
|
- `password: PasswordHashDeps` — use `argon2_password_deps` in production.
|
|
1233
1614
|
- `db: Db` — pool-level instance (middleware uses this; route handlers
|
|
1234
1615
|
get a transaction-scoped `Db` via `RouteContext`).
|
|
1235
1616
|
- `log: Logger`.
|
|
1236
|
-
- `
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
`
|
|
1243
|
-
|
|
1244
|
-
|
|
1617
|
+
- `audit: AuditEmitter` — bound emitter built once at `create_app_backend`
|
|
1618
|
+
via `create_audit_emitter`. Closes over the pool, the
|
|
1619
|
+
`on_audit_event` subscriber chain, and the optional
|
|
1620
|
+
`AuditLogConfig` so handlers reach `audit.emit(ctx, input)` /
|
|
1621
|
+
`audit.emit_role_grant_target(ctx, auth, input)` and never see the
|
|
1622
|
+
pool. Pass `on_audit_event` and `audit_log_config` to
|
|
1623
|
+
`create_app_backend` — both fold into `audit`'s closure and the slot
|
|
1624
|
+
is the single seam for SSE/WS fan-out (additional listeners append
|
|
1625
|
+
via `audit.on_event_chain.push(...)` at server assembly).
|
|
1245
1626
|
- **`RouteFactoryDeps = Omit<AppDeps, 'db'>`** — for route factories. Route
|
|
1246
1627
|
handlers receive DB access via `RouteContext`, so factories don't capture
|
|
1247
1628
|
a pool-level `Db`.
|
|
1248
1629
|
|
|
1630
|
+
Action factories take `Pick<RouteFactoryDeps, 'log' | 'audit'>` directly
|
|
1631
|
+
(role-grant-offer adds `notification_sender?` inline).
|
|
1632
|
+
|
|
1249
1633
|
See root `../../../CLAUDE.md` §AppDeps Vocabulary for the
|
|
1250
1634
|
capability / options / runtime-state split across the whole project.
|