@fuzdev/fuz_app 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +211 -155
- package/dist/actions/action_bridge.d.ts +8 -5
- package/dist/actions/action_bridge.d.ts.map +1 -1
- package/dist/actions/action_bridge.js +1 -11
- package/dist/actions/action_codegen.d.ts +19 -0
- package/dist/actions/action_codegen.d.ts.map +1 -1
- package/dist/actions/action_codegen.js +20 -14
- package/dist/actions/action_registry.d.ts.map +1 -1
- package/dist/actions/action_registry.js +5 -2
- package/dist/actions/action_rpc.d.ts +110 -44
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +92 -287
- package/dist/actions/action_spec.d.ts +55 -16
- package/dist/actions/action_spec.d.ts.map +1 -1
- package/dist/actions/action_spec.js +16 -11
- package/dist/actions/action_types.d.ts +28 -60
- package/dist/actions/action_types.d.ts.map +1 -1
- package/dist/actions/action_types.js +13 -5
- package/dist/actions/broadcast_api.d.ts +2 -2
- package/dist/actions/broadcast_api.js +2 -2
- package/dist/actions/compile_action_registry.d.ts +50 -0
- package/dist/actions/compile_action_registry.d.ts.map +1 -0
- package/dist/actions/compile_action_registry.js +69 -0
- package/dist/actions/heartbeat.d.ts +8 -4
- package/dist/actions/heartbeat.d.ts.map +1 -1
- package/dist/actions/heartbeat.js +5 -4
- package/dist/actions/perform_action.d.ts +145 -0
- package/dist/actions/perform_action.d.ts.map +1 -0
- package/dist/actions/perform_action.js +258 -0
- package/dist/actions/register_action_ws.d.ts +44 -38
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +101 -159
- package/dist/actions/register_ws_endpoint.d.ts +2 -10
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +32 -10
- package/dist/actions/transports_ws_auth_guard.d.ts +1 -1
- package/dist/actions/transports_ws_auth_guard.js +1 -1
- package/dist/actions/transports_ws_backend.d.ts +1 -1
- package/dist/actions/transports_ws_backend.js +1 -1
- package/dist/auth/CLAUDE.md +673 -442
- package/dist/auth/account_action_specs.d.ts +28 -7
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +7 -7
- package/dist/auth/account_actions.d.ts +8 -14
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +26 -32
- package/dist/auth/account_queries.d.ts +46 -13
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +73 -33
- package/dist/auth/account_routes.d.ts +4 -3
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +58 -33
- package/dist/auth/account_schema.d.ts +46 -54
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +21 -48
- package/dist/auth/admin_action_specs.d.ts +55 -21
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +42 -26
- package/dist/auth/admin_actions.d.ts +14 -21
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +47 -44
- package/dist/auth/audit_emitter.d.ts +160 -0
- package/dist/auth/audit_emitter.d.ts.map +1 -0
- package/dist/auth/audit_emitter.js +83 -0
- package/dist/auth/audit_log_queries.d.ts +17 -87
- package/dist/auth/audit_log_queries.d.ts.map +1 -1
- package/dist/auth/audit_log_queries.js +17 -96
- package/dist/auth/audit_log_routes.d.ts +1 -1
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +7 -3
- package/dist/auth/audit_log_schema.d.ts +48 -42
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +56 -43
- package/dist/auth/auth_guard_resolver.d.ts +44 -0
- package/dist/auth/auth_guard_resolver.d.ts.map +1 -0
- package/dist/auth/auth_guard_resolver.js +56 -0
- package/dist/auth/bootstrap_account.d.ts +7 -7
- package/dist/auth/bootstrap_account.d.ts.map +1 -1
- package/dist/auth/bootstrap_account.js +7 -7
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +11 -10
- package/dist/auth/cleanup.d.ts +20 -26
- package/dist/auth/cleanup.d.ts.map +1 -1
- package/dist/auth/cleanup.js +33 -47
- package/dist/auth/credential_type_schema.d.ts +115 -0
- package/dist/auth/credential_type_schema.d.ts.map +1 -0
- package/dist/auth/credential_type_schema.js +127 -0
- package/dist/auth/daemon_token_middleware.d.ts +1 -1
- package/dist/auth/daemon_token_middleware.js +3 -3
- package/dist/auth/ddl.d.ts +2 -2
- package/dist/auth/ddl.d.ts.map +1 -1
- package/dist/auth/ddl.js +6 -6
- package/dist/auth/deps.d.ts +7 -32
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/grant_path_schema.d.ts +117 -0
- package/dist/auth/grant_path_schema.d.ts.map +1 -0
- package/dist/auth/grant_path_schema.js +137 -0
- package/dist/auth/invite_queries.d.ts +12 -1
- package/dist/auth/invite_queries.d.ts.map +1 -1
- package/dist/auth/invite_queries.js +12 -1
- package/dist/auth/invite_schema.d.ts +1 -1
- package/dist/auth/invite_schema.d.ts.map +1 -1
- package/dist/auth/invite_schema.js +1 -1
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +5 -2
- package/dist/auth/migrations.d.ts +22 -7
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +64 -25
- package/dist/auth/request_context.d.ts +157 -170
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +224 -268
- package/dist/auth/{permit_offer_action_specs.d.ts → role_grant_offer_action_specs.d.ts} +130 -100
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -0
- package/dist/auth/role_grant_offer_action_specs.js +262 -0
- package/dist/auth/role_grant_offer_actions.d.ts +104 -0
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -0
- package/dist/auth/{permit_offer_actions.js → role_grant_offer_actions.js} +153 -140
- package/dist/auth/{permit_offer_notifications.d.ts → role_grant_offer_notifications.d.ts} +80 -70
- package/dist/auth/role_grant_offer_notifications.d.ts.map +1 -0
- package/dist/auth/role_grant_offer_notifications.js +182 -0
- package/dist/auth/{permit_offer_queries.d.ts → role_grant_offer_queries.d.ts} +64 -64
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -0
- package/dist/auth/{permit_offer_queries.js → role_grant_offer_queries.js} +136 -123
- package/dist/auth/role_grant_offer_schema.d.ts +150 -0
- package/dist/auth/role_grant_offer_schema.d.ts.map +1 -0
- package/dist/auth/{permit_offer_schema.js → role_grant_offer_schema.js} +55 -36
- package/dist/auth/role_grant_queries.d.ts +231 -0
- package/dist/auth/role_grant_queries.d.ts.map +1 -0
- package/dist/auth/role_grant_queries.js +320 -0
- package/dist/auth/role_schema.d.ts +150 -40
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +144 -45
- package/dist/auth/scope_kind_schema.d.ts +96 -0
- package/dist/auth/scope_kind_schema.d.ts.map +1 -0
- package/dist/auth/scope_kind_schema.js +94 -0
- package/dist/auth/self_service_role_action_specs.d.ts +4 -1
- package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
- package/dist/auth/self_service_role_action_specs.js +2 -2
- package/dist/auth/self_service_role_actions.d.ts +35 -29
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.js +58 -48
- package/dist/auth/session_cookie.d.ts +43 -6
- package/dist/auth/session_cookie.d.ts.map +1 -1
- package/dist/auth/session_cookie.js +31 -5
- package/dist/auth/session_middleware.d.ts +37 -3
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +33 -7
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +48 -19
- package/dist/auth/standard_action_specs.d.ts +2 -2
- package/dist/auth/standard_action_specs.js +4 -4
- package/dist/auth/standard_rpc_actions.d.ts +23 -19
- package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
- package/dist/auth/standard_rpc_actions.js +12 -12
- package/dist/db/migrate.d.ts +1 -1
- package/dist/db/migrate.js +1 -1
- package/dist/dev/setup.d.ts +2 -2
- package/dist/dev/setup.d.ts.map +1 -1
- package/dist/dev/setup.js +4 -4
- package/dist/env/load.d.ts +1 -1
- package/dist/env/load.js +1 -1
- package/dist/hono_context.d.ts +27 -45
- package/dist/hono_context.d.ts.map +1 -1
- package/dist/hono_context.js +14 -28
- package/dist/http/CLAUDE.md +235 -121
- package/dist/http/auth_shape.d.ts +191 -0
- package/dist/http/auth_shape.d.ts.map +1 -0
- package/dist/http/auth_shape.js +237 -0
- package/dist/http/common_routes.js +3 -3
- package/dist/http/db_routes.d.ts +4 -0
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +44 -7
- package/dist/http/error_schemas.d.ts +72 -39
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +81 -33
- package/dist/http/pending_effects.d.ts +71 -18
- package/dist/http/pending_effects.d.ts.map +1 -1
- package/dist/http/pending_effects.js +87 -18
- package/dist/http/proxy.d.ts +52 -5
- package/dist/http/proxy.d.ts.map +1 -1
- package/dist/http/proxy.js +92 -14
- package/dist/http/route_spec.d.ts +89 -75
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +54 -72
- package/dist/http/schema_helpers.d.ts +3 -14
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +2 -14
- package/dist/http/surface.d.ts +2 -10
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +3 -4
- package/dist/http/surface_query.d.ts +39 -35
- package/dist/http/surface_query.d.ts.map +1 -1
- package/dist/http/surface_query.js +79 -36
- package/dist/primitive_schemas.d.ts +39 -0
- package/dist/primitive_schemas.d.ts.map +1 -0
- package/dist/primitive_schemas.js +40 -0
- package/dist/realtime/sse_auth_guard.d.ts +5 -5
- package/dist/realtime/sse_auth_guard.js +9 -9
- package/dist/runtime/mock.d.ts +1 -1
- package/dist/runtime/mock.js +1 -1
- package/dist/server/app_backend.d.ts +14 -11
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +12 -8
- package/dist/server/app_server.d.ts +7 -7
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +35 -40
- package/dist/server/validate_nginx.d.ts +1 -1
- package/dist/server/validate_nginx.js +1 -1
- package/dist/testing/CLAUDE.md +50 -38
- package/dist/testing/admin_integration.d.ts +5 -6
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +87 -85
- package/dist/testing/app_server.d.ts +11 -14
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +16 -15
- package/dist/testing/assertions.d.ts.map +1 -1
- package/dist/testing/assertions.js +2 -1
- package/dist/testing/attack_surface.d.ts.map +1 -1
- package/dist/testing/attack_surface.js +15 -9
- package/dist/testing/audit_completeness.d.ts +2 -2
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +36 -36
- package/dist/testing/auth_apps.d.ts +5 -4
- package/dist/testing/auth_apps.d.ts.map +1 -1
- package/dist/testing/auth_apps.js +22 -19
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/data_exposure.js +5 -5
- package/dist/testing/db.d.ts +1 -1
- package/dist/testing/db.d.ts.map +1 -1
- package/dist/testing/db.js +4 -4
- package/dist/testing/db_entities.d.ts +22 -0
- package/dist/testing/db_entities.d.ts.map +1 -0
- package/dist/testing/db_entities.js +28 -0
- package/dist/testing/entities.d.ts +8 -7
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +21 -18
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +13 -14
- package/dist/testing/integration_helpers.d.ts +4 -4
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +20 -18
- package/dist/testing/middleware.d.ts +4 -4
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +12 -11
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.js +40 -24
- package/dist/testing/rpc_round_trip.d.ts +1 -1
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.js +14 -13
- package/dist/testing/sse_round_trip.d.ts +3 -4
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +7 -11
- package/dist/testing/standard.d.ts +1 -1
- package/dist/testing/stubs.d.ts +25 -0
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +43 -2
- package/dist/testing/surface_invariants.d.ts +14 -6
- package/dist/testing/surface_invariants.d.ts.map +1 -1
- package/dist/testing/surface_invariants.js +119 -43
- package/dist/testing/ws_round_trip.d.ts +12 -13
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +19 -11
- package/dist/ui/AdminAccounts.svelte +23 -20
- package/dist/ui/AdminOverview.svelte +15 -13
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/{AdminPermitHistory.svelte → AdminRoleGrantHistory.svelte} +12 -12
- package/dist/ui/AdminRoleGrantHistory.svelte.d.ts +4 -0
- package/dist/ui/AdminRoleGrantHistory.svelte.d.ts.map +1 -0
- package/dist/ui/BootstrapForm.svelte +1 -1
- package/dist/ui/CLAUDE.md +60 -60
- package/dist/ui/{PermitOfferForm.svelte → RoleGrantOfferForm.svelte} +27 -26
- package/dist/ui/{PermitOfferForm.svelte.d.ts → RoleGrantOfferForm.svelte.d.ts} +7 -7
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts.map +1 -0
- package/dist/ui/{PermitOfferHistory.svelte → RoleGrantOfferHistory.svelte} +12 -12
- package/dist/ui/{PermitOfferHistory.svelte.d.ts → RoleGrantOfferHistory.svelte.d.ts} +4 -4
- package/dist/ui/RoleGrantOfferHistory.svelte.d.ts.map +1 -0
- package/dist/ui/{PermitOfferInbox.svelte → RoleGrantOfferInbox.svelte} +14 -14
- package/dist/ui/{PermitOfferInbox.svelte.d.ts → RoleGrantOfferInbox.svelte.d.ts} +4 -4
- package/dist/ui/RoleGrantOfferInbox.svelte.d.ts.map +1 -0
- package/dist/ui/SignupForm.svelte +1 -1
- package/dist/ui/SurfaceExplorer.svelte +35 -15
- package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.d.ts +2 -3
- package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.js +2 -3
- package/dist/ui/admin_accounts_state.svelte.d.ts +18 -18
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +16 -16
- package/dist/ui/admin_rpc_adapters.d.ts +20 -20
- package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
- package/dist/ui/admin_rpc_adapters.js +17 -17
- package/dist/ui/admin_sessions_state.svelte.d.ts +2 -2
- package/dist/ui/admin_sessions_state.svelte.js +2 -2
- package/dist/ui/audit_log_state.svelte.d.ts +7 -7
- package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
- package/dist/ui/audit_log_state.svelte.js +6 -6
- package/dist/ui/auth_state.svelte.d.ts +3 -3
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +6 -6
- package/dist/ui/format_scope.d.ts +2 -2
- package/dist/ui/format_scope.js +2 -2
- package/dist/ui/{permit_offers_state.svelte.d.ts → role_grant_offers_state.svelte.d.ts} +30 -30
- package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -0
- package/dist/ui/{permit_offers_state.svelte.js → role_grant_offers_state.svelte.js} +18 -18
- package/dist/ui/ui_format.js +2 -2
- package/package.json +3 -3
- package/dist/auth/permit_offer_action_specs.d.ts.map +0 -1
- package/dist/auth/permit_offer_action_specs.js +0 -258
- package/dist/auth/permit_offer_actions.d.ts +0 -110
- package/dist/auth/permit_offer_actions.d.ts.map +0 -1
- package/dist/auth/permit_offer_notifications.d.ts.map +0 -1
- package/dist/auth/permit_offer_notifications.js +0 -182
- package/dist/auth/permit_offer_queries.d.ts.map +0 -1
- package/dist/auth/permit_offer_schema.d.ts +0 -125
- package/dist/auth/permit_offer_schema.d.ts.map +0 -1
- package/dist/auth/permit_queries.d.ts +0 -222
- package/dist/auth/permit_queries.d.ts.map +0 -1
- package/dist/auth/permit_queries.js +0 -305
- package/dist/auth/require_keeper.d.ts +0 -20
- package/dist/auth/require_keeper.d.ts.map +0 -1
- package/dist/auth/require_keeper.js +0 -35
- package/dist/auth/route_guards.d.ts +0 -27
- package/dist/auth/route_guards.d.ts.map +0 -1
- package/dist/auth/route_guards.js +0 -38
- package/dist/auth/session_lifecycle.d.ts +0 -37
- package/dist/auth/session_lifecycle.d.ts.map +0 -1
- package/dist/auth/session_lifecycle.js +0 -29
- package/dist/ui/AdminPermitHistory.svelte.d.ts +0 -4
- package/dist/ui/AdminPermitHistory.svelte.d.ts.map +0 -1
- package/dist/ui/PermitOfferForm.svelte.d.ts.map +0 -1
- package/dist/ui/PermitOfferHistory.svelte.d.ts.map +0 -1
- package/dist/ui/PermitOfferInbox.svelte.d.ts.map +0 -1
- package/dist/ui/permit_offers_state.svelte.d.ts.map +0 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Role grant offer database queries.
|
|
3
3
|
*
|
|
4
|
-
* Covers the offer side of the consentful-
|
|
4
|
+
* Covers the offer side of the consentful-role-grants flow: create (with
|
|
5
5
|
* re-offer upsert), decline, retract, list, find-pending, sweep-expired,
|
|
6
|
-
* and the atomic `query_accept_offer` that bridges offer →
|
|
6
|
+
* and the atomic `query_accept_offer` that bridges offer → role_grant.
|
|
7
7
|
*
|
|
8
8
|
* IDOR guards are expressed in each helper's signature — decline/accept
|
|
9
9
|
* require the recipient's `to_account_id`, retract requires the grantor's
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
* @module
|
|
13
13
|
*/
|
|
14
14
|
import { assert_row } from '../db/assert_row.js';
|
|
15
|
-
import {
|
|
15
|
+
import { ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN, ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID, } from './role_grant_offer_schema.js';
|
|
16
16
|
import { query_audit_log } from './audit_log_queries.js';
|
|
17
17
|
/**
|
|
18
18
|
* Error thrown by offer-lifecycle queries when the offer is in a non-pending
|
|
19
19
|
* state (accepted / declined / retracted / superseded) and therefore not
|
|
20
|
-
* actionable. Distinct from `
|
|
20
|
+
* actionable. Distinct from `RoleGrantOfferExpiredError` — expiry has its own
|
|
21
21
|
* user-facing story ("ask the grantor to re-send") so it travels separately.
|
|
22
22
|
*/
|
|
23
|
-
export class
|
|
23
|
+
export class RoleGrantOfferAlreadyTerminalError extends Error {
|
|
24
24
|
constructor(offer_id) {
|
|
25
25
|
super(`Offer ${offer_id} is already in a terminal state`);
|
|
26
|
-
this.name = '
|
|
26
|
+
this.name = 'RoleGrantOfferAlreadyTerminalError';
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
@@ -32,10 +32,10 @@ export class PermitOfferAlreadyTerminalError extends Error {
|
|
|
32
32
|
* must not be accepted, even in the race window between expiry and the
|
|
33
33
|
* sweep stamping the audit event.
|
|
34
34
|
*/
|
|
35
|
-
export class
|
|
35
|
+
export class RoleGrantOfferExpiredError extends Error {
|
|
36
36
|
constructor(offer_id) {
|
|
37
37
|
super(`Offer ${offer_id} has expired`);
|
|
38
|
-
this.name = '
|
|
38
|
+
this.name = 'RoleGrantOfferExpiredError';
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
@@ -44,14 +44,14 @@ export class PermitOfferExpiredError extends Error {
|
|
|
44
44
|
* (IDOR guard) — the standard 404-over-403 pattern that avoids disclosing
|
|
45
45
|
* whether an offer id exists.
|
|
46
46
|
*/
|
|
47
|
-
export class
|
|
47
|
+
export class RoleGrantOfferNotFoundError extends Error {
|
|
48
48
|
constructor(offer_id) {
|
|
49
49
|
super(`Offer ${offer_id} not found`);
|
|
50
|
-
this.name = '
|
|
50
|
+
this.name = 'RoleGrantOfferNotFoundError';
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
|
-
* Error thrown when a grantor attempts to offer a
|
|
54
|
+
* Error thrown when a grantor attempts to offer a role_grant to their own account.
|
|
55
55
|
*
|
|
56
56
|
* Enforced via a single SELECT on the grantor's `actor.account_id` (rather
|
|
57
57
|
* than via a CHECK constraint or a denormalized column). Resolving from the
|
|
@@ -59,40 +59,40 @@ export class PermitOfferNotFoundError extends Error {
|
|
|
59
59
|
* recipient account may host many actors, but the grantor → account binding
|
|
60
60
|
* remains 1:1 by definition of `actor`.
|
|
61
61
|
*/
|
|
62
|
-
export class
|
|
62
|
+
export class RoleGrantOfferSelfTargetError extends Error {
|
|
63
63
|
constructor() {
|
|
64
|
-
super('Cannot offer a
|
|
65
|
-
this.name = '
|
|
64
|
+
super('Cannot offer a role_grant to your own account');
|
|
65
|
+
this.name = 'RoleGrantOfferSelfTargetError';
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
69
|
* Error thrown when an actor-targeted offer is being accepted by an actor
|
|
70
|
-
* other than `offer.to_actor_id`. Distinct from `
|
|
70
|
+
* other than `offer.to_actor_id`. Distinct from `RoleGrantOfferNotFoundError`
|
|
71
71
|
* (the IDOR mask): once an offer has been resolved to the recipient account,
|
|
72
72
|
* a wrong-actor accept on a same-account actor is a contract violation, not
|
|
73
73
|
* a privacy boundary — surface a specific error so the client UI can
|
|
74
74
|
* distinguish "this offer isn't for you" from "no such offer".
|
|
75
75
|
*/
|
|
76
|
-
export class
|
|
76
|
+
export class RoleGrantOfferActorMismatchError extends Error {
|
|
77
77
|
constructor(offer_id) {
|
|
78
78
|
super(`Offer ${offer_id} is targeted to a different actor on this account`);
|
|
79
|
-
this.name = '
|
|
79
|
+
this.name = 'RoleGrantOfferActorMismatchError';
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
/**
|
|
83
|
-
* Error thrown when `
|
|
83
|
+
* Error thrown when `query_role_grant_offer_create` is called with a
|
|
84
84
|
* `to_actor_id` that does not exist or does not belong to `to_account_id`.
|
|
85
85
|
* Surfaces the actor↔account binding mismatch at the boundary instead of
|
|
86
86
|
* letting the FK silently disagree with the recipient field.
|
|
87
87
|
*/
|
|
88
|
-
export class
|
|
88
|
+
export class RoleGrantOfferActorAccountMismatchError extends Error {
|
|
89
89
|
constructor() {
|
|
90
90
|
super('to_actor_id does not belong to to_account_id');
|
|
91
|
-
this.name = '
|
|
91
|
+
this.name = 'RoleGrantOfferActorAccountMismatchError';
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
/**
|
|
95
|
-
* Create a new
|
|
95
|
+
* Create a new role_grant offer, or refresh an existing pending offer for the
|
|
96
96
|
* same `(to_account_id, role, scope_id, from_actor_id)` tuple.
|
|
97
97
|
*
|
|
98
98
|
* Re-offer semantics: a second call by the same grantor with the same
|
|
@@ -104,7 +104,7 @@ export class PermitOfferActorAccountMismatchError extends Error {
|
|
|
104
104
|
* a distinct row — multiple pending grantors coexist. After a terminal
|
|
105
105
|
* state, a re-offer is a fresh INSERT.
|
|
106
106
|
*
|
|
107
|
-
* Self-offer rejection: throws `
|
|
107
|
+
* Self-offer rejection: throws `RoleGrantOfferSelfTargetError` if the offering
|
|
108
108
|
* actor belongs to the recipient account.
|
|
109
109
|
*
|
|
110
110
|
* Actor-targeted offers: when `to_actor_id` is supplied,
|
|
@@ -113,11 +113,11 @@ export class PermitOfferActorAccountMismatchError extends Error {
|
|
|
113
113
|
* `target_actor_id` null even when the recipient binding is known at
|
|
114
114
|
* offer time. The actor↔account binding is verified here in one SELECT.
|
|
115
115
|
*
|
|
116
|
-
* @mutates `
|
|
117
|
-
* @throws
|
|
118
|
-
* @throws
|
|
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
119
|
*/
|
|
120
|
-
export const
|
|
120
|
+
export const query_role_grant_offer_create = async (deps, input) => {
|
|
121
121
|
// Self-target check resolves the **grantor** actor's account and
|
|
122
122
|
// compares against to_account_id. This is multi-actor-correct:
|
|
123
123
|
// a single account may host many actors, and self-target means
|
|
@@ -129,18 +129,24 @@ export const query_permit_offer_create = async (deps, input) => {
|
|
|
129
129
|
// actor wasn't the offering one.)
|
|
130
130
|
const grantor = await deps.db.query_one(`SELECT account_id FROM actor WHERE id = $1`, [input.from_actor_id]);
|
|
131
131
|
if (grantor && grantor.account_id === input.to_account_id) {
|
|
132
|
-
throw new
|
|
132
|
+
throw new RoleGrantOfferSelfTargetError();
|
|
133
133
|
}
|
|
134
134
|
if (input.to_actor_id != null) {
|
|
135
135
|
const target = await deps.db.query_one(`SELECT account_id FROM actor WHERE id = $1`, [input.to_actor_id]);
|
|
136
136
|
if (!target || target.account_id !== input.to_account_id) {
|
|
137
|
-
throw new
|
|
137
|
+
throw new RoleGrantOfferActorAccountMismatchError();
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
|
-
const row = await deps.db.query_one(`INSERT INTO
|
|
141
|
-
(from_actor_id, to_account_id, to_actor_id, role, scope_id, message, expires_at)
|
|
142
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
143
|
-
ON CONFLICT (
|
|
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
|
+
)
|
|
144
150
|
WHERE accepted_at IS NULL AND declined_at IS NULL AND retracted_at IS NULL AND superseded_at IS NULL
|
|
145
151
|
DO UPDATE SET
|
|
146
152
|
to_actor_id = EXCLUDED.to_actor_id,
|
|
@@ -151,18 +157,19 @@ export const query_permit_offer_create = async (deps, input) => {
|
|
|
151
157
|
input.to_account_id,
|
|
152
158
|
input.to_actor_id ?? null,
|
|
153
159
|
input.role,
|
|
160
|
+
input.scope_kind ?? null,
|
|
154
161
|
input.scope_id ?? null,
|
|
155
162
|
input.message ?? null,
|
|
156
163
|
input.expires_at.toISOString(),
|
|
157
164
|
]);
|
|
158
|
-
return assert_row(row, 'INSERT INTO
|
|
165
|
+
return assert_row(row, 'INSERT INTO role_grant_offer');
|
|
159
166
|
};
|
|
160
167
|
/**
|
|
161
168
|
* Mark an offer declined.
|
|
162
169
|
*
|
|
163
170
|
* Guarded by `to_account_id` (IDOR). Returns `null` if the offer does not
|
|
164
171
|
* exist or belongs to a different account. Throws
|
|
165
|
-
* `
|
|
172
|
+
* `RoleGrantOfferAlreadyTerminalError` if the offer exists for the caller but
|
|
166
173
|
* is already in a terminal state.
|
|
167
174
|
*
|
|
168
175
|
* Returns the declined offer with the grantor's `from_account_id` joined
|
|
@@ -171,12 +178,12 @@ export const query_permit_offer_create = async (deps, input) => {
|
|
|
171
178
|
* grantor account), satisfying the "both populated → same account"
|
|
172
179
|
* invariant the audit-log column comments describe.
|
|
173
180
|
*
|
|
174
|
-
* @mutates `
|
|
175
|
-
* @throws
|
|
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
|
|
176
183
|
*/
|
|
177
|
-
export const
|
|
184
|
+
export const query_role_grant_offer_decline = async (deps, offer_id, to_account_id, reason) => {
|
|
178
185
|
const updated = await deps.db.query_one(`WITH updated AS (
|
|
179
|
-
UPDATE
|
|
186
|
+
UPDATE role_grant_offer
|
|
180
187
|
SET declined_at = NOW(), decline_reason = $3
|
|
181
188
|
WHERE id = $1
|
|
182
189
|
AND to_account_id = $2
|
|
@@ -198,14 +205,14 @@ export const query_permit_offer_decline = async (deps, offer_id, to_account_id,
|
|
|
198
205
|
*
|
|
199
206
|
* Guarded by `from_actor_id` (IDOR). Returns `null` if the offer does not
|
|
200
207
|
* exist or was issued by a different actor. Throws
|
|
201
|
-
* `
|
|
208
|
+
* `RoleGrantOfferAlreadyTerminalError` if the offer exists for this grantor
|
|
202
209
|
* but is already in a terminal state.
|
|
203
210
|
*
|
|
204
|
-
* @mutates `
|
|
205
|
-
* @throws
|
|
211
|
+
* @mutates `role_grant_offer` row - sets `retracted_at`
|
|
212
|
+
* @throws RoleGrantOfferAlreadyTerminalError if the offer is already accepted, declined, retracted, or superseded
|
|
206
213
|
*/
|
|
207
|
-
export const
|
|
208
|
-
const updated = await deps.db.query_one(`UPDATE
|
|
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
|
|
209
216
|
SET retracted_at = NOW()
|
|
210
217
|
WHERE id = $1
|
|
211
218
|
AND from_actor_id = $2
|
|
@@ -231,11 +238,11 @@ const resolve_terminal_or_missing = async (deps, offer_id, scope) => {
|
|
|
231
238
|
conditions.push(`from_actor_id = $${idx++}`);
|
|
232
239
|
params.push(scope.from_actor_id);
|
|
233
240
|
}
|
|
234
|
-
const row = await deps.db.query_one(`SELECT * FROM
|
|
241
|
+
const row = await deps.db.query_one(`SELECT * FROM role_grant_offer WHERE ${conditions.join(' AND ')}`, params);
|
|
235
242
|
if (!row)
|
|
236
243
|
return null;
|
|
237
244
|
if (row.accepted_at || row.declined_at || row.retracted_at || row.superseded_at) {
|
|
238
|
-
throw new
|
|
245
|
+
throw new RoleGrantOfferAlreadyTerminalError(offer_id);
|
|
239
246
|
}
|
|
240
247
|
return null;
|
|
241
248
|
};
|
|
@@ -244,10 +251,10 @@ const resolve_terminal_or_missing = async (deps, offer_id, scope) => {
|
|
|
244
251
|
*
|
|
245
252
|
* Expired offers are filtered server-side (`expires_at > NOW()`) so the
|
|
246
253
|
* inbox never surfaces a row that can no longer be accepted. The periodic
|
|
247
|
-
* sweep (`
|
|
254
|
+
* sweep (`query_role_grant_offer_sweep_expired`) handles audit tombstoning.
|
|
248
255
|
*/
|
|
249
|
-
export const
|
|
250
|
-
return deps.db.query(`SELECT * FROM
|
|
256
|
+
export const query_role_grant_offer_list = async (deps, to_account_id) => {
|
|
257
|
+
return deps.db.query(`SELECT * FROM role_grant_offer
|
|
251
258
|
WHERE to_account_id = $1
|
|
252
259
|
AND accepted_at IS NULL
|
|
253
260
|
AND declined_at IS NULL
|
|
@@ -261,8 +268,8 @@ export const query_permit_offer_list = async (deps, to_account_id) => {
|
|
|
261
268
|
*
|
|
262
269
|
* Includes terminal offers — used by the grantor-side admin / history view.
|
|
263
270
|
*/
|
|
264
|
-
export const
|
|
265
|
-
return deps.db.query(`SELECT o.* FROM
|
|
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
|
|
266
273
|
LEFT JOIN actor a ON a.id = o.from_actor_id
|
|
267
274
|
WHERE o.to_account_id = $1 OR a.account_id = $1
|
|
268
275
|
ORDER BY o.created_at DESC
|
|
@@ -272,8 +279,8 @@ export const query_permit_offer_history_for_account = async (deps, account_id, l
|
|
|
272
279
|
* Look up a pending offer by id. Returns `null` if the offer is terminal,
|
|
273
280
|
* expired (server-side filter), or missing.
|
|
274
281
|
*/
|
|
275
|
-
export const
|
|
276
|
-
const row = await deps.db.query_one(`SELECT * FROM
|
|
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
|
|
277
284
|
WHERE id = $1
|
|
278
285
|
AND accepted_at IS NULL
|
|
279
286
|
AND declined_at IS NULL
|
|
@@ -285,13 +292,13 @@ export const query_permit_offer_find_pending = async (deps, offer_id) => {
|
|
|
285
292
|
/**
|
|
286
293
|
* Return pending offers whose `expires_at` has passed.
|
|
287
294
|
*
|
|
288
|
-
* Callers fire `
|
|
295
|
+
* Callers fire `role_grant_offer_expire` audit events for each row. The schema
|
|
289
296
|
* does not tombstone the row, so callers are responsible for their own
|
|
290
|
-
* idempotency (e.g. check whether a `
|
|
297
|
+
* idempotency (e.g. check whether a `role_grant_offer_expire` audit event
|
|
291
298
|
* already exists for the offer id).
|
|
292
299
|
*/
|
|
293
|
-
export const
|
|
294
|
-
return deps.db.query(`SELECT * FROM
|
|
300
|
+
export const query_role_grant_offer_sweep_expired = async (deps) => {
|
|
301
|
+
return deps.db.query(`SELECT * FROM role_grant_offer
|
|
295
302
|
WHERE accepted_at IS NULL
|
|
296
303
|
AND declined_at IS NULL
|
|
297
304
|
AND retracted_at IS NULL
|
|
@@ -300,35 +307,35 @@ export const query_permit_offer_sweep_expired = async (deps) => {
|
|
|
300
307
|
ORDER BY expires_at ASC`);
|
|
301
308
|
};
|
|
302
309
|
/**
|
|
303
|
-
* Accept an offer atomically: mark accepted, insert the
|
|
304
|
-
* `
|
|
305
|
-
* `(to_account, role, scope)`, and emit `
|
|
306
|
-
* `
|
|
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
|
|
307
314
|
* inside a transaction — the caller's route spec should declare
|
|
308
315
|
* `transaction: true` (or wrap explicitly).
|
|
309
316
|
*
|
|
310
317
|
* Idempotent on race: if a second concurrent call observes the offer
|
|
311
|
-
* already accepted, returns the existing
|
|
318
|
+
* already accepted, returns the existing role_grant rather than creating a
|
|
312
319
|
* duplicate or throwing.
|
|
313
320
|
*
|
|
314
321
|
* Error map:
|
|
315
|
-
* - `
|
|
322
|
+
* - `RoleGrantOfferNotFoundError` — offer does not exist, or belongs to a
|
|
316
323
|
* different recipient (IDOR guard). The offer row is untouched.
|
|
317
|
-
* - `
|
|
324
|
+
* - `RoleGrantOfferAlreadyTerminalError` — offer is declined, retracted, or
|
|
318
325
|
* superseded.
|
|
319
|
-
* - `
|
|
326
|
+
* - `RoleGrantOfferExpiredError` — offer is pending but past `expires_at`.
|
|
320
327
|
*
|
|
321
328
|
* Sibling supersede is what closes the "accept a pre-revoke sibling offer
|
|
322
329
|
* to bypass a revoke" path: once A is accepted, B/C/... can no longer be
|
|
323
|
-
* accepted even if the resulting
|
|
330
|
+
* accepted even if the resulting role_grant is later revoked.
|
|
324
331
|
*
|
|
325
|
-
* @mutates `
|
|
326
|
-
* @mutates `
|
|
327
|
-
* @mutates `
|
|
328
|
-
* @mutates `audit_log` table - emits `
|
|
329
|
-
* @throws
|
|
330
|
-
* @throws
|
|
331
|
-
* @throws
|
|
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`
|
|
332
339
|
* @throws Error if the accepting `actor_id` does not belong to `to_account_id`, or invariant assertions fail
|
|
333
340
|
*/
|
|
334
341
|
export const query_accept_offer = async (deps, input) => {
|
|
@@ -336,31 +343,31 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
336
343
|
// Claim the offer with a row-level lock. Subsequent concurrent callers
|
|
337
344
|
// block on the lock until this transaction commits/rolls back; after commit
|
|
338
345
|
// they see the new state (accepted or terminal) and branch idempotently.
|
|
339
|
-
// We defer writing `accepted_at` until the
|
|
340
|
-
// `
|
|
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
|
|
341
348
|
// (or neither) at row-visibility time.
|
|
342
|
-
const locked = await deps.db.query_one(`SELECT * FROM
|
|
349
|
+
const locked = await deps.db.query_one(`SELECT * FROM role_grant_offer
|
|
343
350
|
WHERE id = $1 AND to_account_id = $2
|
|
344
351
|
FOR UPDATE`, [offer_id, to_account_id]);
|
|
345
352
|
if (!locked) {
|
|
346
|
-
throw new
|
|
353
|
+
throw new RoleGrantOfferNotFoundError(offer_id);
|
|
347
354
|
}
|
|
348
355
|
if (locked.accepted_at) {
|
|
349
|
-
// Race winner already committed; return the pre-existing
|
|
350
|
-
// `
|
|
351
|
-
const
|
|
352
|
-
locked.
|
|
353
|
-
]), '
|
|
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');
|
|
354
361
|
// Multi-actor guard: two actors on the same recipient account may
|
|
355
362
|
// both race an account-grain offer — the loser must not silently
|
|
356
|
-
// receive the winner's
|
|
357
|
-
// while the actor on the
|
|
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
|
|
358
365
|
// as terminal for the loser.
|
|
359
|
-
if (
|
|
360
|
-
throw new
|
|
366
|
+
if (role_grant.actor_id !== actor_id) {
|
|
367
|
+
throw new RoleGrantOfferAlreadyTerminalError(offer_id);
|
|
361
368
|
}
|
|
362
369
|
return {
|
|
363
|
-
|
|
370
|
+
role_grant,
|
|
364
371
|
offer: locked,
|
|
365
372
|
created: false,
|
|
366
373
|
superseded_offers: [],
|
|
@@ -368,13 +375,13 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
368
375
|
};
|
|
369
376
|
}
|
|
370
377
|
if (locked.declined_at || locked.retracted_at || locked.superseded_at) {
|
|
371
|
-
throw new
|
|
378
|
+
throw new RoleGrantOfferAlreadyTerminalError(offer_id);
|
|
372
379
|
}
|
|
373
380
|
// Expiry check AFTER the accepted-path: a validly-accepted offer past its
|
|
374
|
-
// expires_at still returns the
|
|
381
|
+
// expires_at still returns the role_grant idempotently. Only pending offers
|
|
375
382
|
// past expiry reach this branch.
|
|
376
383
|
if (new Date(locked.expires_at) <= new Date()) {
|
|
377
|
-
throw new
|
|
384
|
+
throw new RoleGrantOfferExpiredError(offer_id);
|
|
378
385
|
}
|
|
379
386
|
// Actor-targeted offer gate. When the offer is account-grain
|
|
380
387
|
// (`to_actor_id IS NULL`) any actor on `to_account_id` may accept and
|
|
@@ -385,7 +392,7 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
385
392
|
//
|
|
386
393
|
// Ordering contract: this check fires *before* the cross-account
|
|
387
394
|
// `actor_check` SELECT below. A wrong-actor accept on an actor-grain
|
|
388
|
-
// offer surfaces as `
|
|
395
|
+
// offer surfaces as `RoleGrantOfferActorMismatchError` regardless of
|
|
389
396
|
// whether the supplied `actor_id` belongs to `to_account_id` — the
|
|
390
397
|
// actor-grain binding is the tighter constraint and dominates. The
|
|
391
398
|
// cross-account `Error` only fires for account-grain offers (or
|
|
@@ -393,42 +400,48 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
393
400
|
// the actor turns out not to be on the account, which is unreachable
|
|
394
401
|
// under the FK invariant but stays as defense-in-depth).
|
|
395
402
|
if (locked.to_actor_id != null && locked.to_actor_id !== actor_id) {
|
|
396
|
-
throw new
|
|
403
|
+
throw new RoleGrantOfferActorMismatchError(offer_id);
|
|
397
404
|
}
|
|
398
405
|
// Verify the accepting actor belongs to the recipient account.
|
|
399
406
|
// Defense-in-depth: the action handler passes `auth.actor.id` which is
|
|
400
407
|
// already session-bound, but enforcing the invariant here protects
|
|
401
408
|
// direct callers (tests, future consumers) from cross-account binding
|
|
402
|
-
// bugs that would silently grant a
|
|
409
|
+
// bugs that would silently grant a role_grant to the wrong actor.
|
|
403
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]);
|
|
404
411
|
if (!actor_check) {
|
|
405
412
|
throw new Error(`Accepting actor ${actor_id} does not belong to account ${to_account_id} (offer ${offer_id})`);
|
|
406
413
|
}
|
|
407
|
-
// Insert the
|
|
408
|
-
// code path already granted the same (actor, role, scope), reuse it.
|
|
409
|
-
const
|
|
410
|
-
VALUES ($1, $2, $3, $4, $5)
|
|
411
|
-
ON CONFLICT (
|
|
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
|
+
)
|
|
412
424
|
WHERE revoked_at IS NULL
|
|
413
425
|
DO NOTHING
|
|
414
|
-
RETURNING *`, [actor_id, locked.role, locked.scope_id, locked.from_actor_id, locked.id]);
|
|
415
|
-
let
|
|
416
|
-
if (
|
|
417
|
-
|
|
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;
|
|
418
430
|
}
|
|
419
431
|
else {
|
|
420
|
-
const existing = await deps.db.query_one(`SELECT * FROM
|
|
432
|
+
const existing = await deps.db.query_one(`SELECT * FROM role_grant
|
|
421
433
|
WHERE actor_id = $1
|
|
422
434
|
AND role = $2
|
|
423
|
-
AND
|
|
424
|
-
AND
|
|
425
|
-
|
|
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');
|
|
426
439
|
}
|
|
427
440
|
// Single UPDATE sets both sides of the CHECK constraint at once.
|
|
428
|
-
const offer_accepted = await deps.db.query_one(`UPDATE
|
|
429
|
-
SET accepted_at = NOW(),
|
|
441
|
+
const offer_accepted = await deps.db.query_one(`UPDATE role_grant_offer
|
|
442
|
+
SET accepted_at = NOW(), resulting_role_grant_id = $2
|
|
430
443
|
WHERE id = $1
|
|
431
|
-
RETURNING *`, [locked.id,
|
|
444
|
+
RETURNING *`, [locked.id, role_grant.id]);
|
|
432
445
|
const offer = assert_row(offer_accepted, 'mark offer accepted');
|
|
433
446
|
// Supersede sibling pending offers for the same (to_account, role, scope).
|
|
434
447
|
// Forecloses the "accept this other sibling later to get the role back
|
|
@@ -436,7 +449,7 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
436
449
|
// is obsoleted by the accept. CTE joins `actor` to surface each sibling's
|
|
437
450
|
// grantor `account_id` for the caller's notification fan-out.
|
|
438
451
|
const superseded = await deps.db.query(`WITH updated AS (
|
|
439
|
-
UPDATE
|
|
452
|
+
UPDATE role_grant_offer
|
|
440
453
|
SET superseded_at = NOW()
|
|
441
454
|
WHERE to_account_id = $1
|
|
442
455
|
AND role = $2
|
|
@@ -451,13 +464,13 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
451
464
|
SELECT u.*, grantor.account_id AS from_account_id
|
|
452
465
|
FROM updated u
|
|
453
466
|
JOIN actor grantor ON grantor.id = u.from_actor_id`, [to_account_id, offer.role, offer.scope_id, offer.id]);
|
|
454
|
-
// Emit audit events in-transaction (atomic with the
|
|
455
|
-
// `RETURNING *` after the SET guarantees `offer.
|
|
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`.
|
|
456
469
|
// Accept binds the actor deterministically — populate both target
|
|
457
|
-
// columns to mirror `
|
|
470
|
+
// columns to mirror `role_grant_create` (the in-tx pair) so forensic
|
|
458
471
|
// queries don't have to split between the two events.
|
|
459
472
|
const offer_accept_event = await query_audit_log(deps, {
|
|
460
|
-
event_type: '
|
|
473
|
+
event_type: 'role_grant_offer_accept',
|
|
461
474
|
actor_id,
|
|
462
475
|
account_id: to_account_id,
|
|
463
476
|
target_account_id: to_account_id,
|
|
@@ -465,18 +478,18 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
465
478
|
ip: ip ?? null,
|
|
466
479
|
metadata: {
|
|
467
480
|
offer_id: offer.id,
|
|
468
|
-
|
|
481
|
+
role_grant_id: role_grant.id,
|
|
469
482
|
role: offer.role,
|
|
470
483
|
scope_id: offer.scope_id,
|
|
471
484
|
},
|
|
472
485
|
});
|
|
473
|
-
// `
|
|
474
|
-
//
|
|
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
|
|
475
488
|
// target are the same identity; on admin direct-grant (separate code
|
|
476
489
|
// path) they differ. Either way `target_actor_id` carries the
|
|
477
490
|
// grantee for actor-grain forensics.
|
|
478
|
-
const
|
|
479
|
-
event_type: '
|
|
491
|
+
const role_grant_create_event = await query_audit_log(deps, {
|
|
492
|
+
event_type: 'role_grant_create',
|
|
480
493
|
actor_id,
|
|
481
494
|
account_id: to_account_id,
|
|
482
495
|
target_account_id: to_account_id,
|
|
@@ -484,7 +497,7 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
484
497
|
ip: ip ?? null,
|
|
485
498
|
metadata: {
|
|
486
499
|
role: offer.role,
|
|
487
|
-
|
|
500
|
+
role_grant_id: role_grant.id,
|
|
488
501
|
scope_id: offer.scope_id,
|
|
489
502
|
source_offer_id: offer.id,
|
|
490
503
|
},
|
|
@@ -495,7 +508,7 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
495
508
|
// when the sibling was actor-targeted, account-grain (null) when it
|
|
496
509
|
// was account-level.
|
|
497
510
|
supersede_events.push(await query_audit_log(deps, {
|
|
498
|
-
event_type: '
|
|
511
|
+
event_type: 'role_grant_offer_supersede',
|
|
499
512
|
actor_id,
|
|
500
513
|
account_id: to_account_id,
|
|
501
514
|
target_account_id: to_account_id,
|
|
@@ -511,10 +524,10 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
511
524
|
}));
|
|
512
525
|
}
|
|
513
526
|
return {
|
|
514
|
-
|
|
527
|
+
role_grant,
|
|
515
528
|
offer,
|
|
516
529
|
created: true,
|
|
517
530
|
superseded_offers: superseded,
|
|
518
|
-
audit_events: [offer_accept_event,
|
|
531
|
+
audit_events: [offer_accept_event, role_grant_create_event, ...supersede_events],
|
|
519
532
|
};
|
|
520
533
|
};
|