@fuzdev/fuz_app 0.54.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +214 -103
- package/dist/actions/action_bridge.d.ts +8 -5
- package/dist/actions/action_bridge.d.ts.map +1 -1
- package/dist/actions/action_bridge.js +1 -11
- package/dist/actions/action_codegen.d.ts +32 -0
- package/dist/actions/action_codegen.d.ts.map +1 -1
- package/dist/actions/action_codegen.js +35 -15
- package/dist/actions/action_registry.d.ts.map +1 -1
- package/dist/actions/action_registry.js +5 -2
- package/dist/actions/action_rpc.d.ts +141 -22
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +106 -187
- package/dist/actions/action_spec.d.ts +55 -16
- package/dist/actions/action_spec.d.ts.map +1 -1
- package/dist/actions/action_spec.js +16 -11
- package/dist/actions/action_types.d.ts +28 -60
- package/dist/actions/action_types.d.ts.map +1 -1
- package/dist/actions/action_types.js +13 -5
- package/dist/actions/broadcast_api.d.ts +2 -2
- package/dist/actions/broadcast_api.js +2 -2
- package/dist/actions/compile_action_registry.d.ts +50 -0
- package/dist/actions/compile_action_registry.d.ts.map +1 -0
- package/dist/actions/compile_action_registry.js +69 -0
- package/dist/actions/heartbeat.d.ts +8 -4
- package/dist/actions/heartbeat.d.ts.map +1 -1
- package/dist/actions/heartbeat.js +5 -4
- package/dist/actions/perform_action.d.ts +145 -0
- package/dist/actions/perform_action.d.ts.map +1 -0
- package/dist/actions/perform_action.js +258 -0
- package/dist/actions/register_action_ws.d.ts +46 -40
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +101 -159
- package/dist/actions/register_ws_endpoint.d.ts +15 -10
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +54 -7
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +0 -4
- package/dist/actions/transports_ws_auth_guard.d.ts +1 -1
- package/dist/actions/transports_ws_auth_guard.js +1 -1
- package/dist/actions/transports_ws_backend.d.ts +1 -1
- package/dist/actions/transports_ws_backend.js +1 -1
- package/dist/auth/CLAUDE.md +794 -410
- package/dist/auth/account_action_specs.d.ts +28 -7
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +7 -7
- package/dist/auth/account_actions.d.ts +7 -13
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +26 -35
- package/dist/auth/account_queries.d.ts +52 -16
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +87 -38
- package/dist/auth/account_routes.d.ts +9 -11
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +118 -46
- package/dist/auth/account_schema.d.ts +46 -35
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +21 -28
- package/dist/auth/admin_action_specs.d.ts +100 -32
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +64 -33
- package/dist/auth/admin_actions.d.ts +13 -19
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +37 -41
- package/dist/auth/audit_emitter.d.ts +160 -0
- package/dist/auth/audit_emitter.d.ts.map +1 -0
- package/dist/auth/audit_emitter.js +83 -0
- package/dist/auth/audit_log_queries.d.ts +17 -48
- package/dist/auth/audit_log_queries.d.ts.map +1 -1
- package/dist/auth/audit_log_queries.js +20 -56
- package/dist/auth/audit_log_routes.d.ts +1 -1
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +7 -3
- package/dist/auth/audit_log_schema.d.ts +92 -32
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +75 -46
- package/dist/auth/auth_guard_resolver.d.ts +44 -0
- package/dist/auth/auth_guard_resolver.d.ts.map +1 -0
- package/dist/auth/auth_guard_resolver.js +56 -0
- package/dist/auth/bearer_auth.d.ts +9 -7
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +13 -21
- package/dist/auth/bootstrap_account.d.ts +7 -7
- package/dist/auth/bootstrap_account.d.ts.map +1 -1
- package/dist/auth/bootstrap_account.js +7 -7
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +11 -10
- package/dist/auth/cleanup.d.ts +20 -26
- package/dist/auth/cleanup.d.ts.map +1 -1
- package/dist/auth/cleanup.js +33 -42
- package/dist/auth/credential_type_schema.d.ts +115 -0
- package/dist/auth/credential_type_schema.d.ts.map +1 -0
- package/dist/auth/credential_type_schema.js +127 -0
- package/dist/auth/daemon_token_middleware.d.ts +23 -11
- package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.js +28 -22
- package/dist/auth/ddl.d.ts +2 -2
- package/dist/auth/ddl.d.ts.map +1 -1
- package/dist/auth/ddl.js +6 -6
- package/dist/auth/deps.d.ts +7 -18
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/grant_path_schema.d.ts +117 -0
- package/dist/auth/grant_path_schema.d.ts.map +1 -0
- package/dist/auth/grant_path_schema.js +137 -0
- package/dist/auth/invite_queries.d.ts +12 -1
- package/dist/auth/invite_queries.d.ts.map +1 -1
- package/dist/auth/invite_queries.js +12 -1
- package/dist/auth/invite_schema.d.ts +1 -1
- package/dist/auth/invite_schema.d.ts.map +1 -1
- package/dist/auth/invite_schema.js +1 -1
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +9 -4
- package/dist/auth/migrations.d.ts +37 -14
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +79 -32
- package/dist/auth/request_context.d.ts +331 -61
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +378 -95
- package/dist/auth/{permit_offer_action_specs.d.ts → role_grant_offer_action_specs.d.ts} +163 -94
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -0
- package/dist/auth/role_grant_offer_action_specs.js +262 -0
- package/dist/auth/role_grant_offer_actions.d.ts +104 -0
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -0
- package/dist/auth/role_grant_offer_actions.js +473 -0
- package/dist/auth/{permit_offer_notifications.d.ts → role_grant_offer_notifications.d.ts} +90 -70
- package/dist/auth/role_grant_offer_notifications.d.ts.map +1 -0
- package/dist/auth/role_grant_offer_notifications.js +182 -0
- package/dist/auth/role_grant_offer_queries.d.ts +242 -0
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -0
- package/dist/auth/role_grant_offer_queries.js +533 -0
- package/dist/auth/role_grant_offer_schema.d.ts +150 -0
- package/dist/auth/role_grant_offer_schema.d.ts.map +1 -0
- package/dist/auth/{permit_offer_schema.js → role_grant_offer_schema.js} +60 -36
- package/dist/auth/role_grant_queries.d.ts +231 -0
- package/dist/auth/role_grant_queries.d.ts.map +1 -0
- package/dist/auth/role_grant_queries.js +320 -0
- package/dist/auth/role_schema.d.ts +150 -40
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +144 -45
- package/dist/auth/scope_kind_schema.d.ts +96 -0
- package/dist/auth/scope_kind_schema.d.ts.map +1 -0
- package/dist/auth/scope_kind_schema.js +94 -0
- package/dist/auth/self_service_role_action_specs.d.ts +6 -1
- package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
- package/dist/auth/self_service_role_action_specs.js +3 -1
- package/dist/auth/self_service_role_actions.d.ts +34 -27
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.js +68 -48
- package/dist/auth/session_cookie.d.ts +43 -6
- package/dist/auth/session_cookie.d.ts.map +1 -1
- package/dist/auth/session_cookie.js +31 -5
- package/dist/auth/session_middleware.d.ts +37 -3
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +33 -7
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +48 -19
- package/dist/auth/standard_action_specs.d.ts +2 -2
- package/dist/auth/standard_action_specs.js +4 -4
- package/dist/auth/standard_rpc_actions.d.ts +23 -19
- package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
- package/dist/auth/standard_rpc_actions.js +12 -12
- package/dist/db/migrate.d.ts +12 -8
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +10 -7
- package/dist/dev/setup.d.ts +2 -2
- package/dist/dev/setup.d.ts.map +1 -1
- package/dist/dev/setup.js +9 -7
- package/dist/env/load.d.ts +1 -1
- package/dist/env/load.js +1 -1
- package/dist/hono_context.d.ts +64 -5
- package/dist/hono_context.d.ts.map +1 -1
- package/dist/hono_context.js +38 -2
- package/dist/http/CLAUDE.md +264 -87
- package/dist/http/auth_shape.d.ts +191 -0
- package/dist/http/auth_shape.d.ts.map +1 -0
- package/dist/http/auth_shape.js +237 -0
- package/dist/http/common_routes.js +3 -3
- package/dist/http/db_routes.d.ts +4 -0
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +44 -7
- package/dist/http/error_schemas.d.ts +132 -19
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +132 -40
- package/dist/http/jsonrpc_errors.d.ts +27 -2
- package/dist/http/jsonrpc_errors.d.ts.map +1 -1
- package/dist/http/jsonrpc_errors.js +26 -2
- package/dist/http/pending_effects.d.ts +71 -18
- package/dist/http/pending_effects.d.ts.map +1 -1
- package/dist/http/pending_effects.js +87 -18
- package/dist/http/proxy.d.ts +52 -5
- package/dist/http/proxy.d.ts.map +1 -1
- package/dist/http/proxy.js +92 -14
- package/dist/http/route_spec.d.ts +113 -41
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +130 -52
- package/dist/http/schema_helpers.d.ts +3 -2
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +9 -2
- package/dist/http/surface.d.ts +2 -1
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +1 -2
- package/dist/http/surface_query.d.ts +39 -35
- package/dist/http/surface_query.d.ts.map +1 -1
- package/dist/http/surface_query.js +79 -36
- package/dist/primitive_schemas.d.ts +39 -0
- package/dist/primitive_schemas.d.ts.map +1 -0
- package/dist/primitive_schemas.js +40 -0
- package/dist/realtime/sse_auth_guard.d.ts +5 -5
- package/dist/realtime/sse_auth_guard.js +9 -9
- package/dist/runtime/mock.d.ts +1 -1
- package/dist/runtime/mock.js +1 -1
- package/dist/server/app_backend.d.ts +14 -11
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +12 -8
- package/dist/server/app_server.d.ts +7 -7
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +36 -31
- package/dist/server/validate_nginx.d.ts +1 -1
- package/dist/server/validate_nginx.js +1 -1
- package/dist/testing/CLAUDE.md +73 -55
- package/dist/testing/admin_integration.d.ts +5 -6
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +100 -96
- package/dist/testing/adversarial_headers.js +1 -1
- package/dist/testing/app_server.d.ts +11 -14
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +18 -17
- package/dist/testing/assertions.d.ts.map +1 -1
- package/dist/testing/assertions.js +2 -1
- package/dist/testing/attack_surface.d.ts.map +1 -1
- package/dist/testing/attack_surface.js +15 -9
- package/dist/testing/audit_completeness.d.ts +2 -2
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +53 -39
- package/dist/testing/auth_apps.d.ts +5 -4
- package/dist/testing/auth_apps.d.ts.map +1 -1
- package/dist/testing/auth_apps.js +28 -22
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/data_exposure.js +5 -5
- package/dist/testing/db.d.ts +1 -1
- package/dist/testing/db.d.ts.map +1 -1
- package/dist/testing/db.js +4 -4
- package/dist/testing/db_entities.d.ts +22 -0
- package/dist/testing/db_entities.d.ts.map +1 -0
- package/dist/testing/db_entities.js +28 -0
- package/dist/testing/entities.d.ts +10 -8
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +22 -18
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +13 -14
- package/dist/testing/integration_helpers.d.ts +8 -6
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +29 -23
- package/dist/testing/middleware.d.ts +15 -11
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +75 -32
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.js +40 -24
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.js +3 -1
- package/dist/testing/rpc_round_trip.d.ts +1 -1
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.js +14 -13
- package/dist/testing/sse_round_trip.d.ts +3 -4
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +7 -11
- package/dist/testing/standard.d.ts +1 -1
- package/dist/testing/stubs.d.ts +25 -0
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +43 -2
- package/dist/testing/surface_invariants.d.ts +2 -2
- package/dist/testing/ws_round_trip.d.ts +12 -13
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +24 -12
- package/dist/ui/AdminAccounts.svelte +23 -20
- package/dist/ui/AdminOverview.svelte +15 -13
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/{AdminPermitHistory.svelte → AdminRoleGrantHistory.svelte} +12 -12
- package/dist/ui/AdminRoleGrantHistory.svelte.d.ts +4 -0
- package/dist/ui/AdminRoleGrantHistory.svelte.d.ts.map +1 -0
- package/dist/ui/BootstrapForm.svelte +1 -1
- package/dist/ui/CLAUDE.md +65 -59
- package/dist/ui/{PermitOfferForm.svelte → RoleGrantOfferForm.svelte} +37 -22
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts +20 -0
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts.map +1 -0
- package/dist/ui/{PermitOfferHistory.svelte → RoleGrantOfferHistory.svelte} +12 -12
- package/dist/ui/{PermitOfferHistory.svelte.d.ts → RoleGrantOfferHistory.svelte.d.ts} +4 -4
- package/dist/ui/RoleGrantOfferHistory.svelte.d.ts.map +1 -0
- package/dist/ui/{PermitOfferInbox.svelte → RoleGrantOfferInbox.svelte} +14 -14
- package/dist/ui/{PermitOfferInbox.svelte.d.ts → RoleGrantOfferInbox.svelte.d.ts} +4 -4
- package/dist/ui/RoleGrantOfferInbox.svelte.d.ts.map +1 -0
- package/dist/ui/SignupForm.svelte +1 -1
- package/dist/ui/SurfaceExplorer.svelte +35 -15
- package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.d.ts +2 -3
- package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.js +2 -3
- package/dist/ui/admin_accounts_state.svelte.d.ts +25 -18
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +28 -17
- package/dist/ui/admin_rpc_adapters.d.ts +20 -20
- package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
- package/dist/ui/admin_rpc_adapters.js +17 -17
- package/dist/ui/admin_sessions_state.svelte.d.ts +2 -2
- package/dist/ui/admin_sessions_state.svelte.js +2 -2
- package/dist/ui/audit_log_state.svelte.d.ts +7 -7
- package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
- package/dist/ui/audit_log_state.svelte.js +6 -6
- package/dist/ui/auth_state.svelte.d.ts +3 -3
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +6 -6
- package/dist/ui/format_scope.d.ts +2 -2
- package/dist/ui/format_scope.js +2 -2
- package/dist/ui/{permit_offers_state.svelte.d.ts → role_grant_offers_state.svelte.d.ts} +39 -31
- package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -0
- package/dist/ui/{permit_offers_state.svelte.js → role_grant_offers_state.svelte.js} +25 -19
- package/dist/ui/ui_format.js +2 -2
- package/package.json +3 -3
- package/dist/auth/permit_offer_action_specs.d.ts.map +0 -1
- package/dist/auth/permit_offer_action_specs.js +0 -227
- package/dist/auth/permit_offer_actions.d.ts +0 -110
- package/dist/auth/permit_offer_actions.d.ts.map +0 -1
- package/dist/auth/permit_offer_actions.js +0 -452
- package/dist/auth/permit_offer_notifications.d.ts.map +0 -1
- package/dist/auth/permit_offer_notifications.js +0 -182
- package/dist/auth/permit_offer_queries.d.ts +0 -183
- package/dist/auth/permit_offer_queries.d.ts.map +0 -1
- package/dist/auth/permit_offer_queries.js +0 -408
- package/dist/auth/permit_offer_schema.d.ts +0 -103
- package/dist/auth/permit_offer_schema.d.ts.map +0 -1
- package/dist/auth/permit_queries.d.ts +0 -210
- package/dist/auth/permit_queries.d.ts.map +0 -1
- package/dist/auth/permit_queries.js +0 -294
- package/dist/auth/require_keeper.d.ts +0 -20
- package/dist/auth/require_keeper.d.ts.map +0 -1
- package/dist/auth/require_keeper.js +0 -35
- package/dist/auth/route_guards.d.ts +0 -21
- package/dist/auth/route_guards.d.ts.map +0 -1
- package/dist/auth/route_guards.js +0 -32
- package/dist/auth/session_lifecycle.d.ts +0 -37
- package/dist/auth/session_lifecycle.d.ts.map +0 -1
- package/dist/auth/session_lifecycle.js +0 -29
- package/dist/ui/AdminPermitHistory.svelte.d.ts +0 -4
- package/dist/ui/AdminPermitHistory.svelte.d.ts.map +0 -1
- package/dist/ui/PermitOfferForm.svelte.d.ts +0 -14
- package/dist/ui/PermitOfferForm.svelte.d.ts.map +0 -1
- package/dist/ui/PermitOfferHistory.svelte.d.ts.map +0 -1
- package/dist/ui/PermitOfferInbox.svelte.d.ts.map +0 -1
- package/dist/ui/permit_offers_state.svelte.d.ts.map +0 -1
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role grant offer RPC action handlers — the consentful-role-grants action surface.
|
|
3
|
+
*
|
|
4
|
+
* Seven actions: six offer-lifecycle methods (create / accept / decline /
|
|
5
|
+
* retract / list / history) plus `role_grant_revoke` (admin-only). All mount
|
|
6
|
+
* on a consumer's JSON-RPC endpoint via `create_rpc_endpoint`. The action
|
|
7
|
+
* specs themselves live in `auth/role_grant_offer_action_specs.ts`. Mutations
|
|
8
|
+
* declare `side_effects: true` so the RPC dispatcher wraps the handler in
|
|
9
|
+
* a DB transaction; `role_grant_offer_list` and `role_grant_offer_history` declare
|
|
10
|
+
* `side_effects: false` so they are addressable via GET.
|
|
11
|
+
*
|
|
12
|
+
* Authorization:
|
|
13
|
+
* - `role_grant_offer_create` — the grantor must hold an active role_grant for the
|
|
14
|
+
* role being offered, and that role's `grant_paths` must include `'admin'`.
|
|
15
|
+
* Consumers needing a richer policy (e.g., "teacher may offer student in
|
|
16
|
+
* *their* classroom") pass an `authorize` callback that overrides the default.
|
|
17
|
+
* - `role_grant_offer_accept` / `role_grant_offer_decline` — keyed to the caller's
|
|
18
|
+
* account; `query_*` helpers enforce the IDOR guard.
|
|
19
|
+
* - `role_grant_offer_retract` — keyed to the caller's actor.
|
|
20
|
+
* - `role_grant_offer_list` / `role_grant_offer_history` — self by default;
|
|
21
|
+
* `{account_id}` is admin-only.
|
|
22
|
+
* - `role_grant_revoke` — spec-level `auth: {role: 'admin'}`; the RPC
|
|
23
|
+
* dispatcher rejects non-admin callers before the handler runs.
|
|
24
|
+
* The admin-grant-path gate prevents revoking keeper / daemon-scoped
|
|
25
|
+
* roles via this surface. Keys on `actor_id` to survive multi-actor accounts.
|
|
26
|
+
*
|
|
27
|
+
* Audit events are emitted in-transaction by the query layer (atomic with
|
|
28
|
+
* the role_grant write on accept/revoke) or by the handler via the bound
|
|
29
|
+
* `deps.audit.emit_role_grant_target` helper for single-event lifecycle
|
|
30
|
+
* transitions. `audit.notify` (SSE/WS broadcast) fires post-commit in both
|
|
31
|
+
* paths.
|
|
32
|
+
*
|
|
33
|
+
* WS notifications fan out post-commit via `emit_after_commit` when a
|
|
34
|
+
* `notification_sender` is wired: offer lifecycle transitions notify the
|
|
35
|
+
* counterparty, `role_grant_revoke` notifies the revokee plus each superseded
|
|
36
|
+
* pending offer's grantor.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
import { rpc_action, } from '../actions/action_rpc.js';
|
|
41
|
+
import { jsonrpc_errors } from '../http/jsonrpc_errors.js';
|
|
42
|
+
import { emit_after_commit } from '../http/pending_effects.js';
|
|
43
|
+
import { BUILTIN_ROLE_SPECS_BY_NAME, ROLE_ADMIN, role_has_grant_path, } from './role_schema.js';
|
|
44
|
+
import { GRANT_PATH_ADMIN } from './grant_path_schema.js';
|
|
45
|
+
import { ROLE_GRANT_OFFER_DEFAULT_TTL_MS, to_role_grant_offer_json, } from './role_grant_offer_schema.js';
|
|
46
|
+
import { query_role_grant_offer_create, query_role_grant_offer_decline, query_role_grant_offer_retract, query_role_grant_offer_list, query_role_grant_offer_history_for_account, query_accept_offer, RoleGrantOfferActorAccountMismatchError, RoleGrantOfferActorMismatchError, RoleGrantOfferAlreadyTerminalError, RoleGrantOfferExpiredError, RoleGrantOfferNotFoundError, RoleGrantOfferSelfTargetError, } from './role_grant_offer_queries.js';
|
|
47
|
+
import { query_role_grant_find_active_role_for_actor, query_revoke_role_grant, } from './role_grant_queries.js';
|
|
48
|
+
import { query_actor_by_id } from './account_queries.js';
|
|
49
|
+
import { has_scoped_role } from './request_context.js';
|
|
50
|
+
import { build_role_grant_offer_accepted_notification, build_role_grant_offer_declined_notification, build_role_grant_offer_received_notification, build_role_grant_offer_retracted_notification, build_role_grant_offer_supersede_notification, build_role_grant_revoke_notification, } from './role_grant_offer_notifications.js';
|
|
51
|
+
import { ERROR_ROLE_GRANT_NOT_FOUND, ERROR_ROLE_NOT_WEB_GRANTABLE } from '../http/error_schemas.js';
|
|
52
|
+
import { ERROR_ROLE_GRANT_OFFER_ACTOR_ACCOUNT_MISMATCH, ERROR_ROLE_GRANT_OFFER_ACTOR_MISMATCH, ERROR_ROLE_GRANT_OFFER_EXPIRED, ERROR_ROLE_GRANT_OFFER_NOT_AUTHORIZED, ERROR_ROLE_GRANT_OFFER_NOT_FOUND, ERROR_ROLE_GRANT_OFFER_ROLE_NOT_GRANTABLE, ERROR_ROLE_GRANT_OFFER_SELF_TARGET, ERROR_ROLE_GRANT_OFFER_TERMINAL, role_grant_offer_create_action_spec, role_grant_offer_accept_action_spec, role_grant_offer_decline_action_spec, role_grant_offer_retract_action_spec, role_grant_offer_list_action_spec, role_grant_offer_history_action_spec, role_grant_revoke_action_spec, } from './role_grant_offer_action_specs.js';
|
|
53
|
+
// -- Helpers ----------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* Fan out a batch of pre-written audit rows to the bound emitter's
|
|
56
|
+
* `notify` listener chain. Used by accept, whose events were written
|
|
57
|
+
* in-transaction by `query_accept_offer` — the rows are already in the DB,
|
|
58
|
+
* we just need SSE/WS subscribers to see them.
|
|
59
|
+
*
|
|
60
|
+
* Per-listener exceptions are isolated inside `audit.notify`; one failing
|
|
61
|
+
* subscriber does not starve siblings, and a failure on the first event
|
|
62
|
+
* does not skip the rest.
|
|
63
|
+
*/
|
|
64
|
+
const fan_out_audit_events = (events, audit) => {
|
|
65
|
+
for (const event of events) {
|
|
66
|
+
audit.notify(event);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
70
|
+
const default_authorize = async (auth, input, _deps, _ctx) => {
|
|
71
|
+
// Caller must hold an active role_grant for the offered role. Global (no scope)
|
|
72
|
+
// check — the scope-aware "only in this classroom" policy is consumer-level.
|
|
73
|
+
// Reads from the in-memory `auth.role_grants` snapshot loaded once per request
|
|
74
|
+
// by `create_request_context_middleware`; no DB roundtrip needed.
|
|
75
|
+
return has_scoped_role(auth, input.role, null);
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Authorization callback that admits any admin and otherwise falls back to
|
|
79
|
+
* the symmetric default (caller must hold the offered role globally).
|
|
80
|
+
*
|
|
81
|
+
* The admin-grant-path filter in `create_handler` runs **before** the
|
|
82
|
+
* `authorize` callback, so this never sees roles whose `grant_paths`
|
|
83
|
+
* omits `'admin'`. Drop into
|
|
84
|
+
* `create_role_grant_offer_actions({authorize: authorize_admin_or_holder})`
|
|
85
|
+
* (or any factory that forwards `authorize`, e.g. `create_standard_rpc_actions`)
|
|
86
|
+
* for the common "admins offer anything; users offer what they hold"
|
|
87
|
+
* pattern. Scope-aware policies (e.g. classroom_teacher offering
|
|
88
|
+
* classroom_student in their own scope) wrap this and short-circuit `true`
|
|
89
|
+
* before delegating.
|
|
90
|
+
*/
|
|
91
|
+
export const authorize_admin_or_holder = async (auth, input, _deps, _ctx) => {
|
|
92
|
+
// Admin bypass keys on **global** admin role_grants only — `has_scoped_role(_, _, null)`
|
|
93
|
+
// rejects scoped admin role_grants. Without this, a `{role: 'admin', scope_id: scope_X}`
|
|
94
|
+
// role_grant would let the holder offer any admin-grant-path role without holding it
|
|
95
|
+
// themselves, escalating scoped admin to global authority over the offer surface.
|
|
96
|
+
if (has_scoped_role(auth, ROLE_ADMIN, null))
|
|
97
|
+
return true;
|
|
98
|
+
return has_scoped_role(auth, input.role, null);
|
|
99
|
+
};
|
|
100
|
+
// -- Action factory ---------------------------------------------------------
|
|
101
|
+
/**
|
|
102
|
+
* Create the seven role-grant-offer RPC actions (six offer-lifecycle methods
|
|
103
|
+
* plus `role_grant_revoke`).
|
|
104
|
+
*
|
|
105
|
+
* @param deps - `RouteFactoryDeps` (`log`, `audit`, …) plus optional `notification_sender` for WS fan-out — when absent, WS fan-out is silently skipped (DB-only side effects still happen). Consumers wiring `BackendWebsocketTransport` assign its instance directly (the transport's `send_to_account` signature accepts the broader `JsonrpcMessageFromServerToClient`, which is contravariantly compatible)
|
|
106
|
+
* @param options - role schema, default TTL, authorization override
|
|
107
|
+
* @returns the `RpcAction` array to spread into a `create_rpc_endpoint` call
|
|
108
|
+
*/
|
|
109
|
+
export const create_role_grant_offer_actions = (deps, options = {}) => {
|
|
110
|
+
const { log, audit, notification_sender = null } = deps;
|
|
111
|
+
const role_specs = options.roles?.role_specs ?? BUILTIN_ROLE_SPECS_BY_NAME;
|
|
112
|
+
const default_ttl_ms = options.default_ttl_ms ?? ROLE_GRANT_OFFER_DEFAULT_TTL_MS;
|
|
113
|
+
const authorize = options.authorize ?? default_authorize;
|
|
114
|
+
// Four denial paths (admin-grant-path gate, authorize, self-target,
|
|
115
|
+
// actor-account mismatch) all emit the same failure-outcome audit
|
|
116
|
+
// event. `target_actor_id` is populated when the caller supplied a
|
|
117
|
+
// `to_actor_id` so failure rows match the success-shape envelope of
|
|
118
|
+
// actor-targeted offers.
|
|
119
|
+
const emit_create_failure_audit = (ctx, auth, input) => {
|
|
120
|
+
audit.emit_role_grant_target(ctx, auth, {
|
|
121
|
+
event_type: 'role_grant_offer_create',
|
|
122
|
+
outcome: 'failure',
|
|
123
|
+
target_account_id: input.to_account_id,
|
|
124
|
+
target_actor_id: input.to_actor_id ?? null,
|
|
125
|
+
metadata: {
|
|
126
|
+
role: input.role,
|
|
127
|
+
scope_id: input.scope_id ?? null,
|
|
128
|
+
to_account_id: input.to_account_id,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
// Returns {offer} only — no auto-accept. Recipient must call
|
|
133
|
+
// role_grant_offer_accept; admin tests materialize role_grants via
|
|
134
|
+
// query_accept_offer (see testing/admin_integration.ts `offer_and_accept`).
|
|
135
|
+
const create_handler = async (input, ctx) => {
|
|
136
|
+
const auth = ctx.auth;
|
|
137
|
+
// Role must include the admin grant path — same gate as admin direct-grant.
|
|
138
|
+
if (!role_has_grant_path(role_specs, input.role, GRANT_PATH_ADMIN)) {
|
|
139
|
+
emit_create_failure_audit(ctx, auth, input);
|
|
140
|
+
throw jsonrpc_errors.forbidden('role not grantable', {
|
|
141
|
+
reason: ERROR_ROLE_GRANT_OFFER_ROLE_NOT_GRANTABLE,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const authorized = await authorize(auth, {
|
|
145
|
+
to_account_id: input.to_account_id,
|
|
146
|
+
role: input.role,
|
|
147
|
+
scope_id: input.scope_id ?? null,
|
|
148
|
+
}, { log }, ctx);
|
|
149
|
+
if (!authorized) {
|
|
150
|
+
emit_create_failure_audit(ctx, auth, input);
|
|
151
|
+
throw jsonrpc_errors.forbidden('not authorized to offer this role', {
|
|
152
|
+
reason: ERROR_ROLE_GRANT_OFFER_NOT_AUTHORIZED,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
let offer;
|
|
156
|
+
try {
|
|
157
|
+
offer = await query_role_grant_offer_create(ctx, {
|
|
158
|
+
from_actor_id: auth.actor.id,
|
|
159
|
+
to_account_id: input.to_account_id,
|
|
160
|
+
to_actor_id: input.to_actor_id ?? null,
|
|
161
|
+
role: input.role,
|
|
162
|
+
scope_kind: input.scope_kind ?? null,
|
|
163
|
+
scope_id: input.scope_id ?? null,
|
|
164
|
+
message: input.message ?? null,
|
|
165
|
+
expires_at: new Date(Date.now() + default_ttl_ms),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
if (err instanceof RoleGrantOfferSelfTargetError) {
|
|
170
|
+
emit_create_failure_audit(ctx, auth, input);
|
|
171
|
+
throw jsonrpc_errors.invalid_params('cannot offer to self', {
|
|
172
|
+
reason: ERROR_ROLE_GRANT_OFFER_SELF_TARGET,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (err instanceof RoleGrantOfferActorAccountMismatchError) {
|
|
176
|
+
emit_create_failure_audit(ctx, auth, input);
|
|
177
|
+
throw jsonrpc_errors.invalid_params('to_actor_id does not belong to to_account_id', {
|
|
178
|
+
reason: ERROR_ROLE_GRANT_OFFER_ACTOR_ACCOUNT_MISMATCH,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
// `target_actor_id` is populated when the offer is actor-targeted
|
|
184
|
+
// (per the offer's `to_actor_id`), null for account-grain offers
|
|
185
|
+
// — closes the audit hole where offer-shape events used to leave
|
|
186
|
+
// actor-grain forensics blank even when the binding was known.
|
|
187
|
+
audit.emit_role_grant_target(ctx, auth, {
|
|
188
|
+
event_type: 'role_grant_offer_create',
|
|
189
|
+
target_account_id: input.to_account_id,
|
|
190
|
+
target_actor_id: offer.to_actor_id,
|
|
191
|
+
metadata: {
|
|
192
|
+
offer_id: offer.id,
|
|
193
|
+
role: offer.role,
|
|
194
|
+
scope_id: offer.scope_id,
|
|
195
|
+
to_account_id: offer.to_account_id,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
const offer_json = to_role_grant_offer_json(offer);
|
|
199
|
+
if (notification_sender) {
|
|
200
|
+
emit_after_commit(ctx, () => {
|
|
201
|
+
notification_sender.send_to_account(offer.to_account_id, build_role_grant_offer_received_notification({ offer: offer_json }));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return { offer: offer_json };
|
|
205
|
+
};
|
|
206
|
+
const accept_handler = async (input, ctx) => {
|
|
207
|
+
const auth = ctx.auth;
|
|
208
|
+
let result;
|
|
209
|
+
try {
|
|
210
|
+
result = await query_accept_offer(ctx, {
|
|
211
|
+
offer_id: input.offer_id,
|
|
212
|
+
to_account_id: auth.account.id,
|
|
213
|
+
actor_id: auth.actor.id,
|
|
214
|
+
ip: ctx.client_ip,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (err instanceof RoleGrantOfferNotFoundError) {
|
|
219
|
+
throw jsonrpc_errors.not_found('offer', { reason: ERROR_ROLE_GRANT_OFFER_NOT_FOUND });
|
|
220
|
+
}
|
|
221
|
+
if (err instanceof RoleGrantOfferAlreadyTerminalError) {
|
|
222
|
+
throw jsonrpc_errors.invalid_request({ reason: ERROR_ROLE_GRANT_OFFER_TERMINAL });
|
|
223
|
+
}
|
|
224
|
+
if (err instanceof RoleGrantOfferExpiredError) {
|
|
225
|
+
throw jsonrpc_errors.invalid_request({ reason: ERROR_ROLE_GRANT_OFFER_EXPIRED });
|
|
226
|
+
}
|
|
227
|
+
if (err instanceof RoleGrantOfferActorMismatchError) {
|
|
228
|
+
throw jsonrpc_errors.forbidden('offer is targeted to a different actor', {
|
|
229
|
+
reason: ERROR_ROLE_GRANT_OFFER_ACTOR_MISMATCH,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
// Look up the grantor's account_id inside the transaction so the
|
|
235
|
+
// post-commit notification has a valid target. One cheap SELECT by
|
|
236
|
+
// PK — the alternative (widening `query_accept_offer` again) would
|
|
237
|
+
// bleed transport concerns into the query layer.
|
|
238
|
+
const grantor_actor = notification_sender
|
|
239
|
+
? await query_actor_by_id(ctx, result.offer.from_actor_id)
|
|
240
|
+
: null;
|
|
241
|
+
const grantor_account_id = grantor_actor?.account_id ?? null;
|
|
242
|
+
const offer_json = to_role_grant_offer_json(result.offer);
|
|
243
|
+
const supersede_payloads = result.superseded_offers.map((sib) => ({
|
|
244
|
+
offer: to_role_grant_offer_json(sib),
|
|
245
|
+
from_account_id: sib.from_account_id,
|
|
246
|
+
}));
|
|
247
|
+
// Audit events are written in-transaction by query_accept_offer; wire
|
|
248
|
+
// them through `audit.notify` post-commit so SSE/WS broadcasts fire.
|
|
249
|
+
// WS notifications piggyback on the same post-commit microtask so the
|
|
250
|
+
// grantor sees "accepted" and each superseded grantor sees
|
|
251
|
+
// "supersede" only once the accept has durably committed.
|
|
252
|
+
emit_after_commit(ctx, () => {
|
|
253
|
+
fan_out_audit_events(result.audit_events, audit);
|
|
254
|
+
if (notification_sender && grantor_account_id) {
|
|
255
|
+
notification_sender.send_to_account(grantor_account_id, build_role_grant_offer_accepted_notification({ offer: offer_json }));
|
|
256
|
+
}
|
|
257
|
+
if (notification_sender) {
|
|
258
|
+
for (const sib of supersede_payloads) {
|
|
259
|
+
notification_sender.send_to_account(sib.from_account_id, build_role_grant_offer_supersede_notification({
|
|
260
|
+
offer: sib.offer,
|
|
261
|
+
reason: 'sibling_accepted',
|
|
262
|
+
cause_id: result.offer.id,
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
return {
|
|
268
|
+
role_grant_id: result.role_grant.id,
|
|
269
|
+
offer: offer_json,
|
|
270
|
+
superseded_offer_ids: result.superseded_offers.map((o) => o.id),
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
const decline_handler = async (input, ctx) => {
|
|
274
|
+
const auth = ctx.auth;
|
|
275
|
+
let declined;
|
|
276
|
+
try {
|
|
277
|
+
declined = await query_role_grant_offer_decline(ctx, input.offer_id, auth.account.id, input.reason ?? null);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
if (err instanceof RoleGrantOfferAlreadyTerminalError) {
|
|
281
|
+
throw jsonrpc_errors.invalid_request({ reason: ERROR_ROLE_GRANT_OFFER_TERMINAL });
|
|
282
|
+
}
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
285
|
+
if (!declined) {
|
|
286
|
+
throw jsonrpc_errors.not_found('offer', { reason: ERROR_ROLE_GRANT_OFFER_NOT_FOUND });
|
|
287
|
+
}
|
|
288
|
+
// `role_grant_offer_decline` is *to* the offering actor — populate both
|
|
289
|
+
// `target_actor_id` (the grantor actor) and `target_account_id`
|
|
290
|
+
// (the grantor account, joined in the decline RETURNING via CTE).
|
|
291
|
+
// The "both populated → same account" invariant holds: the
|
|
292
|
+
// grantor's actor↔account binding is 1:1 by definition of `actor`.
|
|
293
|
+
audit.emit_role_grant_target(ctx, auth, {
|
|
294
|
+
event_type: 'role_grant_offer_decline',
|
|
295
|
+
target_account_id: declined.from_account_id,
|
|
296
|
+
target_actor_id: declined.from_actor_id,
|
|
297
|
+
metadata: {
|
|
298
|
+
offer_id: declined.id,
|
|
299
|
+
role: declined.role,
|
|
300
|
+
scope_id: declined.scope_id,
|
|
301
|
+
reason: input.reason ?? undefined,
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
if (notification_sender) {
|
|
305
|
+
// Grantor's account_id rides on `declined.from_account_id` from
|
|
306
|
+
// the decline RETURNING — no second SELECT needed. The decline
|
|
307
|
+
// reason rides along on `offer.decline_reason` — the DB set it
|
|
308
|
+
// in the RETURNING above.
|
|
309
|
+
const offer_json = to_role_grant_offer_json(declined);
|
|
310
|
+
emit_after_commit(ctx, () => {
|
|
311
|
+
notification_sender.send_to_account(declined.from_account_id, build_role_grant_offer_declined_notification({ offer: offer_json }));
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return { ok: true };
|
|
315
|
+
};
|
|
316
|
+
const retract_handler = async (input, ctx) => {
|
|
317
|
+
const auth = ctx.auth;
|
|
318
|
+
let retracted;
|
|
319
|
+
try {
|
|
320
|
+
retracted = await query_role_grant_offer_retract(ctx, input.offer_id, auth.actor.id);
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
if (err instanceof RoleGrantOfferAlreadyTerminalError) {
|
|
324
|
+
throw jsonrpc_errors.invalid_request({ reason: ERROR_ROLE_GRANT_OFFER_TERMINAL });
|
|
325
|
+
}
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
if (!retracted) {
|
|
329
|
+
throw jsonrpc_errors.not_found('offer', { reason: ERROR_ROLE_GRANT_OFFER_NOT_FOUND });
|
|
330
|
+
}
|
|
331
|
+
// `role_grant_offer_retract` is *from* the recipient inbox —
|
|
332
|
+
// `target_account_id` is the recipient account; `target_actor_id`
|
|
333
|
+
// inherits the offer's `to_actor_id` (set on actor-targeted
|
|
334
|
+
// offers, null on account-grain offers).
|
|
335
|
+
audit.emit_role_grant_target(ctx, auth, {
|
|
336
|
+
event_type: 'role_grant_offer_retract',
|
|
337
|
+
target_account_id: retracted.to_account_id,
|
|
338
|
+
target_actor_id: retracted.to_actor_id,
|
|
339
|
+
metadata: {
|
|
340
|
+
offer_id: retracted.id,
|
|
341
|
+
role: retracted.role,
|
|
342
|
+
scope_id: retracted.scope_id,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
if (notification_sender) {
|
|
346
|
+
const offer_json = to_role_grant_offer_json(retracted);
|
|
347
|
+
emit_after_commit(ctx, () => {
|
|
348
|
+
notification_sender.send_to_account(retracted.to_account_id, build_role_grant_offer_retracted_notification({ offer: offer_json }));
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return { ok: true };
|
|
352
|
+
};
|
|
353
|
+
const list_handler = async (input, ctx) => {
|
|
354
|
+
const auth = ctx.auth;
|
|
355
|
+
const target = input.account_id ?? auth.account.id;
|
|
356
|
+
// Cross-account inspection requires **global** admin — a scoped admin
|
|
357
|
+
// role_grant must not be able to read another account's offer list.
|
|
358
|
+
if (target !== auth.account.id && !has_scoped_role(auth, ROLE_ADMIN, null)) {
|
|
359
|
+
throw jsonrpc_errors.forbidden('admin required to inspect another account');
|
|
360
|
+
}
|
|
361
|
+
const offers = await query_role_grant_offer_list(ctx, target);
|
|
362
|
+
return { offers: offers.map(to_role_grant_offer_json) };
|
|
363
|
+
};
|
|
364
|
+
const history_handler = async (input, ctx) => {
|
|
365
|
+
const auth = ctx.auth;
|
|
366
|
+
const target = input.account_id ?? auth.account.id;
|
|
367
|
+
if (target !== auth.account.id && !has_scoped_role(auth, ROLE_ADMIN, null)) {
|
|
368
|
+
throw jsonrpc_errors.forbidden('admin required to inspect another account');
|
|
369
|
+
}
|
|
370
|
+
const offers = await query_role_grant_offer_history_for_account(ctx, target, input.limit ?? undefined, input.offset ?? undefined);
|
|
371
|
+
return { offers: offers.map(to_role_grant_offer_json) };
|
|
372
|
+
};
|
|
373
|
+
const revoke_handler = async (input, ctx) => {
|
|
374
|
+
const auth = ctx.auth;
|
|
375
|
+
// IDOR guard + role lookup + actor → account JOIN. One SELECT —
|
|
376
|
+
// returns null when the role_grant is revoked, missing, or belongs
|
|
377
|
+
// to a different actor. The JOIN supplies `account_id` for the
|
|
378
|
+
// audit envelope's `target_account_id` and the post-commit
|
|
379
|
+
// SSE/WS socket-close fan-out target. `role_grant_revoke` is the
|
|
380
|
+
// canonical actor-bound-subject event: `target_actor_id` is the
|
|
381
|
+
// role_grant's grantee (input.actor_id); `target_account_id` is the
|
|
382
|
+
// account hosting that actor (sessions remain account-grain
|
|
383
|
+
// after multi-actor lands).
|
|
384
|
+
const role_grant_row = await query_role_grant_find_active_role_for_actor(ctx, input.role_grant_id, input.actor_id);
|
|
385
|
+
if (!role_grant_row) {
|
|
386
|
+
throw jsonrpc_errors.not_found('role_grant', { reason: ERROR_ROLE_GRANT_NOT_FOUND });
|
|
387
|
+
}
|
|
388
|
+
const target_account_id = role_grant_row.account_id;
|
|
389
|
+
const target_actor_id = input.actor_id;
|
|
390
|
+
// Admin-grant-path gate — keeper / daemon-scoped roles stay CLI-only
|
|
391
|
+
// (their `grant_paths` does not include `'admin'`).
|
|
392
|
+
if (!role_has_grant_path(role_specs, role_grant_row.role, GRANT_PATH_ADMIN)) {
|
|
393
|
+
audit.emit_role_grant_target(ctx, auth, {
|
|
394
|
+
event_type: 'role_grant_revoke',
|
|
395
|
+
outcome: 'failure',
|
|
396
|
+
target_account_id,
|
|
397
|
+
target_actor_id,
|
|
398
|
+
metadata: { role: role_grant_row.role, role_grant_id: input.role_grant_id },
|
|
399
|
+
});
|
|
400
|
+
throw jsonrpc_errors.forbidden('role not web-grantable', {
|
|
401
|
+
reason: ERROR_ROLE_NOT_WEB_GRANTABLE,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const result = await query_revoke_role_grant(ctx, input.role_grant_id, input.actor_id, auth.actor.id, input.reason ?? null);
|
|
405
|
+
if (!result) {
|
|
406
|
+
// Raced with another revoker or the role_grant was revoked between
|
|
407
|
+
// the IDOR check and the UPDATE.
|
|
408
|
+
throw jsonrpc_errors.not_found('role_grant', { reason: ERROR_ROLE_GRANT_NOT_FOUND });
|
|
409
|
+
}
|
|
410
|
+
audit.emit_role_grant_target(ctx, auth, {
|
|
411
|
+
event_type: 'role_grant_revoke',
|
|
412
|
+
target_account_id,
|
|
413
|
+
target_actor_id,
|
|
414
|
+
metadata: {
|
|
415
|
+
role: result.role,
|
|
416
|
+
role_grant_id: result.id,
|
|
417
|
+
scope_id: result.scope_id,
|
|
418
|
+
reason: input.reason ?? undefined,
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
// Supersede cascade — the recipient is known (`offer.to_account_id`),
|
|
422
|
+
// so populate `target_account_id` rather than leaving it null;
|
|
423
|
+
// `target_actor_id` inherits the offer's `to_actor_id` (actor-grain
|
|
424
|
+
// when the superseded offer was actor-targeted, null otherwise).
|
|
425
|
+
for (const offer of result.superseded_offers) {
|
|
426
|
+
audit.emit_role_grant_target(ctx, auth, {
|
|
427
|
+
event_type: 'role_grant_offer_supersede',
|
|
428
|
+
target_account_id: offer.to_account_id,
|
|
429
|
+
target_actor_id: offer.to_actor_id,
|
|
430
|
+
metadata: {
|
|
431
|
+
offer_id: offer.id,
|
|
432
|
+
role: offer.role,
|
|
433
|
+
scope_id: offer.scope_id,
|
|
434
|
+
reason: 'role_grant_revoked',
|
|
435
|
+
cause_id: result.id,
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (notification_sender) {
|
|
440
|
+
const superseded = result.superseded_offers.map((o) => ({
|
|
441
|
+
offer: to_role_grant_offer_json(o),
|
|
442
|
+
from_account_id: o.from_account_id,
|
|
443
|
+
}));
|
|
444
|
+
const cause_id = result.id;
|
|
445
|
+
const reason = input.reason ?? null;
|
|
446
|
+
emit_after_commit(ctx, () => {
|
|
447
|
+
notification_sender.send_to_account(target_account_id, build_role_grant_revoke_notification({
|
|
448
|
+
role_grant_id: result.id,
|
|
449
|
+
role: result.role,
|
|
450
|
+
scope_id: result.scope_id,
|
|
451
|
+
reason,
|
|
452
|
+
}));
|
|
453
|
+
for (const sib of superseded) {
|
|
454
|
+
notification_sender.send_to_account(sib.from_account_id, build_role_grant_offer_supersede_notification({
|
|
455
|
+
offer: sib.offer,
|
|
456
|
+
reason: 'role_grant_revoked',
|
|
457
|
+
cause_id,
|
|
458
|
+
}));
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return { ok: true, revoked: true };
|
|
463
|
+
};
|
|
464
|
+
return [
|
|
465
|
+
rpc_action(role_grant_offer_create_action_spec, create_handler),
|
|
466
|
+
rpc_action(role_grant_offer_accept_action_spec, accept_handler),
|
|
467
|
+
rpc_action(role_grant_offer_decline_action_spec, decline_handler),
|
|
468
|
+
rpc_action(role_grant_offer_retract_action_spec, retract_handler),
|
|
469
|
+
rpc_action(role_grant_offer_list_action_spec, list_handler),
|
|
470
|
+
rpc_action(role_grant_offer_history_action_spec, history_handler),
|
|
471
|
+
rpc_action(role_grant_revoke_action_spec, revoke_handler),
|
|
472
|
+
];
|
|
473
|
+
};
|