@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,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role grant offer database queries.
|
|
3
|
+
*
|
|
4
|
+
* Covers the offer side of the consentful-role-grants flow: create (with
|
|
5
|
+
* re-offer upsert), decline, retract, list, find-pending, sweep-expired,
|
|
6
|
+
* and the atomic `query_accept_offer` that bridges offer → role_grant.
|
|
7
|
+
*
|
|
8
|
+
* IDOR guards are expressed in each helper's signature — decline/accept
|
|
9
|
+
* require the recipient's `to_account_id`, retract requires the grantor's
|
|
10
|
+
* `from_actor_id`.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import { assert_row } from '../db/assert_row.js';
|
|
15
|
+
import { ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN, ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID, } from './role_grant_offer_schema.js';
|
|
16
|
+
import { query_audit_log } from './audit_log_queries.js';
|
|
17
|
+
/**
|
|
18
|
+
* Error thrown by offer-lifecycle queries when the offer is in a non-pending
|
|
19
|
+
* state (accepted / declined / retracted / superseded) and therefore not
|
|
20
|
+
* actionable. Distinct from `RoleGrantOfferExpiredError` — expiry has its own
|
|
21
|
+
* user-facing story ("ask the grantor to re-send") so it travels separately.
|
|
22
|
+
*/
|
|
23
|
+
export class RoleGrantOfferAlreadyTerminalError extends Error {
|
|
24
|
+
constructor(offer_id) {
|
|
25
|
+
super(`Offer ${offer_id} is already in a terminal state`);
|
|
26
|
+
this.name = 'RoleGrantOfferAlreadyTerminalError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Error thrown when an offer's `expires_at` has passed. The accept path
|
|
31
|
+
* enforces this independently of the sweep — a stale offer past its expiry
|
|
32
|
+
* must not be accepted, even in the race window between expiry and the
|
|
33
|
+
* sweep stamping the audit event.
|
|
34
|
+
*/
|
|
35
|
+
export class RoleGrantOfferExpiredError extends Error {
|
|
36
|
+
constructor(offer_id) {
|
|
37
|
+
super(`Offer ${offer_id} has expired`);
|
|
38
|
+
this.name = 'RoleGrantOfferExpiredError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Error thrown when an offer cannot be located for the caller. Covers both
|
|
43
|
+
* "offer does not exist" and "offer belongs to a different recipient"
|
|
44
|
+
* (IDOR guard) — the standard 404-over-403 pattern that avoids disclosing
|
|
45
|
+
* whether an offer id exists.
|
|
46
|
+
*/
|
|
47
|
+
export class RoleGrantOfferNotFoundError extends Error {
|
|
48
|
+
constructor(offer_id) {
|
|
49
|
+
super(`Offer ${offer_id} not found`);
|
|
50
|
+
this.name = 'RoleGrantOfferNotFoundError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Error thrown when a grantor attempts to offer a role_grant to their own account.
|
|
55
|
+
*
|
|
56
|
+
* Enforced via a single SELECT on the grantor's `actor.account_id` (rather
|
|
57
|
+
* than via a CHECK constraint or a denormalized column). Resolving from the
|
|
58
|
+
* grantor side keeps the check multi-actor-correct: under multi-actor the
|
|
59
|
+
* recipient account may host many actors, but the grantor → account binding
|
|
60
|
+
* remains 1:1 by definition of `actor`.
|
|
61
|
+
*/
|
|
62
|
+
export class RoleGrantOfferSelfTargetError extends Error {
|
|
63
|
+
constructor() {
|
|
64
|
+
super('Cannot offer a role_grant to your own account');
|
|
65
|
+
this.name = 'RoleGrantOfferSelfTargetError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Error thrown when an actor-targeted offer is being accepted by an actor
|
|
70
|
+
* other than `offer.to_actor_id`. Distinct from `RoleGrantOfferNotFoundError`
|
|
71
|
+
* (the IDOR mask): once an offer has been resolved to the recipient account,
|
|
72
|
+
* a wrong-actor accept on a same-account actor is a contract violation, not
|
|
73
|
+
* a privacy boundary — surface a specific error so the client UI can
|
|
74
|
+
* distinguish "this offer isn't for you" from "no such offer".
|
|
75
|
+
*/
|
|
76
|
+
export class RoleGrantOfferActorMismatchError extends Error {
|
|
77
|
+
constructor(offer_id) {
|
|
78
|
+
super(`Offer ${offer_id} is targeted to a different actor on this account`);
|
|
79
|
+
this.name = 'RoleGrantOfferActorMismatchError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Error thrown when `query_role_grant_offer_create` is called with a
|
|
84
|
+
* `to_actor_id` that does not exist or does not belong to `to_account_id`.
|
|
85
|
+
* Surfaces the actor↔account binding mismatch at the boundary instead of
|
|
86
|
+
* letting the FK silently disagree with the recipient field.
|
|
87
|
+
*/
|
|
88
|
+
export class RoleGrantOfferActorAccountMismatchError extends Error {
|
|
89
|
+
constructor() {
|
|
90
|
+
super('to_actor_id does not belong to to_account_id');
|
|
91
|
+
this.name = 'RoleGrantOfferActorAccountMismatchError';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Create a new role_grant offer, or refresh an existing pending offer for the
|
|
96
|
+
* same `(to_account_id, role, scope_id, from_actor_id)` tuple.
|
|
97
|
+
*
|
|
98
|
+
* Re-offer semantics: a second call by the same grantor with the same
|
|
99
|
+
* `(to_account, role, scope)` while pending upserts the existing row,
|
|
100
|
+
* refreshing `message` and `expires_at` (and `to_actor_id` — supplying
|
|
101
|
+
* a different `to_actor_id` on re-offer narrows the existing row to the
|
|
102
|
+
* named actor; supplying null widens it back to account-grain). A
|
|
103
|
+
* different grantor offering the same `(to_account, role, scope)` creates
|
|
104
|
+
* a distinct row — multiple pending grantors coexist. After a terminal
|
|
105
|
+
* state, a re-offer is a fresh INSERT.
|
|
106
|
+
*
|
|
107
|
+
* Self-offer rejection: throws `RoleGrantOfferSelfTargetError` if the offering
|
|
108
|
+
* actor belongs to the recipient account.
|
|
109
|
+
*
|
|
110
|
+
* Actor-targeted offers: when `to_actor_id` is supplied,
|
|
111
|
+
* `query_accept_offer` rejects any actor other than the named one. Closes
|
|
112
|
+
* the audit hole where offer-shape events would otherwise leave
|
|
113
|
+
* `target_actor_id` null even when the recipient binding is known at
|
|
114
|
+
* offer time. The actor↔account binding is verified here in one SELECT.
|
|
115
|
+
*
|
|
116
|
+
* @mutates `role_grant_offer` table - inserts a new offer or upserts the matching pending row
|
|
117
|
+
* @throws RoleGrantOfferSelfTargetError if the offering actor belongs to `to_account_id`
|
|
118
|
+
* @throws RoleGrantOfferActorAccountMismatchError if `to_actor_id` is set but does not belong to `to_account_id`
|
|
119
|
+
*/
|
|
120
|
+
export const query_role_grant_offer_create = async (deps, input) => {
|
|
121
|
+
// Self-target check resolves the **grantor** actor's account and
|
|
122
|
+
// compares against to_account_id. This is multi-actor-correct:
|
|
123
|
+
// a single account may host many actors, and self-target means
|
|
124
|
+
// "the offering actor's account == the recipient account",
|
|
125
|
+
// regardless of how many other actors live on either account.
|
|
126
|
+
// (The earlier shape — "look up an actor on to_account_id, compare
|
|
127
|
+
// to from_actor_id" — silently picked one actor on a multi-actor
|
|
128
|
+
// recipient account, missing the self-target case when the picked
|
|
129
|
+
// actor wasn't the offering one.)
|
|
130
|
+
const grantor = await deps.db.query_one(`SELECT account_id FROM actor WHERE id = $1`, [input.from_actor_id]);
|
|
131
|
+
if (grantor && grantor.account_id === input.to_account_id) {
|
|
132
|
+
throw new RoleGrantOfferSelfTargetError();
|
|
133
|
+
}
|
|
134
|
+
if (input.to_actor_id != null) {
|
|
135
|
+
const target = await deps.db.query_one(`SELECT account_id FROM actor WHERE id = $1`, [input.to_actor_id]);
|
|
136
|
+
if (!target || target.account_id !== input.to_account_id) {
|
|
137
|
+
throw new RoleGrantOfferActorAccountMismatchError();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const row = await deps.db.query_one(`INSERT INTO role_grant_offer
|
|
141
|
+
(from_actor_id, to_account_id, to_actor_id, role, scope_kind, scope_id, message, expires_at)
|
|
142
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
143
|
+
ON CONFLICT (
|
|
144
|
+
to_account_id,
|
|
145
|
+
role,
|
|
146
|
+
COALESCE(scope_kind, '${ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN}'),
|
|
147
|
+
COALESCE(scope_id, '${ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID}'::uuid),
|
|
148
|
+
from_actor_id
|
|
149
|
+
)
|
|
150
|
+
WHERE accepted_at IS NULL AND declined_at IS NULL AND retracted_at IS NULL AND superseded_at IS NULL
|
|
151
|
+
DO UPDATE SET
|
|
152
|
+
to_actor_id = EXCLUDED.to_actor_id,
|
|
153
|
+
message = EXCLUDED.message,
|
|
154
|
+
expires_at = EXCLUDED.expires_at
|
|
155
|
+
RETURNING *`, [
|
|
156
|
+
input.from_actor_id,
|
|
157
|
+
input.to_account_id,
|
|
158
|
+
input.to_actor_id ?? null,
|
|
159
|
+
input.role,
|
|
160
|
+
input.scope_kind ?? null,
|
|
161
|
+
input.scope_id ?? null,
|
|
162
|
+
input.message ?? null,
|
|
163
|
+
input.expires_at.toISOString(),
|
|
164
|
+
]);
|
|
165
|
+
return assert_row(row, 'INSERT INTO role_grant_offer');
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* Mark an offer declined.
|
|
169
|
+
*
|
|
170
|
+
* Guarded by `to_account_id` (IDOR). Returns `null` if the offer does not
|
|
171
|
+
* exist or belongs to a different account. Throws
|
|
172
|
+
* `RoleGrantOfferAlreadyTerminalError` if the offer exists for the caller but
|
|
173
|
+
* is already in a terminal state.
|
|
174
|
+
*
|
|
175
|
+
* Returns the declined offer with the grantor's `from_account_id` joined
|
|
176
|
+
* in via CTE — the decline audit envelope populates **both**
|
|
177
|
+
* `target_actor_id` (the grantor actor) and `target_account_id` (the
|
|
178
|
+
* grantor account), satisfying the "both populated → same account"
|
|
179
|
+
* invariant the audit-log column comments describe.
|
|
180
|
+
*
|
|
181
|
+
* @mutates `role_grant_offer` row - sets `declined_at` and `decline_reason`
|
|
182
|
+
* @throws RoleGrantOfferAlreadyTerminalError if the offer is already accepted, declined, retracted, or superseded
|
|
183
|
+
*/
|
|
184
|
+
export const query_role_grant_offer_decline = async (deps, offer_id, to_account_id, reason) => {
|
|
185
|
+
const updated = await deps.db.query_one(`WITH updated AS (
|
|
186
|
+
UPDATE role_grant_offer
|
|
187
|
+
SET declined_at = NOW(), decline_reason = $3
|
|
188
|
+
WHERE id = $1
|
|
189
|
+
AND to_account_id = $2
|
|
190
|
+
AND accepted_at IS NULL
|
|
191
|
+
AND declined_at IS NULL
|
|
192
|
+
AND retracted_at IS NULL
|
|
193
|
+
AND superseded_at IS NULL
|
|
194
|
+
RETURNING *
|
|
195
|
+
)
|
|
196
|
+
SELECT u.*, grantor.account_id AS from_account_id
|
|
197
|
+
FROM updated u
|
|
198
|
+
JOIN actor grantor ON grantor.id = u.from_actor_id`, [offer_id, to_account_id, reason ?? null]);
|
|
199
|
+
if (updated)
|
|
200
|
+
return updated;
|
|
201
|
+
return resolve_terminal_or_missing(deps, offer_id, { to_account_id });
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Mark an offer retracted by the grantor.
|
|
205
|
+
*
|
|
206
|
+
* Guarded by `from_actor_id` (IDOR). Returns `null` if the offer does not
|
|
207
|
+
* exist or was issued by a different actor. Throws
|
|
208
|
+
* `RoleGrantOfferAlreadyTerminalError` if the offer exists for this grantor
|
|
209
|
+
* but is already in a terminal state.
|
|
210
|
+
*
|
|
211
|
+
* @mutates `role_grant_offer` row - sets `retracted_at`
|
|
212
|
+
* @throws RoleGrantOfferAlreadyTerminalError if the offer is already accepted, declined, retracted, or superseded
|
|
213
|
+
*/
|
|
214
|
+
export const query_role_grant_offer_retract = async (deps, offer_id, from_actor_id) => {
|
|
215
|
+
const updated = await deps.db.query_one(`UPDATE role_grant_offer
|
|
216
|
+
SET retracted_at = NOW()
|
|
217
|
+
WHERE id = $1
|
|
218
|
+
AND from_actor_id = $2
|
|
219
|
+
AND accepted_at IS NULL
|
|
220
|
+
AND declined_at IS NULL
|
|
221
|
+
AND retracted_at IS NULL
|
|
222
|
+
AND superseded_at IS NULL
|
|
223
|
+
RETURNING *`, [offer_id, from_actor_id]);
|
|
224
|
+
if (updated)
|
|
225
|
+
return updated;
|
|
226
|
+
return resolve_terminal_or_missing(deps, offer_id, { from_actor_id });
|
|
227
|
+
};
|
|
228
|
+
/** Helper: distinguish "not found / different owner" from "already terminal". */
|
|
229
|
+
const resolve_terminal_or_missing = async (deps, offer_id, scope) => {
|
|
230
|
+
const conditions = ['id = $1'];
|
|
231
|
+
const params = [offer_id];
|
|
232
|
+
let idx = 2;
|
|
233
|
+
if (scope.to_account_id) {
|
|
234
|
+
conditions.push(`to_account_id = $${idx++}`);
|
|
235
|
+
params.push(scope.to_account_id);
|
|
236
|
+
}
|
|
237
|
+
if (scope.from_actor_id) {
|
|
238
|
+
conditions.push(`from_actor_id = $${idx++}`);
|
|
239
|
+
params.push(scope.from_actor_id);
|
|
240
|
+
}
|
|
241
|
+
const row = await deps.db.query_one(`SELECT * FROM role_grant_offer WHERE ${conditions.join(' AND ')}`, params);
|
|
242
|
+
if (!row)
|
|
243
|
+
return null;
|
|
244
|
+
if (row.accepted_at || row.declined_at || row.retracted_at || row.superseded_at) {
|
|
245
|
+
throw new RoleGrantOfferAlreadyTerminalError(offer_id);
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* List pending, non-expired offers for an account, soonest expiry first.
|
|
251
|
+
*
|
|
252
|
+
* Expired offers are filtered server-side (`expires_at > NOW()`) so the
|
|
253
|
+
* inbox never surfaces a row that can no longer be accepted. The periodic
|
|
254
|
+
* sweep (`query_role_grant_offer_sweep_expired`) handles audit tombstoning.
|
|
255
|
+
*/
|
|
256
|
+
export const query_role_grant_offer_list = async (deps, to_account_id) => {
|
|
257
|
+
return deps.db.query(`SELECT * FROM role_grant_offer
|
|
258
|
+
WHERE to_account_id = $1
|
|
259
|
+
AND accepted_at IS NULL
|
|
260
|
+
AND declined_at IS NULL
|
|
261
|
+
AND retracted_at IS NULL
|
|
262
|
+
AND superseded_at IS NULL
|
|
263
|
+
AND expires_at > NOW()
|
|
264
|
+
ORDER BY expires_at ASC`, [to_account_id]);
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* List every offer involving an account (either direction), newest first.
|
|
268
|
+
*
|
|
269
|
+
* Includes terminal offers — used by the grantor-side admin / history view.
|
|
270
|
+
*/
|
|
271
|
+
export const query_role_grant_offer_history_for_account = async (deps, account_id, limit = 100, offset = 0) => {
|
|
272
|
+
return deps.db.query(`SELECT o.* FROM role_grant_offer o
|
|
273
|
+
LEFT JOIN actor a ON a.id = o.from_actor_id
|
|
274
|
+
WHERE o.to_account_id = $1 OR a.account_id = $1
|
|
275
|
+
ORDER BY o.created_at DESC
|
|
276
|
+
LIMIT $2 OFFSET $3`, [account_id, limit, offset]);
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* Look up a pending offer by id. Returns `null` if the offer is terminal,
|
|
280
|
+
* expired (server-side filter), or missing.
|
|
281
|
+
*/
|
|
282
|
+
export const query_role_grant_offer_find_pending = async (deps, offer_id) => {
|
|
283
|
+
const row = await deps.db.query_one(`SELECT * FROM role_grant_offer
|
|
284
|
+
WHERE id = $1
|
|
285
|
+
AND accepted_at IS NULL
|
|
286
|
+
AND declined_at IS NULL
|
|
287
|
+
AND retracted_at IS NULL
|
|
288
|
+
AND superseded_at IS NULL
|
|
289
|
+
AND expires_at > NOW()`, [offer_id]);
|
|
290
|
+
return row ?? null;
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* Return pending offers whose `expires_at` has passed.
|
|
294
|
+
*
|
|
295
|
+
* Callers fire `role_grant_offer_expire` audit events for each row. The schema
|
|
296
|
+
* does not tombstone the row, so callers are responsible for their own
|
|
297
|
+
* idempotency (e.g. check whether a `role_grant_offer_expire` audit event
|
|
298
|
+
* already exists for the offer id).
|
|
299
|
+
*/
|
|
300
|
+
export const query_role_grant_offer_sweep_expired = async (deps) => {
|
|
301
|
+
return deps.db.query(`SELECT * FROM role_grant_offer
|
|
302
|
+
WHERE accepted_at IS NULL
|
|
303
|
+
AND declined_at IS NULL
|
|
304
|
+
AND retracted_at IS NULL
|
|
305
|
+
AND superseded_at IS NULL
|
|
306
|
+
AND expires_at <= NOW()
|
|
307
|
+
ORDER BY expires_at ASC`);
|
|
308
|
+
};
|
|
309
|
+
/**
|
|
310
|
+
* Accept an offer atomically: mark accepted, insert the role_grant, stamp
|
|
311
|
+
* `resulting_role_grant_id`, supersede sibling pending offers for the same
|
|
312
|
+
* `(to_account, role, scope)`, and emit `role_grant_offer_accept` +
|
|
313
|
+
* `role_grant_create` + one `role_grant_offer_supersede` per sibling. Must run
|
|
314
|
+
* inside a transaction — the caller's route spec should declare
|
|
315
|
+
* `transaction: true` (or wrap explicitly).
|
|
316
|
+
*
|
|
317
|
+
* Idempotent on race: if a second concurrent call observes the offer
|
|
318
|
+
* already accepted, returns the existing role_grant rather than creating a
|
|
319
|
+
* duplicate or throwing.
|
|
320
|
+
*
|
|
321
|
+
* Error map:
|
|
322
|
+
* - `RoleGrantOfferNotFoundError` — offer does not exist, or belongs to a
|
|
323
|
+
* different recipient (IDOR guard). The offer row is untouched.
|
|
324
|
+
* - `RoleGrantOfferAlreadyTerminalError` — offer is declined, retracted, or
|
|
325
|
+
* superseded.
|
|
326
|
+
* - `RoleGrantOfferExpiredError` — offer is pending but past `expires_at`.
|
|
327
|
+
*
|
|
328
|
+
* Sibling supersede is what closes the "accept a pre-revoke sibling offer
|
|
329
|
+
* to bypass a revoke" path: once A is accepted, B/C/... can no longer be
|
|
330
|
+
* accepted even if the resulting role_grant is later revoked.
|
|
331
|
+
*
|
|
332
|
+
* @mutates `role_grant_offer` row - stamps `accepted_at` and `resulting_role_grant_id`
|
|
333
|
+
* @mutates `role_grant` table - inserts the resulting role_grant (idempotent on race)
|
|
334
|
+
* @mutates `role_grant_offer` siblings - stamps `superseded_at` on every other pending offer for the tuple
|
|
335
|
+
* @mutates `audit_log` table - emits `role_grant_offer_accept` + `role_grant_create` + one `role_grant_offer_supersede` per sibling
|
|
336
|
+
* @throws RoleGrantOfferNotFoundError if the offer is missing or belongs to another recipient
|
|
337
|
+
* @throws RoleGrantOfferAlreadyTerminalError if the offer is declined, retracted, or superseded
|
|
338
|
+
* @throws RoleGrantOfferExpiredError if the offer is pending but past `expires_at`
|
|
339
|
+
* @throws Error if the accepting `actor_id` does not belong to `to_account_id`, or invariant assertions fail
|
|
340
|
+
*/
|
|
341
|
+
export const query_accept_offer = async (deps, input) => {
|
|
342
|
+
const { offer_id, to_account_id, actor_id, ip } = input;
|
|
343
|
+
// Claim the offer with a row-level lock. Subsequent concurrent callers
|
|
344
|
+
// block on the lock until this transaction commits/rolls back; after commit
|
|
345
|
+
// they see the new state (accepted or terminal) and branch idempotently.
|
|
346
|
+
// We defer writing `accepted_at` until the role_grant row exists — the
|
|
347
|
+
// `role_grant_offer_role_grant_iff_accepted` CHECK constraint demands both be set
|
|
348
|
+
// (or neither) at row-visibility time.
|
|
349
|
+
const locked = await deps.db.query_one(`SELECT * FROM role_grant_offer
|
|
350
|
+
WHERE id = $1 AND to_account_id = $2
|
|
351
|
+
FOR UPDATE`, [offer_id, to_account_id]);
|
|
352
|
+
if (!locked) {
|
|
353
|
+
throw new RoleGrantOfferNotFoundError(offer_id);
|
|
354
|
+
}
|
|
355
|
+
if (locked.accepted_at) {
|
|
356
|
+
// Race winner already committed; return the pre-existing role_grant.
|
|
357
|
+
// `role_grant_offer_role_grant_iff_accepted` CHECK guarantees resulting_role_grant_id is non-null.
|
|
358
|
+
const role_grant = assert_row(await deps.db.query_one(`SELECT * FROM role_grant WHERE id = $1`, [
|
|
359
|
+
locked.resulting_role_grant_id,
|
|
360
|
+
]), 'resulting_role_grant lookup');
|
|
361
|
+
// Multi-actor guard: two actors on the same recipient account may
|
|
362
|
+
// both race an account-grain offer — the loser must not silently
|
|
363
|
+
// receive the winner's role_grant (which would tell them "you got it"
|
|
364
|
+
// while the actor on the role_grant is someone else). Treat the offer
|
|
365
|
+
// as terminal for the loser.
|
|
366
|
+
if (role_grant.actor_id !== actor_id) {
|
|
367
|
+
throw new RoleGrantOfferAlreadyTerminalError(offer_id);
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
role_grant,
|
|
371
|
+
offer: locked,
|
|
372
|
+
created: false,
|
|
373
|
+
superseded_offers: [],
|
|
374
|
+
audit_events: [],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (locked.declined_at || locked.retracted_at || locked.superseded_at) {
|
|
378
|
+
throw new RoleGrantOfferAlreadyTerminalError(offer_id);
|
|
379
|
+
}
|
|
380
|
+
// Expiry check AFTER the accepted-path: a validly-accepted offer past its
|
|
381
|
+
// expires_at still returns the role_grant idempotently. Only pending offers
|
|
382
|
+
// past expiry reach this branch.
|
|
383
|
+
if (new Date(locked.expires_at) <= new Date()) {
|
|
384
|
+
throw new RoleGrantOfferExpiredError(offer_id);
|
|
385
|
+
}
|
|
386
|
+
// Actor-targeted offer gate. When the offer is account-grain
|
|
387
|
+
// (`to_actor_id IS NULL`) any actor on `to_account_id` may accept and
|
|
388
|
+
// the existing actor↔account check below applies. When actor-grain
|
|
389
|
+
// (`to_actor_id IS NOT NULL`) the accepting actor must match —
|
|
390
|
+
// reject otherwise, even when the actor is on the same account, so
|
|
391
|
+
// teacher-A's offer cannot be claimed by teacher-B's actor.
|
|
392
|
+
//
|
|
393
|
+
// Ordering contract: this check fires *before* the cross-account
|
|
394
|
+
// `actor_check` SELECT below. A wrong-actor accept on an actor-grain
|
|
395
|
+
// offer surfaces as `RoleGrantOfferActorMismatchError` regardless of
|
|
396
|
+
// whether the supplied `actor_id` belongs to `to_account_id` — the
|
|
397
|
+
// actor-grain binding is the tighter constraint and dominates. The
|
|
398
|
+
// cross-account `Error` only fires for account-grain offers (or
|
|
399
|
+
// matching actor-grain offers where `to_actor_id === actor_id` but
|
|
400
|
+
// the actor turns out not to be on the account, which is unreachable
|
|
401
|
+
// under the FK invariant but stays as defense-in-depth).
|
|
402
|
+
if (locked.to_actor_id != null && locked.to_actor_id !== actor_id) {
|
|
403
|
+
throw new RoleGrantOfferActorMismatchError(offer_id);
|
|
404
|
+
}
|
|
405
|
+
// Verify the accepting actor belongs to the recipient account.
|
|
406
|
+
// Defense-in-depth: the action handler passes `auth.actor.id` which is
|
|
407
|
+
// already session-bound, but enforcing the invariant here protects
|
|
408
|
+
// direct callers (tests, future consumers) from cross-account binding
|
|
409
|
+
// bugs that would silently grant a role_grant to the wrong actor.
|
|
410
|
+
const actor_check = await deps.db.query_one(`SELECT id FROM actor WHERE id = $1 AND account_id = $2`, [actor_id, to_account_id]);
|
|
411
|
+
if (!actor_check) {
|
|
412
|
+
throw new Error(`Accepting actor ${actor_id} does not belong to account ${to_account_id} (offer ${offer_id})`);
|
|
413
|
+
}
|
|
414
|
+
// Insert the role_grant. Uses the normal grant idempotency — if another
|
|
415
|
+
// code path already granted the same (actor, role, scope_kind, scope), reuse it.
|
|
416
|
+
const granted_role_grant = await deps.db.query_one(`INSERT INTO role_grant (actor_id, role, scope_kind, scope_id, granted_by, source_offer_id)
|
|
417
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
418
|
+
ON CONFLICT (
|
|
419
|
+
actor_id,
|
|
420
|
+
role,
|
|
421
|
+
COALESCE(scope_kind, '${ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN}'),
|
|
422
|
+
COALESCE(scope_id, '${ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID}'::uuid)
|
|
423
|
+
)
|
|
424
|
+
WHERE revoked_at IS NULL
|
|
425
|
+
DO NOTHING
|
|
426
|
+
RETURNING *`, [actor_id, locked.role, locked.scope_kind, locked.scope_id, locked.from_actor_id, locked.id]);
|
|
427
|
+
let role_grant;
|
|
428
|
+
if (granted_role_grant) {
|
|
429
|
+
role_grant = granted_role_grant;
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
const existing = await deps.db.query_one(`SELECT * FROM role_grant
|
|
433
|
+
WHERE actor_id = $1
|
|
434
|
+
AND role = $2
|
|
435
|
+
AND scope_kind IS NOT DISTINCT FROM $3
|
|
436
|
+
AND scope_id IS NOT DISTINCT FROM $4
|
|
437
|
+
AND revoked_at IS NULL`, [actor_id, locked.role, locked.scope_kind, locked.scope_id]);
|
|
438
|
+
role_grant = assert_row(existing, 'query_accept_offer idempotent role_grant lookup');
|
|
439
|
+
}
|
|
440
|
+
// Single UPDATE sets both sides of the CHECK constraint at once.
|
|
441
|
+
const offer_accepted = await deps.db.query_one(`UPDATE role_grant_offer
|
|
442
|
+
SET accepted_at = NOW(), resulting_role_grant_id = $2
|
|
443
|
+
WHERE id = $1
|
|
444
|
+
RETURNING *`, [locked.id, role_grant.id]);
|
|
445
|
+
const offer = assert_row(offer_accepted, 'mark offer accepted');
|
|
446
|
+
// Supersede sibling pending offers for the same (to_account, role, scope).
|
|
447
|
+
// Forecloses the "accept this other sibling later to get the role back
|
|
448
|
+
// after a revoke" path — any pending offer for this tuple at accept time
|
|
449
|
+
// is obsoleted by the accept. CTE joins `actor` to surface each sibling's
|
|
450
|
+
// grantor `account_id` for the caller's notification fan-out.
|
|
451
|
+
const superseded = await deps.db.query(`WITH updated AS (
|
|
452
|
+
UPDATE role_grant_offer
|
|
453
|
+
SET superseded_at = NOW()
|
|
454
|
+
WHERE to_account_id = $1
|
|
455
|
+
AND role = $2
|
|
456
|
+
AND scope_id IS NOT DISTINCT FROM $3
|
|
457
|
+
AND id <> $4
|
|
458
|
+
AND accepted_at IS NULL
|
|
459
|
+
AND declined_at IS NULL
|
|
460
|
+
AND retracted_at IS NULL
|
|
461
|
+
AND superseded_at IS NULL
|
|
462
|
+
RETURNING *
|
|
463
|
+
)
|
|
464
|
+
SELECT u.*, grantor.account_id AS from_account_id
|
|
465
|
+
FROM updated u
|
|
466
|
+
JOIN actor grantor ON grantor.id = u.from_actor_id`, [to_account_id, offer.role, offer.scope_id, offer.id]);
|
|
467
|
+
// Emit audit events in-transaction (atomic with the role_grant insert).
|
|
468
|
+
// `RETURNING *` after the SET guarantees `offer.resulting_role_grant_id === role_grant.id`.
|
|
469
|
+
// Accept binds the actor deterministically — populate both target
|
|
470
|
+
// columns to mirror `role_grant_create` (the in-tx pair) so forensic
|
|
471
|
+
// queries don't have to split between the two events.
|
|
472
|
+
const offer_accept_event = await query_audit_log(deps, {
|
|
473
|
+
event_type: 'role_grant_offer_accept',
|
|
474
|
+
actor_id,
|
|
475
|
+
account_id: to_account_id,
|
|
476
|
+
target_account_id: to_account_id,
|
|
477
|
+
target_actor_id: actor_id,
|
|
478
|
+
ip: ip ?? null,
|
|
479
|
+
metadata: {
|
|
480
|
+
offer_id: offer.id,
|
|
481
|
+
role_grant_id: role_grant.id,
|
|
482
|
+
role: offer.role,
|
|
483
|
+
scope_id: offer.scope_id,
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
// `role_grant_create` is the canonical actor-bound-subject event — the
|
|
487
|
+
// role_grant just bound to this actor. On self-accept the actor and the
|
|
488
|
+
// target are the same identity; on admin direct-grant (separate code
|
|
489
|
+
// path) they differ. Either way `target_actor_id` carries the
|
|
490
|
+
// grantee for actor-grain forensics.
|
|
491
|
+
const role_grant_create_event = await query_audit_log(deps, {
|
|
492
|
+
event_type: 'role_grant_create',
|
|
493
|
+
actor_id,
|
|
494
|
+
account_id: to_account_id,
|
|
495
|
+
target_account_id: to_account_id,
|
|
496
|
+
target_actor_id: actor_id,
|
|
497
|
+
ip: ip ?? null,
|
|
498
|
+
metadata: {
|
|
499
|
+
role: offer.role,
|
|
500
|
+
role_grant_id: role_grant.id,
|
|
501
|
+
scope_id: offer.scope_id,
|
|
502
|
+
source_offer_id: offer.id,
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
const supersede_events = [];
|
|
506
|
+
for (const sibling of superseded) {
|
|
507
|
+
// Supersede inherits the sibling's actor-grain target — actor-grain
|
|
508
|
+
// when the sibling was actor-targeted, account-grain (null) when it
|
|
509
|
+
// was account-level.
|
|
510
|
+
supersede_events.push(await query_audit_log(deps, {
|
|
511
|
+
event_type: 'role_grant_offer_supersede',
|
|
512
|
+
actor_id,
|
|
513
|
+
account_id: to_account_id,
|
|
514
|
+
target_account_id: to_account_id,
|
|
515
|
+
target_actor_id: sibling.to_actor_id,
|
|
516
|
+
ip: ip ?? null,
|
|
517
|
+
metadata: {
|
|
518
|
+
offer_id: sibling.id,
|
|
519
|
+
role: sibling.role,
|
|
520
|
+
scope_id: sibling.scope_id,
|
|
521
|
+
reason: 'sibling_accepted',
|
|
522
|
+
cause_id: offer.id,
|
|
523
|
+
},
|
|
524
|
+
}));
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
role_grant,
|
|
528
|
+
offer,
|
|
529
|
+
created: true,
|
|
530
|
+
superseded_offers: superseded,
|
|
531
|
+
audit_events: [offer_accept_event, role_grant_create_event, ...supersede_events],
|
|
532
|
+
};
|
|
533
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role grant offer DDL, types, and client-safe schemas.
|
|
3
|
+
*
|
|
4
|
+
* An offer is a pending grant awaiting recipient consent. Lifecycle states
|
|
5
|
+
* are mutually exclusive via a CHECK constraint (`role_grant_offer_single_terminal`):
|
|
6
|
+
* at most one of `accepted_at` / `declined_at` / `retracted_at` may be set.
|
|
7
|
+
* On accept, the offer's `resulting_role_grant_id` links to the role_grant row
|
|
8
|
+
* produced by `query_accept_offer`.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { Uuid } from '@fuzdev/fuz_util/id.js';
|
|
14
|
+
/** Sentinel UUID used inside the partial unique indexes to collapse `scope_id IS NULL` into a comparable value. */
|
|
15
|
+
export declare const ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
16
|
+
/** Maximum length of the optional message attached to an offer. */
|
|
17
|
+
export declare const ROLE_GRANT_OFFER_MESSAGE_LENGTH_MAX = 500;
|
|
18
|
+
/** Default TTL for a newly created offer — 30 days. Matches GitHub org-invite expiry. */
|
|
19
|
+
export declare const ROLE_GRANT_OFFER_DEFAULT_TTL_MS: number;
|
|
20
|
+
export declare const ROLE_GRANT_OFFER_SCHEMA = "\nCREATE TABLE IF NOT EXISTS role_grant_offer (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n from_actor_id UUID NOT NULL REFERENCES actor(id) ON DELETE CASCADE,\n to_account_id UUID NOT NULL REFERENCES account(id) ON DELETE CASCADE,\n to_actor_id UUID NULL REFERENCES actor(id) ON DELETE CASCADE,\n role TEXT NOT NULL,\n scope_kind TEXT NULL,\n scope_id UUID NULL,\n message TEXT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n expires_at TIMESTAMPTZ NOT NULL,\n accepted_at TIMESTAMPTZ NULL,\n declined_at TIMESTAMPTZ NULL,\n decline_reason TEXT NULL,\n retracted_at TIMESTAMPTZ NULL,\n superseded_at TIMESTAMPTZ NULL,\n resulting_role_grant_id UUID NULL REFERENCES role_grant(id) ON DELETE SET NULL,\n CONSTRAINT role_grant_offer_single_terminal CHECK (\n (accepted_at IS NOT NULL)::int\n + (declined_at IS NOT NULL)::int\n + (retracted_at IS NOT NULL)::int\n + (superseded_at IS NOT NULL)::int\n <= 1\n ),\n CONSTRAINT role_grant_offer_role_grant_iff_accepted CHECK (\n (accepted_at IS NOT NULL) = (resulting_role_grant_id IS NOT NULL)\n ),\n CONSTRAINT role_grant_offer_reason_iff_declined CHECK (\n decline_reason IS NULL OR declined_at IS NOT NULL\n ),\n CONSTRAINT role_grant_offer_scope_kind_paired CHECK (\n (scope_kind IS NULL) = (scope_id IS NULL)\n )\n)";
|
|
21
|
+
/**
|
|
22
|
+
* Index-side token for the global case in the partial unique index. Uppercase
|
|
23
|
+
* so it cannot collide with consumer-declared `ScopeKindName` values (which
|
|
24
|
+
* are lowercase by regex). Never appears as a column value — column-level
|
|
25
|
+
* `scope_kind = NULL` and `scope_id = NULL` together encode the global case.
|
|
26
|
+
*/
|
|
27
|
+
export declare const ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN = "GLOBAL";
|
|
28
|
+
/**
|
|
29
|
+
* At most one pending offer per (to_account, role, scope_kind, scope, from_actor).
|
|
30
|
+
*
|
|
31
|
+
* Including `from_actor_id` in the tuple lets multiple grantors coexist —
|
|
32
|
+
* teacher A and teacher B can each have a pending `classroom_student` offer
|
|
33
|
+
* for the same student and scope. A same-grantor re-offer upserts the
|
|
34
|
+
* existing pending row. `COALESCE` collapses `NULL` scopes into the
|
|
35
|
+
* sentinel values so Postgres's NULL-in-unique-index quirk does not allow
|
|
36
|
+
* duplicate global pending offers; the `scope_kind` / `scope_id` pair is
|
|
37
|
+
* always either both null (global) or both non-null (scoped) per the
|
|
38
|
+
* `role_grant_offer_scope_kind_paired` CHECK, so the two COALESCE expressions
|
|
39
|
+
* always agree. The ON CONFLICT target in `query_role_grant_offer_create` must
|
|
40
|
+
* match this expression literally.
|
|
41
|
+
*/
|
|
42
|
+
export declare const ROLE_GRANT_OFFER_PENDING_UNIQUE_INDEX = "\nCREATE UNIQUE INDEX IF NOT EXISTS role_grant_offer_pending_unique\n ON role_grant_offer (\n to_account_id,\n role,\n COALESCE(scope_kind, 'GLOBAL'),\n COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'::uuid),\n from_actor_id\n )\n WHERE accepted_at IS NULL\n AND declined_at IS NULL\n AND retracted_at IS NULL\n AND superseded_at IS NULL";
|
|
43
|
+
/** Inbox lookup — pending offers for an account, ordered by soonest expiry. */
|
|
44
|
+
export declare const ROLE_GRANT_OFFER_INBOX_INDEX = "\nCREATE INDEX IF NOT EXISTS role_grant_offer_inbox\n ON role_grant_offer (to_account_id, expires_at)\n WHERE accepted_at IS NULL\n AND declined_at IS NULL\n AND retracted_at IS NULL\n AND superseded_at IS NULL";
|
|
45
|
+
/** Role grant offer row as returned by the database. */
|
|
46
|
+
export interface RoleGrantOffer {
|
|
47
|
+
id: Uuid;
|
|
48
|
+
from_actor_id: Uuid;
|
|
49
|
+
to_account_id: Uuid;
|
|
50
|
+
/**
|
|
51
|
+
* Optional actor-grain target on the recipient account. When set, accept
|
|
52
|
+
* is gated to this specific actor — `query_accept_offer` rejects any
|
|
53
|
+
* other actor with `role_grant_offer_actor_mismatch` even when they belong
|
|
54
|
+
* to `to_account_id`. When null the offer is account-grain and any
|
|
55
|
+
* actor on `to_account_id` may accept (the v1 default).
|
|
56
|
+
*
|
|
57
|
+
* Drives the audit envelope's `target_actor_id` on offer-shape events
|
|
58
|
+
* (`role_grant_offer_create` / `_expire` / `_retract` / `_supersede`) — when
|
|
59
|
+
* set, the actor-grain forensic field carries the named actor; when
|
|
60
|
+
* null the offer-shape events leave it null by design.
|
|
61
|
+
*/
|
|
62
|
+
to_actor_id: Uuid | null;
|
|
63
|
+
role: string;
|
|
64
|
+
/**
|
|
65
|
+
* Machine-readable kind tag for the polymorphic `scope_id`. Paired-null
|
|
66
|
+
* with `scope_id` per the `role_grant_offer_scope_kind_paired` CHECK: both
|
|
67
|
+
* null (global) or both non-null (scoped). Consumer-declared via
|
|
68
|
+
* `create_scope_kind_schema(...)`; v1 keeps validation registry-membership
|
|
69
|
+
* only, with no INSERT-time `(role, scope_kind)` enforcement.
|
|
70
|
+
*/
|
|
71
|
+
scope_kind: string | null;
|
|
72
|
+
scope_id: Uuid | null;
|
|
73
|
+
message: string | null;
|
|
74
|
+
created_at: string;
|
|
75
|
+
expires_at: string;
|
|
76
|
+
accepted_at: string | null;
|
|
77
|
+
declined_at: string | null;
|
|
78
|
+
decline_reason: string | null;
|
|
79
|
+
retracted_at: string | null;
|
|
80
|
+
/**
|
|
81
|
+
* Set when the offer was obsoleted by an external event — a sibling
|
|
82
|
+
* offer was accepted (yielding the role_grant this offer's role+scope maps to)
|
|
83
|
+
* or the resulting role_grant for this (to_account, role, scope) was revoked.
|
|
84
|
+
* Closes the "accept a pre-revoke offer to bypass the revoke" path.
|
|
85
|
+
*/
|
|
86
|
+
superseded_at: string | null;
|
|
87
|
+
resulting_role_grant_id: Uuid | null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A superseded offer row annotated with the grantor's `account_id`.
|
|
91
|
+
*
|
|
92
|
+
* Carried by `superseded_offers` in accept/revoke query results so callers
|
|
93
|
+
* can fan out `role_grant_offer_supersede` notifications to the grantor's
|
|
94
|
+
* sockets without a second round-trip. Populated via a CTE join on `actor`
|
|
95
|
+
* in the supersede UPDATE.
|
|
96
|
+
*/
|
|
97
|
+
export interface SupersededOffer extends RoleGrantOffer {
|
|
98
|
+
from_account_id: Uuid;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Input for `query_role_grant_offer_create`.
|
|
102
|
+
*
|
|
103
|
+
* `expires_at` must be supplied — the query layer does not apply a default,
|
|
104
|
+
* so callers can thread their own TTL (typically `ROLE_GRANT_OFFER_DEFAULT_TTL_MS`).
|
|
105
|
+
*/
|
|
106
|
+
export interface CreateRoleGrantOfferInput {
|
|
107
|
+
from_actor_id: Uuid;
|
|
108
|
+
to_account_id: Uuid;
|
|
109
|
+
/**
|
|
110
|
+
* Optional actor-grain target on the recipient account. When set,
|
|
111
|
+
* `query_role_grant_offer_create` validates that the actor belongs to
|
|
112
|
+
* `to_account_id` and stamps the column; accept then matches against
|
|
113
|
+
* this specific actor. Omit (or pass null) for the account-grain
|
|
114
|
+
* default — any actor on `to_account_id` may accept.
|
|
115
|
+
*/
|
|
116
|
+
to_actor_id?: Uuid | null;
|
|
117
|
+
role: string;
|
|
118
|
+
/**
|
|
119
|
+
* Machine-readable kind for the `scope_id`. Required iff `scope_id` is
|
|
120
|
+
* set; must be null when `scope_id` is null (DB-level CHECK rejects the
|
|
121
|
+
* mismatch). Consumer-declared via `create_scope_kind_schema(...)`.
|
|
122
|
+
*/
|
|
123
|
+
scope_kind?: string | null;
|
|
124
|
+
scope_id?: Uuid | null;
|
|
125
|
+
message?: string | null;
|
|
126
|
+
expires_at: Date;
|
|
127
|
+
}
|
|
128
|
+
/** Zod schema for client-safe role_grant offer data. */
|
|
129
|
+
export declare const RoleGrantOfferJson: z.ZodObject<{
|
|
130
|
+
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
131
|
+
from_actor_id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
132
|
+
to_account_id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
133
|
+
to_actor_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
134
|
+
role: z.ZodString;
|
|
135
|
+
scope_kind: z.ZodNullable<z.ZodString>;
|
|
136
|
+
scope_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
137
|
+
message: z.ZodNullable<z.ZodString>;
|
|
138
|
+
created_at: z.ZodString;
|
|
139
|
+
expires_at: z.ZodString;
|
|
140
|
+
accepted_at: z.ZodNullable<z.ZodString>;
|
|
141
|
+
declined_at: z.ZodNullable<z.ZodString>;
|
|
142
|
+
decline_reason: z.ZodNullable<z.ZodString>;
|
|
143
|
+
retracted_at: z.ZodNullable<z.ZodString>;
|
|
144
|
+
superseded_at: z.ZodNullable<z.ZodString>;
|
|
145
|
+
resulting_role_grant_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
146
|
+
}, z.core.$strict>;
|
|
147
|
+
export type RoleGrantOfferJson = z.infer<typeof RoleGrantOfferJson>;
|
|
148
|
+
/** Convert a `RoleGrantOffer` row to its JSON payload shape. */
|
|
149
|
+
export declare const to_role_grant_offer_json: (offer: RoleGrantOffer) => RoleGrantOfferJson;
|
|
150
|
+
//# sourceMappingURL=role_grant_offer_schema.d.ts.map
|