@fuzdev/fuz_app 0.55.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 +211 -155
- 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 +19 -0
- package/dist/actions/action_codegen.d.ts.map +1 -1
- package/dist/actions/action_codegen.js +20 -14
- 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 +110 -44
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +92 -287
- 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 +44 -38
- 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 +2 -10
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +32 -10
- 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 +673 -442
- 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 +8 -14
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +26 -32
- package/dist/auth/account_queries.d.ts +46 -13
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +73 -33
- package/dist/auth/account_routes.d.ts +4 -3
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +58 -33
- package/dist/auth/account_schema.d.ts +46 -54
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +21 -48
- package/dist/auth/admin_action_specs.d.ts +55 -21
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +42 -26
- package/dist/auth/admin_actions.d.ts +14 -21
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +47 -44
- 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 -87
- package/dist/auth/audit_log_queries.d.ts.map +1 -1
- package/dist/auth/audit_log_queries.js +17 -96
- 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 +48 -42
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +56 -43
- 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/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 -47
- 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 +1 -1
- package/dist/auth/daemon_token_middleware.js +3 -3
- 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 -32
- 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 +5 -2
- package/dist/auth/migrations.d.ts +22 -7
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +64 -25
- package/dist/auth/request_context.d.ts +157 -170
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +224 -268
- package/dist/auth/{permit_offer_action_specs.d.ts → role_grant_offer_action_specs.d.ts} +130 -100
- 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/{permit_offer_actions.js → role_grant_offer_actions.js} +153 -140
- package/dist/auth/{permit_offer_notifications.d.ts → role_grant_offer_notifications.d.ts} +80 -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/{permit_offer_queries.d.ts → role_grant_offer_queries.d.ts} +64 -64
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -0
- package/dist/auth/{permit_offer_queries.js → role_grant_offer_queries.js} +136 -123
- 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} +55 -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 +4 -1
- package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
- package/dist/auth/self_service_role_action_specs.js +2 -2
- package/dist/auth/self_service_role_actions.d.ts +35 -29
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.js +58 -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 +1 -1
- package/dist/db/migrate.js +1 -1
- package/dist/dev/setup.d.ts +2 -2
- package/dist/dev/setup.d.ts.map +1 -1
- package/dist/dev/setup.js +4 -4
- package/dist/env/load.d.ts +1 -1
- package/dist/env/load.js +1 -1
- package/dist/hono_context.d.ts +27 -45
- package/dist/hono_context.d.ts.map +1 -1
- package/dist/hono_context.js +14 -28
- package/dist/http/CLAUDE.md +235 -121
- 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 +56 -34
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +63 -28
- 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 +89 -75
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +54 -72
- package/dist/http/schema_helpers.d.ts +3 -14
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +2 -14
- package/dist/http/surface.d.ts +2 -10
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +3 -4
- 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 +35 -40
- package/dist/server/validate_nginx.d.ts +1 -1
- package/dist/server/validate_nginx.js +1 -1
- package/dist/testing/CLAUDE.md +50 -38
- 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 +87 -85
- 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 +16 -15
- 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 +36 -36
- 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 +22 -19
- 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 +8 -7
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +21 -18
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +13 -14
- package/dist/testing/integration_helpers.d.ts +4 -4
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +20 -18
- package/dist/testing/middleware.d.ts +4 -4
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +12 -11
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.js +40 -24
- 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 +19 -11
- 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 +60 -60
- package/dist/ui/{PermitOfferForm.svelte → RoleGrantOfferForm.svelte} +27 -26
- package/dist/ui/{PermitOfferForm.svelte.d.ts → RoleGrantOfferForm.svelte.d.ts} +7 -7
- 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 +18 -18
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +16 -16
- 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} +30 -30
- 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} +18 -18
- 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 -258
- 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_notifications.d.ts.map +0 -1
- package/dist/auth/permit_offer_notifications.js +0 -182
- package/dist/auth/permit_offer_queries.d.ts.map +0 -1
- package/dist/auth/permit_offer_schema.d.ts +0 -125
- package/dist/auth/permit_offer_schema.d.ts.map +0 -1
- package/dist/auth/permit_queries.d.ts +0 -222
- package/dist/auth/permit_queries.d.ts.map +0 -1
- package/dist/auth/permit_queries.js +0 -305
- 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 -27
- package/dist/auth/route_guards.d.ts.map +0 -1
- package/dist/auth/route_guards.js +0 -38
- 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.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,25 +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
|
|
93
|
+
that acts — owns cells, holds role_grants, appears in audit trails; an account
|
|
88
94
|
may host one or more actors, with the dispatcher's authorization phase
|
|
89
95
|
resolving the acting actor per-request via `acting?: ActingActor` on
|
|
90
|
-
inputs), `
|
|
91
|
-
actor — carries `
|
|
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`),
|
|
92
99
|
`AuthSession` (server-side, keyed by blake3), `ApiToken`.
|
|
93
100
|
- Every `id` / `*_id` field on entity interfaces, `*Json` schemas, and
|
|
94
101
|
`*Input` types is branded `Uuid` (from `@fuzdev/fuz_util/uuid.js`), except
|
|
@@ -98,44 +105,137 @@ Design notes:
|
|
|
98
105
|
`UsernameProvided`: `min(1).max(255)` — permissive for login/lookup so
|
|
99
106
|
tightening creation rules won't lock out existing users.
|
|
100
107
|
- `Email`: `z.email()`.
|
|
101
|
-
- `
|
|
102
|
-
and the `
|
|
108
|
+
- `ROLE_GRANT_REVOKED_REASON_LENGTH_MAX = 500` — bounds both the admin input
|
|
109
|
+
and the `role_grant_revoke` WS payload.
|
|
103
110
|
- Client-safe Zod schemas (every exported schema has a same-named `z.infer`
|
|
104
111
|
type export):
|
|
105
112
|
- `SessionAccountJson` — strips sensitive fields from `Account`
|
|
106
113
|
- `AuthSessionJson` — `id` is the blake3 hash (safe for client)
|
|
107
114
|
- `ClientApiTokenJson` — excludes `token_hash`
|
|
108
|
-
- `
|
|
115
|
+
- `RoleGrantSummaryJson` — the client-safe role_grant shape carried by
|
|
109
116
|
`GET /api/account/status` and the admin account listing; includes
|
|
110
|
-
`scope_id` so clients can make
|
|
117
|
+
`scope_kind` + `scope_id` (paired-null) so clients can make
|
|
118
|
+
per-scope auth decisions. Excludes
|
|
111
119
|
`revoked_at` / `revoked_by` / `revoked_reason` because the callers
|
|
112
|
-
that return it already filter to active
|
|
120
|
+
that return it already filter to active role_grants.
|
|
113
121
|
- `ActorSummaryJson`
|
|
114
122
|
- `AdminAccountJson` extends `SessionAccountJson` with `updated_at` / `updated_by`
|
|
115
|
-
- `PendingOfferSummaryJson` — narrower than `
|
|
123
|
+
- `PendingOfferSummaryJson` — narrower than `RoleGrantOfferJson`; omits
|
|
116
124
|
`message` and `decline_reason` so cross-admin visibility of the listing
|
|
117
125
|
does not expose grantor-authored text beyond what the audit log
|
|
118
126
|
discloses. `from_username` is resolved server-side so admins can see
|
|
119
127
|
whose pending offer is blocking a "+ role" button.
|
|
120
|
-
- `AdminAccountEntryJson` — composes `{account, actor,
|
|
128
|
+
- `AdminAccountEntryJson` — composes `{account, actor, role_grants, pending_offers}`
|
|
121
129
|
- Converters: `to_session_account(account)`, `to_admin_account(account)`,
|
|
122
|
-
`
|
|
123
|
-
- Input types: `CreateAccountInput`, `
|
|
124
|
-
`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}`.
|
|
125
207
|
|
|
126
208
|
### Role system (`role_schema.ts`)
|
|
127
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
|
+
|
|
128
217
|
- `RoleName`: lowercase letters + underscores, no leading/trailing
|
|
129
218
|
underscore.
|
|
130
|
-
- `ROLE_KEEPER = 'keeper'`
|
|
131
|
-
|
|
132
|
-
- `
|
|
133
|
-
- `
|
|
134
|
-
|
|
135
|
-
- `
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
139
239
|
|
|
140
240
|
### Raw DDL (`ddl.ts`)
|
|
141
241
|
|
|
@@ -145,8 +245,13 @@ Separated from runtime types to isolate DDL concerns. Consumed by
|
|
|
145
245
|
- `ACCOUNT_SCHEMA` (plus `ACCOUNT_EMAIL_INDEX`, `ACCOUNT_USERNAME_CI_INDEX`
|
|
146
246
|
— both case-insensitive partial uniques)
|
|
147
247
|
- `ACTOR_SCHEMA`, `ACTOR_INDEX`
|
|
148
|
-
- `
|
|
149
|
-
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).
|
|
150
255
|
- `AUTH_SESSION_SCHEMA`, `AUTH_SESSION_INDEXES`
|
|
151
256
|
- `API_TOKEN_SCHEMA`, `API_TOKEN_INDEX`
|
|
152
257
|
- `BOOTSTRAP_LOCK_SCHEMA`, `BOOTSTRAP_LOCK_SEED` — seeded as `bootstrapped`
|
|
@@ -161,50 +266,50 @@ Separated from runtime types to isolate DDL concerns. Consumed by
|
|
|
161
266
|
|
|
162
267
|
#### Audit event types
|
|
163
268
|
|
|
164
|
-
`AUDIT_EVENT_TYPES` — 21 events covering auth +
|
|
165
|
-
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` /
|
|
166
271
|
`_decline` / `_retract` / `_expire` / `_supersede`. `AuditEventType` is the
|
|
167
272
|
Zod enum; `AuditOutcome` is `'success' | 'failure'`.
|
|
168
273
|
|
|
169
|
-
| Event type
|
|
170
|
-
|
|
|
171
|
-
| `login`
|
|
172
|
-
| `logout`
|
|
173
|
-
| `bootstrap`
|
|
174
|
-
| `signup`
|
|
175
|
-
| `password_change`
|
|
176
|
-
| `session_revoke`
|
|
177
|
-
| `session_revoke_all`
|
|
178
|
-
| `token_create`
|
|
179
|
-
| `token_revoke`
|
|
180
|
-
| `token_revoke_all`
|
|
181
|
-
| `
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
| `invite_create`
|
|
190
|
-
| `invite_delete`
|
|
191
|
-
| `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` |
|
|
192
297
|
|
|
193
298
|
#### Metadata schemas
|
|
194
299
|
|
|
195
300
|
- `AUDIT_METADATA_SCHEMAS` — per-type `z.looseObject`. Notable shapes:
|
|
196
|
-
- `
|
|
197
|
-
omit —
|
|
301
|
+
- `role_grant_create` — `scope_id`, optional `role_grant_id` (failed grants
|
|
302
|
+
omit — admin-grant-path denial never produces a row), optional
|
|
198
303
|
`source_offer_id`, optional `self_service` (set by
|
|
199
304
|
`self_service_role_actions.ts`; declared on the schema rather than
|
|
200
305
|
riding on `z.looseObject` so the field is part of the documented surface).
|
|
201
|
-
- `
|
|
306
|
+
- `role_grant_revoke` — `scope_id`, optional `reason`, optional
|
|
202
307
|
`self_service` (same self-service toggle).
|
|
203
|
-
- `
|
|
204
|
-
- `
|
|
205
|
-
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
|
|
206
311
|
parent scope row id respectively). The `scope_destroyed` variant is
|
|
207
|
-
emitted by callers of `
|
|
312
|
+
emitted by callers of `query_role_grant_revoke_for_scope` when a polymorphic
|
|
208
313
|
parent scope row is deleted.
|
|
209
314
|
- `AuditLogEvent` (row); `AuditLogInput<T extends string = AuditEventType>`
|
|
210
315
|
(narrow metadata when `T` is builtin, generic record otherwise);
|
|
@@ -213,16 +318,16 @@ Zod enum; `AuditOutcome` is `'success' | 'failure'`.
|
|
|
213
318
|
side so client codegen can import it without dragging in the query layer).
|
|
214
319
|
`target_actor_id` lives parallel to `target_account_id` on both row
|
|
215
320
|
and input. **Rule** — `target_actor_id` is populated when the event
|
|
216
|
-
subject is bound to a specific actor. Concretely: `
|
|
217
|
-
and `
|
|
321
|
+
subject is bound to a specific actor. Concretely: `role_grant_revoke`
|
|
322
|
+
and `role_grant_create` (admin direct-grant, self-service toggle, and
|
|
218
323
|
in-tx accept all populate both target columns — the grantee is the
|
|
219
|
-
subject regardless of initiator), in-tx `
|
|
220
|
-
accept, and `
|
|
324
|
+
subject regardless of initiator), in-tx `role_grant_offer_accept` on
|
|
325
|
+
accept, and `role_grant_offer_decline` always populate both target
|
|
221
326
|
columns (decline joins `from_account_id` into the RETURNING so the
|
|
222
327
|
"both populated → same account" invariant holds uniformly).
|
|
223
|
-
Offer-shape events (`
|
|
328
|
+
Offer-shape events (`role_grant_offer_create`, `_expire`, `_retract`,
|
|
224
329
|
`_supersede`) populate `target_actor_id` when the offer was
|
|
225
|
-
actor-targeted at create time (`
|
|
330
|
+
actor-targeted at create time (`role_grant_offer.to_actor_id` set),
|
|
226
331
|
null when the offer was account-grain (any actor on
|
|
227
332
|
`to_account_id` may accept). Account-shape events (login, logout,
|
|
228
333
|
signup, bootstrap, password change, session/token revoke,
|
|
@@ -230,33 +335,33 @@ Zod enum; `AuditOutcome` is `'success' | 'failure'`.
|
|
|
230
335
|
`target_actor_id` **and** `actor_id` — the operation is performed
|
|
231
336
|
by the account, and a multi-actor user must be able to log out
|
|
232
337
|
(or change password, or revoke sessions) without first picking an
|
|
233
|
-
acting actor.
|
|
338
|
+
acting actor. Role-grant/admin/offer events keep recording the
|
|
234
339
|
initiator's actor in `actor_id`.
|
|
235
340
|
SSE/WS socket-close keys on `target_account_id ?? account_id`
|
|
236
341
|
(sessions stay account-grain at the routing layer even though
|
|
237
342
|
they bind to a specific actor at request-context resolution time —
|
|
238
343
|
see request_context.ts).
|
|
239
|
-
- **Actor-targetable offers** — `
|
|
344
|
+
- **Actor-targetable offers** — `role_grant_offer.to_actor_id` is the
|
|
240
345
|
optional column that flips an offer from account-grain (null,
|
|
241
|
-
default) to actor-grain (non-null). `
|
|
346
|
+
default) to actor-grain (non-null). `query_role_grant_offer_create`
|
|
242
347
|
validates the actor↔account binding in one SELECT and rejects with
|
|
243
|
-
`
|
|
348
|
+
`RoleGrantOfferActorAccountMismatchError` when the supplied actor isn't
|
|
244
349
|
on `to_account_id`. `query_accept_offer` rejects wrong-actor accepts
|
|
245
|
-
on actor-targeted offers with `
|
|
246
|
-
surfaced to RPC callers as `
|
|
350
|
+
on actor-targeted offers with `RoleGrantOfferActorMismatchError` —
|
|
351
|
+
surfaced to RPC callers as `role_grant_offer_actor_mismatch`. Closes the
|
|
247
352
|
audit hole where offer-shape events left `target_actor_id` null even
|
|
248
353
|
when the recipient binding was known at offer time.
|
|
249
|
-
- **`
|
|
250
|
-
for
|
|
251
|
-
target_account_id, target_actor_id, metadata, outcome?})`
|
|
252
|
-
the `actor_id` / `account_id` / `ip` boilerplate that every
|
|
253
|
-
`
|
|
254
|
-
`
|
|
255
|
-
`target_*_id` columns; reach for the lower-level
|
|
256
|
-
|
|
257
|
-
|
|
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).
|
|
258
363
|
- Client-safe: `AuditLogEventJson`, `AuditLogEventWithUsernamesJson`,
|
|
259
|
-
`
|
|
364
|
+
`RoleGrantHistoryEventJson`, `AdminSessionJson`.
|
|
260
365
|
- `get_audit_metadata(event)` type-narrows after checking `event_type`.
|
|
261
366
|
- DDL: `AUDIT_LOG_SCHEMA` (includes monotonically-increasing `seq SERIAL`
|
|
262
367
|
for cursor-based gap fill), `AUDIT_LOG_INDEXES`.
|
|
@@ -264,14 +369,15 @@ target_account_id, target_actor_id, metadata, outcome?})` and lifts
|
|
|
264
369
|
builds an `AuditLogConfig` merging builtins with consumer event-type
|
|
265
370
|
strings keyed to a Zod schema (validates metadata) or `null` (registers
|
|
266
371
|
without validation). Pass the result to `create_app_backend({audit_log_config})`
|
|
267
|
-
— it
|
|
268
|
-
|
|
372
|
+
— it gets captured inside the bound `AppDeps.audit` emitter, and every
|
|
373
|
+
call to `audit.emit` validates against it (defaults to
|
|
269
374
|
`BUILTIN_AUDIT_LOG_CONFIG` when absent). `query_audit_log` still accepts
|
|
270
375
|
the trailing `config` positional arg for in-transaction emit sites that
|
|
271
|
-
|
|
272
|
-
format failures throw at construction. The DB
|
|
273
|
-
(no enum), so consumer types round-trip
|
|
274
|
-
`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.
|
|
275
381
|
`AuditLogEvent.event_type` (row interface), `AuditLogEventJson.event_type`,
|
|
276
382
|
and the `audit_log_list` filter input are all `AuditEventTypeName`
|
|
277
383
|
(regex-validated string) — widened from the closed enum so consumer rows
|
|
@@ -290,67 +396,76 @@ target_account_id, target_actor_id, metadata, outcome?})` and lifts
|
|
|
290
396
|
accidental mutation (bugs, test cross-contamination, cast escapes)
|
|
291
397
|
into loud TypeErrors — not a security boundary.
|
|
292
398
|
|
|
293
|
-
###
|
|
399
|
+
### Role grant offer (`role_grant_offer_schema.ts`)
|
|
294
400
|
|
|
295
|
-
The consentful-
|
|
401
|
+
The consentful-role-grants surface. Key constants:
|
|
296
402
|
|
|
297
|
-
- `
|
|
403
|
+
- `ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID = '00000000-…'` — all-zeros UUID used
|
|
298
404
|
inside `COALESCE(scope_id, sentinel)` in partial unique indexes to collapse
|
|
299
405
|
NULL scopes into a comparable value. Without this, Postgres's NULL-in-
|
|
300
406
|
unique-index quirk would allow duplicate global pending offers.
|
|
301
|
-
- `
|
|
302
|
-
|
|
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).
|
|
303
414
|
|
|
304
415
|
DDL:
|
|
305
416
|
|
|
306
|
-
- `
|
|
417
|
+
- `ROLE_GRANT_OFFER_SCHEMA` carries four nullable terminal timestamps:
|
|
307
418
|
`accepted_at`, `declined_at`, `retracted_at`, **`superseded_at`** (fourth
|
|
308
|
-
terminal — obsoleted by sibling accept or revoke of the resulting
|
|
309
|
-
|
|
310
|
-
- `
|
|
311
|
-
- `
|
|
312
|
-
- `
|
|
313
|
-
- `
|
|
314
|
-
|
|
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)`
|
|
315
428
|
where all four terminal timestamps are null. Including `from_actor_id`
|
|
316
429
|
lets multiple grantors coexist (teacher A and B can both offer the same
|
|
317
430
|
student role). A same-grantor re-offer upserts the pending row. The
|
|
318
|
-
`ON CONFLICT` target in `
|
|
319
|
-
expression literally
|
|
320
|
-
|
|
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
|
|
321
436
|
pending rows, soonest-expiry first.
|
|
322
437
|
|
|
323
438
|
Types:
|
|
324
439
|
|
|
325
|
-
- `
|
|
326
|
-
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`
|
|
327
442
|
notifications without a second round trip).
|
|
328
|
-
- `
|
|
443
|
+
- `CreateRoleGrantOfferInput` (`expires_at` is required — query layer applies
|
|
329
444
|
no default).
|
|
330
|
-
- `
|
|
331
|
-
with `
|
|
445
|
+
- `RoleGrantOfferJson` (with `.meta({description})` on every field) paired
|
|
446
|
+
with `to_role_grant_offer_json(offer)`.
|
|
332
447
|
|
|
333
|
-
### WS notifications (`
|
|
448
|
+
### WS notifications (`role_grant_offer_notifications.ts`)
|
|
334
449
|
|
|
335
450
|
Six `RemoteNotificationActionSpec`s fan notifications to affected sockets:
|
|
336
451
|
|
|
337
|
-
| Method
|
|
338
|
-
|
|
|
339
|
-
| `
|
|
340
|
-
| `
|
|
341
|
-
| `
|
|
342
|
-
| `
|
|
343
|
-
| `
|
|
344
|
-
| `
|
|
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?}` |
|
|
345
460
|
|
|
346
|
-
Method constants: `
|
|
461
|
+
Method constants: `ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD`,
|
|
347
462
|
`_RETRACTED_`, `_ACCEPTED_`, `_DECLINED_`, `_SUPERSEDE_`,
|
|
348
|
-
`
|
|
349
|
-
exports: `
|
|
350
|
-
`_DeclinedParams`, `_SupersedeParams`, `
|
|
351
|
-
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.
|
|
352
467
|
|
|
353
|
-
`
|
|
468
|
+
`ROLE_GRANT_OFFER_NOTIFICATION_SPECS: Array<EventSpec>` — pass to
|
|
354
469
|
`create_app_server`'s `event_specs` so the attack surface reflects them
|
|
355
470
|
and DEV-mode `create_validated_broadcaster` catches payload drift.
|
|
356
471
|
|
|
@@ -359,7 +474,7 @@ and DEV-mode `create_validated_broadcaster` catches payload drift.
|
|
|
359
474
|
structurally satisfies it (its signature accepts the broader
|
|
360
475
|
`JsonrpcMessageFromServerToClient`, contravariantly compatible). Target
|
|
361
476
|
account travels via the send argument, not the payload — `revoked_by` is
|
|
362
|
-
deliberately not in the `
|
|
477
|
+
deliberately not in the `role_grant_revoke` payload (the revokee doesn't need
|
|
363
478
|
to learn the admin's identity).
|
|
364
479
|
|
|
365
480
|
## Queries
|
|
@@ -377,8 +492,12 @@ CRUD + listing:
|
|
|
377
492
|
indexes).
|
|
378
493
|
- `query_account_by_username_or_email(deps, input)` — if `@` in input, tries
|
|
379
494
|
email first; else username first. Single login field accepting either.
|
|
380
|
-
- `query_update_account_password
|
|
381
|
-
|
|
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.
|
|
382
501
|
- `query_account_has_any` — used by bootstrap for belt-and-suspenders check.
|
|
383
502
|
- `query_actors_by_account` — list every actor on an account, ordered
|
|
384
503
|
by `created_at`. Used by `resolve_acting_actor` to pick the unique
|
|
@@ -386,18 +505,24 @@ CRUD + listing:
|
|
|
386
505
|
account has multiple actors.
|
|
387
506
|
- `query_actor_by_id` — direct lookup by id; preferred when the caller
|
|
388
507
|
already has an actor id in scope.
|
|
389
|
-
- `query_admin_account_list` — composes accounts + actors +
|
|
390
|
-
pending inbound offers
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
397
522
|
`SELECT` both use `COALESCE(scope_id, sentinel)`. The fallback `SELECT`
|
|
398
523
|
uses `IS NOT DISTINCT FROM` (plain `=` would miss the NULL-scope conflict
|
|
399
524
|
case).
|
|
400
|
-
- `
|
|
525
|
+
- `query_role_grant_find_active_role_for_actor(deps, role_grant_id, actor_id)` —
|
|
401
526
|
actor-scoped read, so IDOR protection is consistent with revoke.
|
|
402
527
|
Returns `{role, account_id}` (the actor's `account_id` joined in) or
|
|
403
528
|
`null`. The `account_id` flows into the audit envelope's
|
|
@@ -406,92 +531,92 @@ CRUD + listing:
|
|
|
406
531
|
the revoke handler into one read closes the small TOCTOU window
|
|
407
532
|
where the actor row could be deleted between the IDOR check and the
|
|
408
533
|
actor lookup.
|
|
409
|
-
- **`
|
|
410
|
-
actor-scoped IDOR guard (returns `null` if the
|
|
411
|
-
different actor). Supersedes pending offers for the revoked
|
|
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
|
|
412
537
|
`(to_account, role, scope)` in the **same transaction** via a CTE that
|
|
413
538
|
joins `actor` to surface each sibling's `from_account_id`. Returns
|
|
414
|
-
`
|
|
539
|
+
`RevokeRoleGrantResult = {id, role, scope_id, superseded_offers}`. Closes the
|
|
415
540
|
"accept a pre-revoke offer to bypass the revoke" path — the stale offer
|
|
416
541
|
becomes terminal at revoke time.
|
|
417
|
-
- `
|
|
418
|
-
- `
|
|
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`
|
|
419
544
|
handles the NULL case. Omitted scope matches `scope_id IS NULL` (pre-scope
|
|
420
545
|
callers keep semantics). Use only when checking an arbitrary `actor_id`
|
|
421
546
|
that isn't the request actor (e.g., post-mutation verification, scripts,
|
|
422
547
|
audit-time checks). For the request actor, prefer `has_scoped_role` /
|
|
423
|
-
`has_any_scoped_role` on the in-memory `auth.
|
|
424
|
-
- `
|
|
425
|
-
|
|
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
|
|
426
551
|
middleware to resolve the keeper account.
|
|
427
|
-
- `
|
|
428
|
-
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
|
|
429
554
|
matching pending offers. Returns `RevokeRoleResult = {revoked, superseded_offers}`.
|
|
430
|
-
- **`
|
|
555
|
+
- **`query_role_grant_revoke_for_scope(deps, scope_id, revoked_by, reason?)`** —
|
|
431
556
|
parent-scope cascade for polymorphic `scope_id` consumers. Revokes every
|
|
432
|
-
active
|
|
557
|
+
active role_grant at `scope_id` (role-agnostic) and supersedes every pending
|
|
433
558
|
offer at `scope_id` (tuple-matched and orphan, undifferentiated) in the
|
|
434
559
|
caller's transaction. Returns `RevokeForScopeResult = {revoked, superseded_offers}`
|
|
435
560
|
— `revoked` carries both `actor_id` (drives `target_actor_id` audit
|
|
436
561
|
envelopes) and `account_id` (drives `target_account_id` for socket-close
|
|
437
562
|
fan-out); `superseded_offers` carries `from_account_id`. Caller emits
|
|
438
|
-
`
|
|
563
|
+
`role_grant_offer_supersede` audits with `reason: 'scope_destroyed'` and
|
|
439
564
|
`cause_id: <destroyed scope row id>` per superseded offer (the cause is
|
|
440
|
-
the scope deletion, not any individual
|
|
441
|
-
consumer's parent-row delete handler when `
|
|
442
|
-
`
|
|
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
|
|
443
568
|
consumer is about to drop.
|
|
444
569
|
|
|
445
|
-
### `
|
|
570
|
+
### `role_grant_offer_queries.ts`
|
|
446
571
|
|
|
447
572
|
Error classes (all extend `Error` with stable `.name` — never use
|
|
448
573
|
`instanceof` against plain messages):
|
|
449
574
|
|
|
450
|
-
- `
|
|
575
|
+
- `RoleGrantOfferSelfTargetError` — grantor offered themselves. Enforced
|
|
451
576
|
via a single SELECT on the grantor's `actor.account_id` in
|
|
452
|
-
`
|
|
577
|
+
`query_role_grant_offer_create` (resolving from the grantor side keeps
|
|
453
578
|
the check multi-actor-correct — the grantor → account binding stays
|
|
454
579
|
1:1 by definition of `actor`, while the recipient account may host
|
|
455
580
|
many actors under multi-actor).
|
|
456
|
-
- `
|
|
581
|
+
- `RoleGrantOfferAlreadyTerminalError` — offer exists for the caller but is
|
|
457
582
|
accepted / declined / retracted / superseded.
|
|
458
|
-
- `
|
|
583
|
+
- `RoleGrantOfferExpiredError` — pending but past `expires_at` (distinct from
|
|
459
584
|
terminal; different user-facing story: "ask the grantor to re-send").
|
|
460
|
-
- `
|
|
585
|
+
- `RoleGrantOfferNotFoundError` — not found or belongs to a different recipient
|
|
461
586
|
(standard 404-over-403 IDOR mask; callers never reveal which).
|
|
462
587
|
|
|
463
588
|
Queries:
|
|
464
589
|
|
|
465
|
-
- `
|
|
590
|
+
- `query_role_grant_offer_create` — INSERT with upsert-on-pending keyed by
|
|
466
591
|
`(to_account, role, scope, from_actor)`. Same-grantor re-offer refreshes
|
|
467
592
|
`message` + `expires_at` only. A terminal-state row with the same tuple
|
|
468
593
|
does not block a fresh INSERT.
|
|
469
|
-
- `
|
|
594
|
+
- `query_role_grant_offer_decline(deps, id, to_account_id, reason)` — IDOR
|
|
470
595
|
guarded by `to_account_id`. `resolve_terminal_or_missing` helper
|
|
471
596
|
distinguishes "not found / different recipient" from "already terminal".
|
|
472
|
-
- `
|
|
597
|
+
- `query_role_grant_offer_retract(deps, id, from_actor_id)` — IDOR guarded by
|
|
473
598
|
grantor actor.
|
|
474
|
-
- `
|
|
599
|
+
- `query_role_grant_offer_list(deps, to_account_id)` — pending + non-expired +
|
|
475
600
|
non-superseded, soonest expiry first.
|
|
476
|
-
- `
|
|
601
|
+
- `query_role_grant_offer_history_for_account(deps, account_id, limit?, offset?)` —
|
|
477
602
|
both directions (recipient or grantor), includes terminal rows, newest
|
|
478
603
|
first.
|
|
479
|
-
- `
|
|
480
|
-
- `
|
|
481
|
-
`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
|
|
482
607
|
per-row (no tombstone — caller is responsible for idempotency).
|
|
483
608
|
- **`query_accept_offer(deps, input)`** — atomic, must run inside a
|
|
484
609
|
transaction. Row-locks with `SELECT ... FOR UPDATE` (concurrent callers
|
|
485
610
|
block until commit / rollback, then branch idempotently). Inserts the
|
|
486
|
-
|
|
487
|
-
`accepted_at` + `
|
|
488
|
-
`
|
|
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
|
|
489
614
|
offers for `(to_account, role, scope)` via CTE joined to `actor` for
|
|
490
|
-
grantor `account_id`, and emits `
|
|
491
|
-
- one `
|
|
492
|
-
pre-existing
|
|
493
|
-
/ `audit_events`. Error map: `
|
|
494
|
-
`
|
|
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
|
|
495
620
|
supersede is what forecloses the "accept a pre-revoke sibling later to
|
|
496
621
|
get the role back" path.
|
|
497
622
|
|
|
@@ -547,8 +672,15 @@ Server-side sessions, keyed by blake3 hash of the session token:
|
|
|
547
672
|
- `query_invite_find_unclaimed_match(deps, email, username)` — three scoping
|
|
548
673
|
modes: email-only invite needs signup-email match; username-only invite
|
|
549
674
|
needs signup-username match; both-field invite requires both to match.
|
|
550
|
-
-
|
|
551
|
-
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.
|
|
552
684
|
- `query_invite_list_all`, `query_invite_list_all_with_usernames` (joins to
|
|
553
685
|
`actor` for `created_by_username` and `account` for `claimed_by_username`).
|
|
554
686
|
- `query_invite_delete_unclaimed` — IDOR not a concern (admin-only surface),
|
|
@@ -583,34 +715,73 @@ run'` if the seed somehow missed (defensive — migrations always seed).
|
|
|
583
715
|
`target_actor_id` is on the row but not currently joined to actor
|
|
584
716
|
for a name; the admin viewer will resolve via `actor_lookup` /
|
|
585
717
|
`actor.name` when the actor-grain forensics pass lands.
|
|
586
|
-
- `
|
|
587
|
-
(filters to `permit_grant` / `permit_revoke`).
|
|
718
|
+
- `query_audit_log_list_role_grant_history` (filters to `role_grant_create` / `role_grant_revoke`).
|
|
588
719
|
- `query_audit_log_cleanup_before`.
|
|
589
|
-
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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`.
|
|
599
764
|
|
|
600
765
|
### `migrations.ts`
|
|
601
766
|
|
|
602
|
-
- `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).
|
|
603
768
|
- `AUTH_MIGRATIONS`:
|
|
604
769
|
- **v0 `full_auth_schema`** — every table + index + seed for the v1
|
|
605
|
-
identity system (account, actor,
|
|
770
|
+
identity system (account, actor, role_grant, auth_session, api_token,
|
|
606
771
|
audit_log, bootstrap_lock, invite, app_settings). All
|
|
607
772
|
`IF NOT EXISTS` — idempotent replay.
|
|
608
|
-
- **v1 `
|
|
609
|
-
plus its two partial indexes; adds `
|
|
610
|
-
`
|
|
611
|
-
`
|
|
612
|
-
`
|
|
613
|
-
`
|
|
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).
|
|
614
785
|
- Forward-only (no down). Migrations are `{name, up}` objects; the name
|
|
615
786
|
surfaces in error messages.
|
|
616
787
|
|
|
@@ -636,10 +807,16 @@ by `sequence`, then enforces:
|
|
|
636
807
|
While fuz_app is pre-stable, migration bodies, names, and positions can
|
|
637
808
|
change freely between versions and consumers upgrading across a schema
|
|
638
809
|
change are expected to drop and re-bootstrap their dev/test databases.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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).
|
|
643
820
|
|
|
644
821
|
`MigrationError` is the only error class thrown from `run_migrations` /
|
|
645
822
|
`baseline`; branch on `.kind` (never on message text). Kinds:
|
|
@@ -711,7 +888,7 @@ assembly order. Two-phase identity:
|
|
|
711
888
|
|
|
712
889
|
- **Authentication** runs in middleware (session / bearer / daemon
|
|
713
890
|
token). Sets `c.var.account_id` + `CREDENTIAL_TYPE_KEY` on a valid
|
|
714
|
-
credential. Account-only — never loads actor or
|
|
891
|
+
credential. Account-only — never loads actor or role_grants, never
|
|
715
892
|
populates `REQUEST_CONTEXT_KEY`. **Production-middleware invariant**:
|
|
716
893
|
no production middleware on the auth path (session / bearer / daemon
|
|
717
894
|
token) populates `REQUEST_CONTEXT_KEY`; identity-related context vars
|
|
@@ -726,42 +903,55 @@ assembly order. Two-phase identity:
|
|
|
726
903
|
actor resolution; production code that consults
|
|
727
904
|
`REQUEST_CONTEXT_KEY` is reading test escape-hatch state, never live
|
|
728
905
|
middleware output.
|
|
729
|
-
- **Authorization** runs
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
`
|
|
735
|
-
|
|
736
|
-
actor-bound `RequestContext
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
`
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
the
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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.
|
|
755
943
|
|
|
756
944
|
Session parsing is separate from auth enforcement — login / bootstrap
|
|
757
|
-
participate in cookie refresh without being blocked. `require_auth
|
|
758
|
-
`require_role
|
|
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`).
|
|
759
949
|
|
|
760
950
|
### `request_context.ts`
|
|
761
951
|
|
|
762
|
-
- `RequestContext = {account, actor: Actor | null,
|
|
763
|
-
is null on account-grain routes (no `acting`, no
|
|
764
|
-
auth); `
|
|
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.
|
|
765
955
|
- `REQUEST_CONTEXT_KEY` — Hono context variable name.
|
|
766
956
|
- **`AUTH_SESSION_TOKEN_HASH_KEY`** — holds the blake3 session hash. Set on
|
|
767
957
|
successful session lookup; `null` for unauthenticated or non-session
|
|
@@ -770,19 +960,21 @@ participate in cookie refresh without being blocked. `require_auth` /
|
|
|
770
960
|
stream on `session_revoke`).
|
|
771
961
|
- `get_request_context(c)`, `require_request_context(c)` (throws on
|
|
772
962
|
misuse — handler ran without authorization phase wiring).
|
|
773
|
-
- **In-memory
|
|
963
|
+
- **In-memory role_grant predicates** — `has_role(ctx, role, now?)`,
|
|
774
964
|
`has_scoped_role(ctx, role, scope_id, now?)`,
|
|
775
965
|
`has_any_scoped_role(ctx, roles, scope_id, now?)`. All three take
|
|
776
966
|
`RequestContext | null` and return `false` for null ctx and for
|
|
777
|
-
account-grain ctx (`actor: null`, empty `
|
|
778
|
-
`
|
|
779
|
-
`
|
|
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
|
|
780
972
|
exact scope. Empty `roles` short-circuits `has_any_scoped_role` to
|
|
781
973
|
`false`. Decide-time predicates only — the predicate / mutation
|
|
782
|
-
race window is the same as the SQL `
|
|
974
|
+
race window is the same as the SQL `query_role_grant_has_role` style and
|
|
783
975
|
only a transactional re-check inside the UPDATE/INSERT closes it.
|
|
784
976
|
- `build_request_context(deps, account_id, actor_id)` — loads
|
|
785
|
-
`account` + the named `actor` + active
|
|
977
|
+
`account` + the named `actor` + active role_grants. Verifies
|
|
786
978
|
`actor.account_id === account.id`; returns `null` when the account
|
|
787
979
|
or actor is missing, or when they don't bind to each other. Called
|
|
788
980
|
by the authorization phase after `resolve_acting_actor` succeeds —
|
|
@@ -796,19 +988,35 @@ participate in cookie refresh without being blocked. `require_auth` /
|
|
|
796
988
|
available list when multi-actor and `acting` is missing;
|
|
797
989
|
`actor_not_on_account` when supplied id doesn't belong; `no_actors`
|
|
798
990
|
defensively.
|
|
799
|
-
- `
|
|
991
|
+
- `refresh_role_grants(ctx, deps)` — reloads role_grants without mutating the
|
|
800
992
|
original (concurrent-safe). Useful for long-lived WebSocket
|
|
801
993
|
connections that have an acting actor.
|
|
802
994
|
- `create_request_context_middleware(deps, log, session_context_key?)` —
|
|
803
995
|
validates the session and sets `c.var.account_id` +
|
|
804
996
|
`CREDENTIAL_TYPE_KEY = 'session'` + `AUTH_SESSION_TOKEN_HASH_KEY`.
|
|
805
|
-
Touches the session fire-and-forget. Does not load actor /
|
|
997
|
+
Touches the session fire-and-forget. Does not load actor / role_grants.
|
|
806
998
|
- `require_auth` — 401 (`ERROR_AUTHENTICATION_REQUIRED`) when
|
|
807
999
|
`account_id` is null. Does not require an acting actor.
|
|
808
|
-
- `require_role(
|
|
809
|
-
(`ERROR_INSUFFICIENT_PERMISSIONS` + `
|
|
810
|
-
don't carry
|
|
811
|
-
role-gated route always
|
|
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']}`.
|
|
812
1020
|
|
|
813
1021
|
### `bearer_auth.ts`
|
|
814
1022
|
|
|
@@ -825,32 +1033,27 @@ participate in cookie refresh without being blocked. `require_auth` /
|
|
|
825
1033
|
- Rate limiter: `record` before async DB work to close the TOCTOU window;
|
|
826
1034
|
`reset` on valid token.
|
|
827
1035
|
|
|
828
|
-
###
|
|
829
|
-
|
|
830
|
-
Two-part type guard:
|
|
831
|
-
|
|
832
|
-
1. `credential_type` must be `'daemon_token'` (not session, not API token).
|
|
833
|
-
A session cookie from the bootstrap account still fails this check.
|
|
834
|
-
2. Active `keeper` permit.
|
|
1036
|
+
### Keeper auth (no dedicated module)
|
|
835
1037
|
|
|
836
|
-
|
|
837
|
-
`
|
|
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`).
|
|
838
1045
|
|
|
839
|
-
### `session_middleware.ts`
|
|
840
|
-
|
|
841
|
-
`session_middleware.ts`:
|
|
1046
|
+
### `session_middleware.ts`
|
|
842
1047
|
|
|
843
1048
|
- `get_session_cookie`, `set_session_cookie`, `clear_session_cookie`.
|
|
844
1049
|
- `create_session_middleware(keyring, options)` — always sets the
|
|
845
1050
|
identity on context (null when invalid/missing) for type-safe reads.
|
|
846
1051
|
Acts on `process_session_cookie`'s `action` (`'clear'` / `'refresh'` /
|
|
847
1052
|
`'none'`).
|
|
848
|
-
|
|
849
|
-
`session_lifecycle.ts` — shared by login and bootstrap:
|
|
850
|
-
|
|
851
1053
|
- `create_session_and_set_cookie({keyring, deps, c, account_id, session_options, max_sessions?})` —
|
|
852
|
-
|
|
853
|
-
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.
|
|
854
1057
|
|
|
855
1058
|
### `daemon_token_middleware.ts`
|
|
856
1059
|
|
|
@@ -859,7 +1062,7 @@ Returns 401 on no context, 403 (`ERROR_KEEPER_REQUIRES_DAEMON_TOKEN` or
|
|
|
859
1062
|
or `null` if `$HOME` unset.
|
|
860
1063
|
- `write_daemon_token(runtime, path, token)` — atomic (temp + rename);
|
|
861
1064
|
`chmod 0600` if available.
|
|
862
|
-
- `resolve_keeper_account_id(deps)` — wraps `
|
|
1065
|
+
- `resolve_keeper_account_id(deps)` — wraps `query_role_grant_find_account_id_for_role(ROLE_KEEPER)`.
|
|
863
1066
|
- `start_daemon_token_rotation(runtime, deps, options, log)` — writes initial
|
|
864
1067
|
token, resolves keeper, sets up interval. Returns `{state, stop}`. The
|
|
865
1068
|
interval guard `writing` skips the next rotation if the prior write is
|
|
@@ -911,18 +1114,17 @@ Session-based auth route specs. Factory: `create_account_route_specs(deps, optio
|
|
|
911
1114
|
`account_verify` RPC action — that surface carries the typed
|
|
912
1115
|
`SessionAccountJson` payload.
|
|
913
1116
|
- `create_account_status_route_spec(options?)` — `GET /api/account/status`
|
|
914
|
-
returns `{account, actor,
|
|
1117
|
+
returns `{account, actor, role_grants}` on 200 or 401 with optional
|
|
915
1118
|
`bootstrap_available` flag. `actor` is the caller's own
|
|
916
1119
|
`ActorSummaryJson` so clients don't need to derive `actor_id` from
|
|
917
|
-
the
|
|
1120
|
+
the role_grant list. Lets the frontend fetch both session state
|
|
918
1121
|
and bootstrap availability in one request (eliminates a separate `/health`
|
|
919
1122
|
round trip).
|
|
920
1123
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
`
|
|
924
|
-
|
|
925
|
-
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` /
|
|
926
1128
|
`query_revoke_api_token_for_account`; `Blake3Hash` on session ids;
|
|
927
1129
|
`ApiTokenId` regex on token ids; `max_tokens` enforcement via
|
|
928
1130
|
`query_api_token_enforce_limit`).
|
|
@@ -959,31 +1161,34 @@ Constants:
|
|
|
959
1161
|
- `POST /signup` — `transaction: false` (manages its own). When
|
|
960
1162
|
`app_settings.open_signup` is false, requires a matching unclaimed invite.
|
|
961
1163
|
On `open_signup: true` path, no invite check.
|
|
962
|
-
- Transaction body: `query_create_account_with_actor` → `
|
|
1164
|
+
- Transaction body: `query_create_account_with_actor` → `query_invite_claim_unscoped`
|
|
963
1165
|
(if invite present; throws `SignupConflictError` on race — another claim
|
|
964
1166
|
won) → `create_session_and_set_cookie`. Catches
|
|
965
1167
|
`is_pg_unique_violation(e)` → 409 `ERROR_SIGNUP_CONFLICT` (username or
|
|
966
1168
|
email already exists).
|
|
967
1169
|
- Error shapes: 403 `ERROR_NO_MATCHING_INVITE`, 409 `ERROR_SIGNUP_CONFLICT`.
|
|
968
1170
|
|
|
969
|
-
### `
|
|
1171
|
+
### `auth_guard_resolver.ts`
|
|
970
1172
|
|
|
971
|
-
`fuz_auth_guard_resolver: AuthGuardResolver` — maps
|
|
972
|
-
|
|
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`.
|
|
973
1178
|
Injected into `apply_route_specs` so the generic HTTP framework stays
|
|
974
1179
|
auth-agnostic (see `../http/CLAUDE.md` §Validation pipeline for where it plugs in).
|
|
975
1180
|
|
|
976
|
-
### `audit_log_routes.ts`
|
|
1181
|
+
### `audit_log_routes.ts`
|
|
977
1182
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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:
|
|
981
1186
|
|
|
982
1187
|
- **`GET /audit/stream`** — optional, wired only when
|
|
983
1188
|
`AuditLogRouteOptions.stream` is passed. Streams aren't an RPC concern.
|
|
984
1189
|
Uses `AUTH_SESSION_TOKEN_HASH_KEY` for SSE `scope` identity (so
|
|
985
1190
|
`session_revoke` can close only that session's stream); `groups: [account_id]`
|
|
986
|
-
for coarse close on `
|
|
1191
|
+
for coarse close on `role_grant_revoke` / `session_revoke_all` / `password_change`.
|
|
987
1192
|
|
|
988
1193
|
`create_audit_log_route_specs(options?)` — returns an empty array when
|
|
989
1194
|
`options.stream` is not set; `required_role` defaults to `'admin'`.
|
|
@@ -999,7 +1204,7 @@ Each surface is split across two files:
|
|
|
999
1204
|
(no per-method `*_METHOD` string constants — read `.method` off the spec),
|
|
1000
1205
|
and `all_*_action_specs: Array<RequestResponseActionSpec>` codegen-ready
|
|
1001
1206
|
registry. Plus any reason-string constants exported to the wire contract
|
|
1002
|
-
(e.g. `
|
|
1207
|
+
(e.g. `ERROR_ROLE_GRANT_OFFER_*` for role_grant offers).
|
|
1003
1208
|
- `*_actions.ts` — `create_*_actions(deps, options) => Array<RpcAction>` factory
|
|
1004
1209
|
containing handler closures, the `*ActionDeps` / `*ActionOptions` interfaces,
|
|
1005
1210
|
and any handler-only helpers. Imports the specs from its sibling.
|
|
@@ -1010,25 +1215,29 @@ skips the handler module's transitive query-layer deps.
|
|
|
1010
1215
|
|
|
1011
1216
|
### `admin_action_specs.ts` + `admin_actions.ts` — eleven admin-only RPC actions
|
|
1012
1217
|
|
|
1013
|
-
Authorization is **spec-level**
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
|
1024
|
-
|
|
|
1025
|
-
| `
|
|
1026
|
-
| `
|
|
1027
|
-
| `
|
|
1028
|
-
| `
|
|
1029
|
-
| `
|
|
1030
|
-
| `
|
|
1031
|
-
| `
|
|
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}` |
|
|
1032
1241
|
|
|
1033
1242
|
Mutating admin specs declare `rate_limit: 'account'` — keyed on the
|
|
1034
1243
|
admin's `request_context.actor.id`. The dispatcher's per-action hook
|
|
@@ -1040,8 +1249,7 @@ per actor — permissive enough for any human admin workflow, slow enough
|
|
|
1040
1249
|
that scripted oracles surface in audit. Tighten downstream via
|
|
1041
1250
|
`AppServerOptions.action_account_rate_limiter`.
|
|
1042
1251
|
|
|
1043
|
-
`AUDIT_LOG_LIST_LIMIT_MAX = 200` — page size clamp
|
|
1044
|
-
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`.
|
|
1045
1253
|
|
|
1046
1254
|
Error reasons returned via `error.data.reason`:
|
|
1047
1255
|
|
|
@@ -1056,9 +1264,9 @@ Audit events fired by handlers (all pass `ip: ctx.client_ip` for
|
|
|
1056
1264
|
transport-uniform forensics — matches the REST convention and the
|
|
1057
1265
|
self-service `account_actions.ts` surface):
|
|
1058
1266
|
|
|
1059
|
-
- `session_revoke_all` / `token_revoke_all` via `
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
|
1062
1270
|
forensic visibility — `target_account_id` is null (FK to `account`
|
|
1063
1271
|
rejects references to missing ids), and the probed id is preserved
|
|
1064
1272
|
under `metadata.attempted_account_id`. Metadata schema widening in
|
|
@@ -1069,8 +1277,9 @@ self-service `account_actions.ts` surface):
|
|
|
1069
1277
|
|
|
1070
1278
|
Closure state:
|
|
1071
1279
|
|
|
1072
|
-
- `grantable_roles` is derived once from `options.roles?.
|
|
1073
|
-
|
|
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.
|
|
1074
1283
|
- `options.app_settings` — when provided, captured by the
|
|
1075
1284
|
`app_settings_get` / `app_settings_update` handlers. Update handler
|
|
1076
1285
|
**mutates the ref** (`open_signup`, `updated_at`, `updated_by`) so
|
|
@@ -1082,70 +1291,79 @@ Closure state:
|
|
|
1082
1291
|
`all_admin_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
|
|
1083
1292
|
registry of all eleven specs (always includes the two app-settings specs).
|
|
1084
1293
|
|
|
1085
|
-
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).
|
|
1086
1295
|
|
|
1087
|
-
### `
|
|
1296
|
+
### `role_grant_offer_action_specs.ts` + `role_grant_offer_actions.ts` — seven RPC actions
|
|
1088
1297
|
|
|
1089
|
-
> **Hazard — admin `
|
|
1090
|
-
> returns `{offer}` only — no `
|
|
1091
|
-
> RPC call (`
|
|
1092
|
-
> 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
|
|
1093
1302
|
> `offer_and_accept` helper in `testing/admin_integration.ts`). The CHANGELOG
|
|
1094
|
-
> v0.31 entry "admin
|
|
1303
|
+
> v0.31 entry "admin create_role_grant routes emit offers instead of direct
|
|
1095
1304
|
> grants" was the first signal of this two-step flow; consumers reading the
|
|
1096
1305
|
> standard admin suite assume auto-accept and have to redesign their tests
|
|
1097
1306
|
> when they discover otherwise. If you need direct grant for a programmatic
|
|
1098
|
-
> path that already proves consent, reach for `
|
|
1307
|
+
> path that already proves consent, reach for `query_create_role_grant` rather
|
|
1099
1308
|
> than the RPC action.
|
|
1100
1309
|
|
|
1101
|
-
Six offer-lifecycle methods plus `
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
`
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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.
|
|
1119
1337
|
|
|
1120
1338
|
Every input row below also carries the shared `acting?: ActingActor`
|
|
1121
1339
|
field that the dispatcher's authorization phase reads off the raw
|
|
1122
1340
|
params (omitted from the table for brevity).
|
|
1123
1341
|
|
|
1124
|
-
| Spec
|
|
1125
|
-
|
|
|
1126
|
-
| `
|
|
1127
|
-
| `
|
|
1128
|
-
| `
|
|
1129
|
-
| `
|
|
1130
|
-
| `
|
|
1131
|
-
| `
|
|
1132
|
-
| `
|
|
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}` |
|
|
1133
1351
|
|
|
1134
1352
|
Error reason constants (exported as `as const` literals):
|
|
1135
1353
|
|
|
1136
|
-
- `
|
|
1137
|
-
- `
|
|
1138
|
-
- `
|
|
1139
|
-
- `
|
|
1140
|
-
- `
|
|
1141
|
-
- `
|
|
1142
|
-
- `
|
|
1143
|
-
`
|
|
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
|
|
1144
1362
|
belong to `to_account_id`)
|
|
1145
|
-
- `
|
|
1363
|
+
- `ERROR_ROLE_GRANT_OFFER_ACTOR_MISMATCH` (`'role_grant_offer_actor_mismatch'` —
|
|
1146
1364
|
actor-targeted offer was accepted by an actor other than `to_actor_id`)
|
|
1147
1365
|
|
|
1148
|
-
Plus re-uses from `../http/error_schemas.ts`: `
|
|
1366
|
+
Plus re-uses from `../http/error_schemas.ts`: `ERROR_ROLE_GRANT_NOT_FOUND`,
|
|
1149
1367
|
`ERROR_ROLE_NOT_WEB_GRANTABLE`, `ERROR_INSUFFICIENT_PERMISSIONS`,
|
|
1150
1368
|
`ERROR_ACCOUNT_NOT_FOUND`.
|
|
1151
1369
|
|
|
@@ -1154,80 +1372,85 @@ Each spec declares the reason codes its handler may surface (see
|
|
|
1154
1372
|
domain reasons returned via `error.data.reason` are listed; standard
|
|
1155
1373
|
transport errors (validation, auth, rate-limit) stay implicit. Drift
|
|
1156
1374
|
between declared reasons and handler throws is caught by
|
|
1157
|
-
`../../test/auth/
|
|
1375
|
+
`../../test/auth/role_grant_offer_actions.error_reasons.test.ts`.
|
|
1158
1376
|
|
|
1159
1377
|
Failure-outcome audit events emitted (success and failure rows both carry
|
|
1160
1378
|
`ip: ctx.client_ip` — uniform with the admin and self-service surfaces):
|
|
1161
1379
|
|
|
1162
|
-
- `
|
|
1380
|
+
- `role_grant_offer_create` failure — admin-grant-path denial, `authorize`
|
|
1163
1381
|
denial, self-target rejection, and actor-account mismatch all emit
|
|
1164
1382
|
the same audit row via `emit_create_failure_audit`. `target_account_id`
|
|
1165
1383
|
carries `input.to_account_id`; `target_actor_id` echoes
|
|
1166
1384
|
`input.to_actor_id` when supplied so failure rows match the
|
|
1167
1385
|
success-shape envelope of actor-targeted offers (null on
|
|
1168
1386
|
account-grain offers — see audit_log_schema rule).
|
|
1169
|
-
- `
|
|
1387
|
+
- `role_grant_revoke` failure — admin-grant-path denial after IDOR / role
|
|
1170
1388
|
lookup succeeded. The admin-role-denied path (pre-IDOR) emits no audit,
|
|
1171
1389
|
matching the middleware auth-guard precedent. `target_account_id` +
|
|
1172
1390
|
`target_actor_id` both populated (the IDOR-passing branch resolves
|
|
1173
1391
|
the target actor before the gate; the subject is an actor-bound
|
|
1174
|
-
|
|
1392
|
+
role_grant).
|
|
1175
1393
|
|
|
1176
1394
|
WS notifications (post-commit via `emit_after_commit` from
|
|
1177
1395
|
`../http/pending_effects.js` — swallows exceptions so one failed send
|
|
1178
1396
|
can't starve others; see `../http/CLAUDE.md` §Pending Effects):
|
|
1179
1397
|
|
|
1180
|
-
- Create → `
|
|
1181
|
-
- Retract → `
|
|
1182
|
-
- Accept → `
|
|
1183
|
-
`
|
|
1184
|
-
- Decline → `
|
|
1185
|
-
- 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
|
|
1186
1404
|
superseded sibling.
|
|
1187
1405
|
|
|
1188
|
-
Deps: `
|
|
1189
|
-
Notification sender is optional — when absent, WS
|
|
1190
|
-
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).
|
|
1191
1409
|
|
|
1192
1410
|
Options:
|
|
1193
1411
|
|
|
1194
|
-
- `roles?: RoleSchemaResult` — drives
|
|
1195
|
-
`
|
|
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`.
|
|
1196
1415
|
- `default_ttl_ms?: number` — applied to new offers (defaults to
|
|
1197
|
-
`
|
|
1198
|
-
- `authorize?:
|
|
1199
|
-
`
|
|
1416
|
+
`ROLE_GRANT_OFFER_DEFAULT_TTL_MS`).
|
|
1417
|
+
- `authorize?: RoleGrantOfferCreateAuthorize` — custom policy for
|
|
1418
|
+
`role_grant_offer_create`. Signature:
|
|
1200
1419
|
`(auth, input: {to_account_id, role, scope_id}, deps: Pick<RouteFactoryDeps, 'log'>, ctx: ActionContext) => boolean | Promise<boolean>`.
|
|
1201
1420
|
Pre-built option: `authorize_admin_or_holder` admits any admin and
|
|
1202
1421
|
otherwise falls back to the symmetric default (caller must hold the
|
|
1203
1422
|
offered role globally). Drop into
|
|
1204
|
-
`
|
|
1423
|
+
`create_role_grant_offer_actions({authorize: authorize_admin_or_holder})`
|
|
1205
1424
|
or any factory that forwards `authorize` (e.g. `create_standard_rpc_actions`)
|
|
1206
|
-
for the common "admins offer anything
|
|
1207
|
-
they hold" pattern.
|
|
1425
|
+
for the common "admins offer anything on the admin grant path; users
|
|
1426
|
+
offer what they hold" pattern.
|
|
1208
1427
|
|
|
1209
|
-
`
|
|
1428
|
+
`all_role_grant_offer_action_specs: Array<RequestResponseActionSpec>` —
|
|
1210
1429
|
codegen-ready registry.
|
|
1211
1430
|
|
|
1212
|
-
### `standard_rpc_actions.ts` — combined admin +
|
|
1431
|
+
### `standard_rpc_actions.ts` — combined admin + role-grant-offer + account factory
|
|
1213
1432
|
|
|
1214
1433
|
`create_standard_rpc_actions(deps, options)` spreads
|
|
1215
|
-
`create_admin_actions`, `
|
|
1434
|
+
`create_admin_actions`, `create_role_grant_offer_actions`, and
|
|
1216
1435
|
`create_account_actions` into a single `Array<RpcAction>` — the
|
|
1217
1436
|
canonical fuz_app "standard" RPC surface (25 actions with
|
|
1218
1437
|
`app_settings` wired, 23 without). Consumers that want a narrower
|
|
1219
1438
|
surface drop down to the per-domain factories directly.
|
|
1220
1439
|
|
|
1221
|
-
Option routing: `roles` is shared between admin and
|
|
1440
|
+
Option routing: `roles` is shared between admin and role-grant-offer;
|
|
1222
1441
|
`app_settings` flows to admin only; `default_ttl_ms` and `authorize`
|
|
1223
|
-
flow to
|
|
1224
|
-
`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 +
|
|
1225
1444
|
account ignore it).
|
|
1226
1445
|
|
|
1227
1446
|
`StandardRpcActionsOptions` composes `AdminActionOptions` +
|
|
1228
|
-
`
|
|
1229
|
-
`StandardRpcActionsDeps
|
|
1230
|
-
|
|
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.
|
|
1231
1454
|
|
|
1232
1455
|
Pair this with `create_app_server`'s `rpc_endpoints` factory form
|
|
1233
1456
|
(`(ctx) => Array<RpcEndpointSpec>`) so the combined action list gets
|
|
@@ -1237,7 +1460,7 @@ again in `create_route_specs`. See `../../../docs/usage.md` §Server
|
|
|
1237
1460
|
Assembly.
|
|
1238
1461
|
|
|
1239
1462
|
Pre-bundle consumers spread `create_admin_actions` and
|
|
1240
|
-
`
|
|
1463
|
+
`create_role_grant_offer_actions` separately, then also
|
|
1241
1464
|
`create_account_actions`. The bundled helper replaces all three —
|
|
1242
1465
|
bundling account actions into the "standard" surface is deliberate:
|
|
1243
1466
|
the admin integration suite exercises `account_token_create` /
|
|
@@ -1247,7 +1470,7 @@ consumer wiring the admin surface without account actions will hit
|
|
|
1247
1470
|
|
|
1248
1471
|
Frontend mirror: `all_standard_action_specs` (in
|
|
1249
1472
|
`./standard_action_specs.ts`) bundles `all_admin_action_specs +
|
|
1250
|
-
|
|
1473
|
+
all_role_grant_offer_action_specs + all_account_action_specs` into one
|
|
1251
1474
|
`ReadonlyArray<RequestResponseActionSpec>` for typed-client codegen
|
|
1252
1475
|
and `create_frontend_rpc_client({specs})` wiring. Self-service role
|
|
1253
1476
|
specs are not included (opt-in, app-specific `eligible_roles`) —
|
|
@@ -1264,8 +1487,10 @@ that was `/api/account/*` is on the RPC endpoint.
|
|
|
1264
1487
|
status-only probe, the RPC action returns `SessionAccountJson` for
|
|
1265
1488
|
programmatic callers.
|
|
1266
1489
|
|
|
1267
|
-
Authorization is **spec-level**
|
|
1268
|
-
|
|
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` /
|
|
1269
1494
|
`query_revoke_api_token_for_account` — passing another account's session
|
|
1270
1495
|
or token id returns `revoked: false` rather than revealing whether the id
|
|
1271
1496
|
exists.
|
|
@@ -1283,12 +1508,12 @@ exists.
|
|
|
1283
1508
|
`session_id` validates as `Blake3Hash`; `token_id` validates as
|
|
1284
1509
|
`ApiTokenId` (`tok_[A-Za-z0-9_-]{12}`).
|
|
1285
1510
|
|
|
1286
|
-
Audit events emitted (via `
|
|
1511
|
+
Audit events emitted (via `deps.audit.emit` with `ip: ctx.client_ip`):
|
|
1287
1512
|
`session_revoke`, `session_revoke_all`, `token_create`, `token_revoke`. The
|
|
1288
1513
|
IP is the resolved trusted-proxy value from `ActionContext.client_ip`,
|
|
1289
1514
|
matching the REST handler convention.
|
|
1290
1515
|
|
|
1291
|
-
Deps: `
|
|
1516
|
+
Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>`.
|
|
1292
1517
|
Options: `{max_tokens?: number | null}` — defaults to `DEFAULT_MAX_TOKENS`
|
|
1293
1518
|
from `account_routes.ts`; `null` disables the cap.
|
|
1294
1519
|
|
|
@@ -1304,45 +1529,50 @@ Zod schemas, the `satisfies RequestResponseActionSpec` literal, the
|
|
|
1304
1529
|
`*_actions.ts` factory imports the spec and pairs it with the handler.
|
|
1305
1530
|
|
|
1306
1531
|
One static `request_response` action — `self_service_role_set` — that
|
|
1307
|
-
takes `{role, enabled: boolean}` and toggles a global
|
|
1532
|
+
takes `{role, enabled: boolean}` and toggles a global role_grant on the
|
|
1308
1533
|
caller. Idempotent in both directions: `changed: false` when the
|
|
1309
1534
|
post-call state already matched the request (already-held when
|
|
1310
1535
|
enabling; not-held when disabling). Output is `{ok, enabled, changed}` —
|
|
1311
1536
|
`enabled` echoes the post-call state for self-describing responses.
|
|
1312
1537
|
Audit metadata carries `self_service: true` so admin reviewers can
|
|
1313
|
-
distinguish self-toggled
|
|
1314
|
-
`
|
|
1538
|
+
distinguish self-toggled role_grants from admin grants/offers. The
|
|
1539
|
+
`role_grant_create` / `role_grant_revoke` metadata schemas declare
|
|
1315
1540
|
`self_service: z.boolean().optional()` explicitly, so the field is
|
|
1316
1541
|
part of the documented surface rather than riding on `z.looseObject`
|
|
1317
1542
|
permissiveness.
|
|
1318
1543
|
|
|
1319
1544
|
Method name is static — `role` lives in the input, not the method
|
|
1320
|
-
name. Mirrors the `
|
|
1545
|
+
name. Mirrors the `role_grant_offer_create({role})` precedent. Per-role
|
|
1321
1546
|
parameterized methods would break the `satisfies RequestResponseActionSpec`
|
|
1322
1547
|
codegen invariant and grow the surface linearly per role.
|
|
1323
1548
|
|
|
1324
1549
|
`create_self_service_role_actions(deps, options)`:
|
|
1325
1550
|
|
|
1326
|
-
- `eligible_roles
|
|
1327
|
-
|
|
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
|
|
1328
1557
|
`role_not_self_service_eligible` (exported as
|
|
1329
1558
|
`ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE`). The eligibility check fires
|
|
1330
1559
|
before the `enabled` branch — same rejection regardless of direction.
|
|
1331
|
-
- `roles?: RoleSchemaResult` —
|
|
1332
|
-
`eligible_roles` is
|
|
1333
|
-
|
|
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.
|
|
1334
1564
|
|
|
1335
1565
|
Grant branch uses `has_scoped_role(auth, role, null)` for a
|
|
1336
1566
|
benign-TOCTOU pre-check (distinguishes new grant from idempotent
|
|
1337
|
-
re-grant) — reads from the in-memory `auth.
|
|
1338
|
-
roundtrip — then `
|
|
1339
|
-
`
|
|
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
|
|
1340
1570
|
`(actor, role, scope_id IS NULL)` row before calling
|
|
1341
|
-
`
|
|
1571
|
+
`query_revoke_role_grant`. Bundle is **not** included in
|
|
1342
1572
|
`create_standard_rpc_actions` — `eligible_roles` is app-specific, opt-in,
|
|
1343
1573
|
spread alongside the standard bundle when needed.
|
|
1344
1574
|
|
|
1345
|
-
Deps: `
|
|
1575
|
+
Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>`.
|
|
1346
1576
|
|
|
1347
1577
|
`all_self_service_role_action_specs: ReadonlyArray<RequestResponseActionSpec>` —
|
|
1348
1578
|
codegen-ready registry of the single unified spec.
|
|
@@ -1351,53 +1581,54 @@ codegen-ready registry of the single unified spec.
|
|
|
1351
1581
|
|
|
1352
1582
|
`cleanup.ts` — periodic auth maintenance:
|
|
1353
1583
|
|
|
1354
|
-
- `AuthCleanupDeps = QueryDeps & {log,
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
`
|
|
1358
|
-
|
|
1359
|
-
|
|
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.
|
|
1360
1594
|
- `run_auth_cleanup(deps)` — one-shot consumer entry point: expired
|
|
1361
1595
|
sessions + expired offers. Returns `{expired_sessions, expired_offers}`.
|
|
1362
1596
|
**Re-throws sweep errors** so the caller's scheduler can log / alert.
|
|
1363
1597
|
Call from `setInterval` / cron / similar.
|
|
1364
1598
|
|
|
1365
|
-
Idempotency: the audit log has no tombstone on `
|
|
1599
|
+
Idempotency: the audit log has no tombstone on `role_grant_offer_expire`, so
|
|
1366
1600
|
concurrent sweep runs double-audit. Deploy a single scheduled invocation
|
|
1367
1601
|
per instance — matches `query_session_cleanup_expired`'s expected pattern.
|
|
1368
1602
|
Expired offer rows are **preserved** (not deleted) — they carry audit value
|
|
1369
1603
|
for the history view, and accepted rows are the provenance for the
|
|
1370
|
-
resulting
|
|
1604
|
+
resulting role_grant.
|
|
1371
1605
|
|
|
1372
1606
|
## Deps
|
|
1373
1607
|
|
|
1374
1608
|
`deps.ts` defines:
|
|
1375
1609
|
|
|
1376
|
-
- **`AppDeps`** — the stateless capabilities bundle.
|
|
1610
|
+
- **`AppDeps`** — the stateless capabilities bundle. Seven members:
|
|
1377
1611
|
- `stat`, `read_text_file`, `delete_file` — filesystem.
|
|
1378
1612
|
- `keyring: Keyring` — HMAC-SHA256 signing.
|
|
1379
1613
|
- `password: PasswordHashDeps` — use `argon2_password_deps` in production.
|
|
1380
1614
|
- `db: Db` — pool-level instance (middleware uses this; route handlers
|
|
1381
1615
|
get a transaction-scoped `Db` via `RouteContext`).
|
|
1382
1616
|
- `log: Logger`.
|
|
1383
|
-
- `
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
`
|
|
1390
|
-
|
|
1391
|
-
|
|
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).
|
|
1392
1626
|
- **`RouteFactoryDeps = Omit<AppDeps, 'db'>`** — for route factories. Route
|
|
1393
1627
|
handlers receive DB access via `RouteContext`, so factories don't capture
|
|
1394
1628
|
a pool-level `Db`.
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
action-factory deps type (`AdminActionDeps`, `AccountActionDeps`,
|
|
1399
|
-
`PermitOfferActionDeps`, `SelfServiceRoleActionDeps`) so the five
|
|
1400
|
-
factories stop spelling the same `Pick` independently.
|
|
1629
|
+
|
|
1630
|
+
Action factories take `Pick<RouteFactoryDeps, 'log' | 'audit'>` directly
|
|
1631
|
+
(role-grant-offer adds `notification_sender?` inline).
|
|
1401
1632
|
|
|
1402
1633
|
See root `../../../CLAUDE.md` §AppDeps Vocabulary for the
|
|
1403
1634
|
capability / options / runtime-state split across the whole project.
|