@fuzdev/fuz_app 0.53.0 → 0.55.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 +68 -13
- package/dist/actions/action_codegen.d.ts +13 -0
- package/dist/actions/action_codegen.d.ts.map +1 -1
- package/dist/actions/action_codegen.js +15 -1
- package/dist/actions/action_rpc.d.ts +60 -7
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +158 -44
- package/dist/actions/register_action_ws.d.ts +4 -4
- package/dist/actions/register_action_ws.js +6 -6
- package/dist/actions/register_ws_endpoint.d.ts +20 -7
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +30 -5
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +0 -4
- package/dist/auth/CLAUDE.md +230 -63
- package/dist/auth/account_actions.d.ts +6 -6
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +8 -11
- package/dist/auth/account_queries.d.ts +6 -3
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +14 -5
- package/dist/auth/account_routes.d.ts +7 -10
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +70 -23
- package/dist/auth/account_schema.d.ts +19 -0
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +20 -0
- package/dist/auth/admin_action_specs.d.ts +45 -11
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +23 -8
- package/dist/auth/admin_actions.d.ts +8 -7
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +11 -18
- package/dist/auth/audit_log_queries.d.ts +53 -14
- package/dist/auth/audit_log_queries.d.ts.map +1 -1
- package/dist/auth/audit_log_queries.js +45 -2
- package/dist/auth/audit_log_schema.d.ts +55 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +19 -3
- 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/cleanup.d.ts.map +1 -1
- package/dist/auth/cleanup.js +5 -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 +26 -20
- package/dist/auth/deps.d.ts +14 -0
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +4 -2
- package/dist/auth/migrations.d.ts +15 -7
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +15 -7
- package/dist/auth/permit_offer_action_specs.d.ts +45 -6
- package/dist/auth/permit_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/permit_offer_action_specs.js +38 -7
- package/dist/auth/permit_offer_actions.d.ts +2 -2
- package/dist/auth/permit_offer_actions.d.ts.map +1 -1
- package/dist/auth/permit_offer_actions.js +106 -95
- package/dist/auth/permit_offer_notifications.d.ts +10 -0
- package/dist/auth/permit_offer_notifications.d.ts.map +1 -1
- package/dist/auth/permit_offer_queries.d.ts +68 -9
- package/dist/auth/permit_offer_queries.d.ts.map +1 -1
- package/dist/auth/permit_offer_queries.js +147 -35
- package/dist/auth/permit_offer_schema.d.ts +23 -1
- package/dist/auth/permit_offer_schema.d.ts.map +1 -1
- package/dist/auth/permit_offer_schema.js +5 -0
- package/dist/auth/permit_queries.d.ts +17 -5
- package/dist/auth/permit_queries.d.ts.map +1 -1
- package/dist/auth/permit_queries.js +19 -8
- package/dist/auth/request_context.d.ts +360 -32
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +442 -60
- package/dist/auth/route_guards.d.ts +10 -4
- package/dist/auth/route_guards.d.ts.map +1 -1
- package/dist/auth/route_guards.js +14 -8
- package/dist/auth/self_service_role_action_specs.d.ts +2 -0
- package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
- package/dist/auth/self_service_role_action_specs.js +2 -0
- package/dist/auth/self_service_role_actions.d.ts +6 -5
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.js +32 -19
- package/dist/db/migrate.d.ts +11 -7
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +9 -6
- package/dist/dev/setup.d.ts.map +1 -1
- package/dist/dev/setup.js +5 -3
- package/dist/hono_context.d.ts +77 -0
- package/dist/hono_context.d.ts.map +1 -1
- package/dist/hono_context.js +50 -0
- package/dist/http/CLAUDE.md +80 -17
- package/dist/http/error_schemas.d.ts +92 -1
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +73 -16
- 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/route_spec.d.ts +62 -4
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +117 -21
- package/dist/http/schema_helpers.d.ts +13 -1
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +21 -2
- package/dist/http/surface.d.ts +10 -1
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +2 -2
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +11 -1
- package/dist/testing/CLAUDE.md +23 -17
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +15 -13
- package/dist/testing/adversarial_headers.js +1 -1
- package/dist/testing/app_server.js +2 -2
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +21 -7
- package/dist/testing/auth_apps.d.ts.map +1 -1
- package/dist/testing/auth_apps.js +6 -3
- package/dist/testing/entities.d.ts +2 -1
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +1 -0
- package/dist/testing/integration_helpers.d.ts +4 -2
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +9 -5
- package/dist/testing/middleware.d.ts +12 -8
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +67 -25
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.js +3 -1
- package/dist/testing/schema_generators.d.ts.map +1 -1
- package/dist/testing/schema_generators.js +12 -0
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +5 -1
- package/dist/ui/CLAUDE.md +16 -10
- package/dist/ui/PermitOfferForm.svelte +14 -0
- package/dist/ui/PermitOfferForm.svelte.d.ts +6 -0
- package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.d.ts +8 -1
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +14 -3
- package/dist/ui/permit_offers_state.svelte.d.ts +9 -1
- package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -1
- package/dist/ui/permit_offers_state.svelte.js +7 -1
- package/package.json +1 -1
|
@@ -46,31 +46,72 @@ export declare class PermitOfferNotFoundError extends Error {
|
|
|
46
46
|
/**
|
|
47
47
|
* Error thrown when a grantor attempts to offer a permit to their own account.
|
|
48
48
|
*
|
|
49
|
-
* Enforced
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* Enforced via a single SELECT on the grantor's `actor.account_id` (rather
|
|
50
|
+
* than via a CHECK constraint or a denormalized column). Resolving from the
|
|
51
|
+
* grantor side keeps the check multi-actor-correct: under multi-actor the
|
|
52
|
+
* recipient account may host many actors, but the grantor → account binding
|
|
53
|
+
* remains 1:1 by definition of `actor`.
|
|
52
54
|
*/
|
|
53
55
|
export declare class PermitOfferSelfTargetError extends Error {
|
|
54
56
|
constructor();
|
|
55
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Error thrown when an actor-targeted offer is being accepted by an actor
|
|
60
|
+
* other than `offer.to_actor_id`. Distinct from `PermitOfferNotFoundError`
|
|
61
|
+
* (the IDOR mask): once an offer has been resolved to the recipient account,
|
|
62
|
+
* a wrong-actor accept on a same-account actor is a contract violation, not
|
|
63
|
+
* a privacy boundary — surface a specific error so the client UI can
|
|
64
|
+
* distinguish "this offer isn't for you" from "no such offer".
|
|
65
|
+
*/
|
|
66
|
+
export declare class PermitOfferActorMismatchError extends Error {
|
|
67
|
+
constructor(offer_id: string);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Error thrown when `query_permit_offer_create` is called with a
|
|
71
|
+
* `to_actor_id` that does not exist or does not belong to `to_account_id`.
|
|
72
|
+
* Surfaces the actor↔account binding mismatch at the boundary instead of
|
|
73
|
+
* letting the FK silently disagree with the recipient field.
|
|
74
|
+
*/
|
|
75
|
+
export declare class PermitOfferActorAccountMismatchError extends Error {
|
|
76
|
+
constructor();
|
|
77
|
+
}
|
|
56
78
|
/**
|
|
57
79
|
* Create a new permit offer, or refresh an existing pending offer for the
|
|
58
80
|
* same `(to_account_id, role, scope_id, from_actor_id)` tuple.
|
|
59
81
|
*
|
|
60
82
|
* Re-offer semantics: a second call by the same grantor with the same
|
|
61
83
|
* `(to_account, role, scope)` while pending upserts the existing row,
|
|
62
|
-
* refreshing `message` and `expires_at
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
84
|
+
* refreshing `message` and `expires_at` (and `to_actor_id` — supplying
|
|
85
|
+
* a different `to_actor_id` on re-offer narrows the existing row to the
|
|
86
|
+
* named actor; supplying null widens it back to account-grain). A
|
|
87
|
+
* different grantor offering the same `(to_account, role, scope)` creates
|
|
88
|
+
* a distinct row — multiple pending grantors coexist. After a terminal
|
|
89
|
+
* state, a re-offer is a fresh INSERT.
|
|
66
90
|
*
|
|
67
91
|
* Self-offer rejection: throws `PermitOfferSelfTargetError` if the offering
|
|
68
92
|
* actor belongs to the recipient account.
|
|
69
93
|
*
|
|
94
|
+
* Actor-targeted offers: when `to_actor_id` is supplied,
|
|
95
|
+
* `query_accept_offer` rejects any actor other than the named one. Closes
|
|
96
|
+
* the audit hole where offer-shape events would otherwise leave
|
|
97
|
+
* `target_actor_id` null even when the recipient binding is known at
|
|
98
|
+
* offer time. The actor↔account binding is verified here in one SELECT.
|
|
99
|
+
*
|
|
70
100
|
* @mutates `permit_offer` table - inserts a new offer or upserts the matching pending row
|
|
71
101
|
* @throws PermitOfferSelfTargetError if the offering actor belongs to `to_account_id`
|
|
102
|
+
* @throws PermitOfferActorAccountMismatchError if `to_actor_id` is set but does not belong to `to_account_id`
|
|
72
103
|
*/
|
|
73
104
|
export declare const query_permit_offer_create: (deps: QueryDeps, input: CreatePermitOfferInput) => Promise<PermitOffer>;
|
|
105
|
+
/** Result of `query_permit_offer_decline` — the declined offer plus the grantor's `account_id`. */
|
|
106
|
+
export interface DeclinedOffer extends PermitOffer {
|
|
107
|
+
/**
|
|
108
|
+
* Grantor's `account_id`, resolved via a join on `actor` so the audit
|
|
109
|
+
* envelope's `target_account_id` (decline is *to* the grantor) and the
|
|
110
|
+
* post-commit notification target are both addressable without a
|
|
111
|
+
* second round-trip.
|
|
112
|
+
*/
|
|
113
|
+
from_account_id: Uuid;
|
|
114
|
+
}
|
|
74
115
|
/**
|
|
75
116
|
* Mark an offer declined.
|
|
76
117
|
*
|
|
@@ -79,10 +120,16 @@ export declare const query_permit_offer_create: (deps: QueryDeps, input: CreateP
|
|
|
79
120
|
* `PermitOfferAlreadyTerminalError` if the offer exists for the caller but
|
|
80
121
|
* is already in a terminal state.
|
|
81
122
|
*
|
|
123
|
+
* Returns the declined offer with the grantor's `from_account_id` joined
|
|
124
|
+
* in via CTE — the decline audit envelope populates **both**
|
|
125
|
+
* `target_actor_id` (the grantor actor) and `target_account_id` (the
|
|
126
|
+
* grantor account), satisfying the "both populated → same account"
|
|
127
|
+
* invariant the audit-log column comments describe.
|
|
128
|
+
*
|
|
82
129
|
* @mutates `permit_offer` row - sets `declined_at` and `decline_reason`
|
|
83
130
|
* @throws PermitOfferAlreadyTerminalError if the offer is already accepted, declined, retracted, or superseded
|
|
84
131
|
*/
|
|
85
|
-
export declare const query_permit_offer_decline: (deps: QueryDeps, offer_id: string, to_account_id: string, reason: string | null) => Promise<
|
|
132
|
+
export declare const query_permit_offer_decline: (deps: QueryDeps, offer_id: string, to_account_id: string, reason: string | null) => Promise<DeclinedOffer | null>;
|
|
86
133
|
/**
|
|
87
134
|
* Mark an offer retracted by the grantor.
|
|
88
135
|
*
|
|
@@ -128,6 +175,18 @@ export interface AcceptOfferInput {
|
|
|
128
175
|
offer_id: Uuid;
|
|
129
176
|
/** Account of the accepting recipient — IDOR guard against another account accepting the offer. */
|
|
130
177
|
to_account_id: Uuid;
|
|
178
|
+
/**
|
|
179
|
+
* Accepting actor — the actor that will hold the resulting permit.
|
|
180
|
+
* Must belong to `to_account_id`; the query verifies and throws if not
|
|
181
|
+
* (defense-in-depth — the action handler passes `auth.actor.id` which
|
|
182
|
+
* is session-bound, but the query enforces the invariant for all
|
|
183
|
+
* callers including tests and future direct consumers).
|
|
184
|
+
*
|
|
185
|
+
* Required because under multi-actor an account may host many actors;
|
|
186
|
+
* the resulting permit must bind to the actor that actually accepted,
|
|
187
|
+
* not "an" actor on the account picked by query order.
|
|
188
|
+
*/
|
|
189
|
+
actor_id: Uuid;
|
|
131
190
|
/** Optional IP to stamp on the audit events. */
|
|
132
191
|
ip?: string | null;
|
|
133
192
|
}
|
|
@@ -177,7 +236,7 @@ export interface AcceptOfferResult {
|
|
|
177
236
|
* @throws PermitOfferNotFoundError if the offer is missing or belongs to another recipient
|
|
178
237
|
* @throws PermitOfferAlreadyTerminalError if the offer is declined, retracted, or superseded
|
|
179
238
|
* @throws PermitOfferExpiredError if the offer is pending but past `expires_at`
|
|
180
|
-
* @throws Error if the accepting
|
|
239
|
+
* @throws Error if the accepting `actor_id` does not belong to `to_account_id`, or invariant assertions fail
|
|
181
240
|
*/
|
|
182
241
|
export declare const query_accept_offer: (deps: QueryDeps, input: AcceptOfferInput) => Promise<AcceptOfferResult>;
|
|
183
242
|
//# sourceMappingURL=permit_offer_queries.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permit_offer_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_offer_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAEjD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAEnD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"permit_offer_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_offer_queries.ts"],"names":[],"mappings":"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"}
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
* @module
|
|
13
13
|
*/
|
|
14
14
|
import { assert_row } from '../db/assert_row.js';
|
|
15
|
-
import { query_actor_by_account } from './account_queries.js';
|
|
16
15
|
import { PERMIT_OFFER_SCOPE_SENTINEL_UUID, } from './permit_offer_schema.js';
|
|
17
16
|
import { query_audit_log } from './audit_log_queries.js';
|
|
18
17
|
/**
|
|
@@ -54,9 +53,11 @@ export class PermitOfferNotFoundError extends Error {
|
|
|
54
53
|
/**
|
|
55
54
|
* Error thrown when a grantor attempts to offer a permit to their own account.
|
|
56
55
|
*
|
|
57
|
-
* Enforced
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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`.
|
|
60
61
|
*/
|
|
61
62
|
export class PermitOfferSelfTargetError extends Error {
|
|
62
63
|
constructor() {
|
|
@@ -64,39 +65,91 @@ export class PermitOfferSelfTargetError extends Error {
|
|
|
64
65
|
this.name = 'PermitOfferSelfTargetError';
|
|
65
66
|
}
|
|
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 `PermitOfferNotFoundError`
|
|
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 PermitOfferActorMismatchError extends Error {
|
|
77
|
+
constructor(offer_id) {
|
|
78
|
+
super(`Offer ${offer_id} is targeted to a different actor on this account`);
|
|
79
|
+
this.name = 'PermitOfferActorMismatchError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Error thrown when `query_permit_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 PermitOfferActorAccountMismatchError extends Error {
|
|
89
|
+
constructor() {
|
|
90
|
+
super('to_actor_id does not belong to to_account_id');
|
|
91
|
+
this.name = 'PermitOfferActorAccountMismatchError';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
67
94
|
/**
|
|
68
95
|
* Create a new permit offer, or refresh an existing pending offer for the
|
|
69
96
|
* same `(to_account_id, role, scope_id, from_actor_id)` tuple.
|
|
70
97
|
*
|
|
71
98
|
* Re-offer semantics: a second call by the same grantor with the same
|
|
72
99
|
* `(to_account, role, scope)` while pending upserts the existing row,
|
|
73
|
-
* refreshing `message` and `expires_at
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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.
|
|
77
106
|
*
|
|
78
107
|
* Self-offer rejection: throws `PermitOfferSelfTargetError` if the offering
|
|
79
108
|
* actor belongs to the recipient account.
|
|
80
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
|
+
*
|
|
81
116
|
* @mutates `permit_offer` table - inserts a new offer or upserts the matching pending row
|
|
82
117
|
* @throws PermitOfferSelfTargetError if the offering actor belongs to `to_account_id`
|
|
118
|
+
* @throws PermitOfferActorAccountMismatchError if `to_actor_id` is set but does not belong to `to_account_id`
|
|
83
119
|
*/
|
|
84
120
|
export const query_permit_offer_create = async (deps, input) => {
|
|
85
|
-
|
|
86
|
-
|
|
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) {
|
|
87
132
|
throw new PermitOfferSelfTargetError();
|
|
88
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 PermitOfferActorAccountMismatchError();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
89
140
|
const row = await deps.db.query_one(`INSERT INTO permit_offer
|
|
90
|
-
(from_actor_id, to_account_id, role, scope_id, message, expires_at)
|
|
91
|
-
VALUES ($1, $2, $3, $4, $5, $6)
|
|
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)
|
|
92
143
|
ON CONFLICT (to_account_id, role, COALESCE(scope_id, '${PERMIT_OFFER_SCOPE_SENTINEL_UUID}'::uuid), from_actor_id)
|
|
93
144
|
WHERE accepted_at IS NULL AND declined_at IS NULL AND retracted_at IS NULL AND superseded_at IS NULL
|
|
94
145
|
DO UPDATE SET
|
|
146
|
+
to_actor_id = EXCLUDED.to_actor_id,
|
|
95
147
|
message = EXCLUDED.message,
|
|
96
148
|
expires_at = EXCLUDED.expires_at
|
|
97
149
|
RETURNING *`, [
|
|
98
150
|
input.from_actor_id,
|
|
99
151
|
input.to_account_id,
|
|
152
|
+
input.to_actor_id ?? null,
|
|
100
153
|
input.role,
|
|
101
154
|
input.scope_id ?? null,
|
|
102
155
|
input.message ?? null,
|
|
@@ -112,19 +165,30 @@ export const query_permit_offer_create = async (deps, input) => {
|
|
|
112
165
|
* `PermitOfferAlreadyTerminalError` if the offer exists for the caller but
|
|
113
166
|
* is already in a terminal state.
|
|
114
167
|
*
|
|
168
|
+
* Returns the declined offer with the grantor's `from_account_id` joined
|
|
169
|
+
* in via CTE — the decline audit envelope populates **both**
|
|
170
|
+
* `target_actor_id` (the grantor actor) and `target_account_id` (the
|
|
171
|
+
* grantor account), satisfying the "both populated → same account"
|
|
172
|
+
* invariant the audit-log column comments describe.
|
|
173
|
+
*
|
|
115
174
|
* @mutates `permit_offer` row - sets `declined_at` and `decline_reason`
|
|
116
175
|
* @throws PermitOfferAlreadyTerminalError if the offer is already accepted, declined, retracted, or superseded
|
|
117
176
|
*/
|
|
118
177
|
export const query_permit_offer_decline = async (deps, offer_id, to_account_id, reason) => {
|
|
119
|
-
const updated = await deps.db.query_one(`
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
178
|
+
const updated = await deps.db.query_one(`WITH updated AS (
|
|
179
|
+
UPDATE permit_offer
|
|
180
|
+
SET declined_at = NOW(), decline_reason = $3
|
|
181
|
+
WHERE id = $1
|
|
182
|
+
AND to_account_id = $2
|
|
183
|
+
AND accepted_at IS NULL
|
|
184
|
+
AND declined_at IS NULL
|
|
185
|
+
AND retracted_at IS NULL
|
|
186
|
+
AND superseded_at IS NULL
|
|
187
|
+
RETURNING *
|
|
188
|
+
)
|
|
189
|
+
SELECT u.*, grantor.account_id AS from_account_id
|
|
190
|
+
FROM updated u
|
|
191
|
+
JOIN actor grantor ON grantor.id = u.from_actor_id`, [offer_id, to_account_id, reason ?? null]);
|
|
128
192
|
if (updated)
|
|
129
193
|
return updated;
|
|
130
194
|
return resolve_terminal_or_missing(deps, offer_id, { to_account_id });
|
|
@@ -265,10 +329,10 @@ export const query_permit_offer_sweep_expired = async (deps) => {
|
|
|
265
329
|
* @throws PermitOfferNotFoundError if the offer is missing or belongs to another recipient
|
|
266
330
|
* @throws PermitOfferAlreadyTerminalError if the offer is declined, retracted, or superseded
|
|
267
331
|
* @throws PermitOfferExpiredError if the offer is pending but past `expires_at`
|
|
268
|
-
* @throws Error if the accepting
|
|
332
|
+
* @throws Error if the accepting `actor_id` does not belong to `to_account_id`, or invariant assertions fail
|
|
269
333
|
*/
|
|
270
334
|
export const query_accept_offer = async (deps, input) => {
|
|
271
|
-
const { offer_id, to_account_id, ip } = input;
|
|
335
|
+
const { offer_id, to_account_id, actor_id, ip } = input;
|
|
272
336
|
// Claim the offer with a row-level lock. Subsequent concurrent callers
|
|
273
337
|
// block on the lock until this transaction commits/rolls back; after commit
|
|
274
338
|
// they see the new state (accepted or terminal) and branch idempotently.
|
|
@@ -284,11 +348,19 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
284
348
|
if (locked.accepted_at) {
|
|
285
349
|
// Race winner already committed; return the pre-existing permit.
|
|
286
350
|
// `permit_offer_permit_iff_accepted` CHECK guarantees resulting_permit_id is non-null.
|
|
287
|
-
const permit = await deps.db.query_one(`SELECT * FROM permit WHERE id = $1`, [
|
|
351
|
+
const permit = assert_row(await deps.db.query_one(`SELECT * FROM permit WHERE id = $1`, [
|
|
288
352
|
locked.resulting_permit_id,
|
|
289
|
-
]);
|
|
353
|
+
]), 'resulting_permit lookup');
|
|
354
|
+
// Multi-actor guard: two actors on the same recipient account may
|
|
355
|
+
// both race an account-grain offer — the loser must not silently
|
|
356
|
+
// receive the winner's permit (which would tell them "you got it"
|
|
357
|
+
// while the actor on the permit is someone else). Treat the offer
|
|
358
|
+
// as terminal for the loser.
|
|
359
|
+
if (permit.actor_id !== actor_id) {
|
|
360
|
+
throw new PermitOfferAlreadyTerminalError(offer_id);
|
|
361
|
+
}
|
|
290
362
|
return {
|
|
291
|
-
permit
|
|
363
|
+
permit,
|
|
292
364
|
offer: locked,
|
|
293
365
|
created: false,
|
|
294
366
|
superseded_offers: [],
|
|
@@ -304,10 +376,33 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
304
376
|
if (new Date(locked.expires_at) <= new Date()) {
|
|
305
377
|
throw new PermitOfferExpiredError(offer_id);
|
|
306
378
|
}
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
379
|
+
// Actor-targeted offer gate. When the offer is account-grain
|
|
380
|
+
// (`to_actor_id IS NULL`) any actor on `to_account_id` may accept and
|
|
381
|
+
// the existing actor↔account check below applies. When actor-grain
|
|
382
|
+
// (`to_actor_id IS NOT NULL`) the accepting actor must match —
|
|
383
|
+
// reject otherwise, even when the actor is on the same account, so
|
|
384
|
+
// teacher-A's offer cannot be claimed by teacher-B's actor.
|
|
385
|
+
//
|
|
386
|
+
// Ordering contract: this check fires *before* the cross-account
|
|
387
|
+
// `actor_check` SELECT below. A wrong-actor accept on an actor-grain
|
|
388
|
+
// offer surfaces as `PermitOfferActorMismatchError` regardless of
|
|
389
|
+
// whether the supplied `actor_id` belongs to `to_account_id` — the
|
|
390
|
+
// actor-grain binding is the tighter constraint and dominates. The
|
|
391
|
+
// cross-account `Error` only fires for account-grain offers (or
|
|
392
|
+
// matching actor-grain offers where `to_actor_id === actor_id` but
|
|
393
|
+
// the actor turns out not to be on the account, which is unreachable
|
|
394
|
+
// under the FK invariant but stays as defense-in-depth).
|
|
395
|
+
if (locked.to_actor_id != null && locked.to_actor_id !== actor_id) {
|
|
396
|
+
throw new PermitOfferActorMismatchError(offer_id);
|
|
397
|
+
}
|
|
398
|
+
// Verify the accepting actor belongs to the recipient account.
|
|
399
|
+
// Defense-in-depth: the action handler passes `auth.actor.id` which is
|
|
400
|
+
// already session-bound, but enforcing the invariant here protects
|
|
401
|
+
// direct callers (tests, future consumers) from cross-account binding
|
|
402
|
+
// bugs that would silently grant a permit to the wrong actor.
|
|
403
|
+
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
|
+
if (!actor_check) {
|
|
405
|
+
throw new Error(`Accepting actor ${actor_id} does not belong to account ${to_account_id} (offer ${offer_id})`);
|
|
311
406
|
}
|
|
312
407
|
// Insert the permit. Uses the normal grant idempotency — if another
|
|
313
408
|
// code path already granted the same (actor, role, scope), reuse it.
|
|
@@ -316,7 +411,7 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
316
411
|
ON CONFLICT (actor_id, role, COALESCE(scope_id, '${PERMIT_OFFER_SCOPE_SENTINEL_UUID}'::uuid))
|
|
317
412
|
WHERE revoked_at IS NULL
|
|
318
413
|
DO NOTHING
|
|
319
|
-
RETURNING *`, [
|
|
414
|
+
RETURNING *`, [actor_id, locked.role, locked.scope_id, locked.from_actor_id, locked.id]);
|
|
320
415
|
let permit;
|
|
321
416
|
if (granted_permit) {
|
|
322
417
|
permit = granted_permit;
|
|
@@ -326,7 +421,7 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
326
421
|
WHERE actor_id = $1
|
|
327
422
|
AND role = $2
|
|
328
423
|
AND scope_id IS NOT DISTINCT FROM $3
|
|
329
|
-
AND revoked_at IS NULL`, [
|
|
424
|
+
AND revoked_at IS NULL`, [actor_id, locked.role, locked.scope_id]);
|
|
330
425
|
permit = assert_row(existing, 'query_accept_offer idempotent permit lookup');
|
|
331
426
|
}
|
|
332
427
|
// Single UPDATE sets both sides of the CHECK constraint at once.
|
|
@@ -358,10 +453,15 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
358
453
|
JOIN actor grantor ON grantor.id = u.from_actor_id`, [to_account_id, offer.role, offer.scope_id, offer.id]);
|
|
359
454
|
// Emit audit events in-transaction (atomic with the permit insert).
|
|
360
455
|
// `RETURNING *` after the SET guarantees `offer.resulting_permit_id === permit.id`.
|
|
456
|
+
// Accept binds the actor deterministically — populate both target
|
|
457
|
+
// columns to mirror `permit_grant` (the in-tx pair) so forensic
|
|
458
|
+
// queries don't have to split between the two events.
|
|
361
459
|
const offer_accept_event = await query_audit_log(deps, {
|
|
362
460
|
event_type: 'permit_offer_accept',
|
|
363
|
-
actor_id
|
|
461
|
+
actor_id,
|
|
364
462
|
account_id: to_account_id,
|
|
463
|
+
target_account_id: to_account_id,
|
|
464
|
+
target_actor_id: actor_id,
|
|
365
465
|
ip: ip ?? null,
|
|
366
466
|
metadata: {
|
|
367
467
|
offer_id: offer.id,
|
|
@@ -370,10 +470,17 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
370
470
|
scope_id: offer.scope_id,
|
|
371
471
|
},
|
|
372
472
|
});
|
|
473
|
+
// `permit_grant` is the canonical actor-bound-subject event — the
|
|
474
|
+
// permit just bound to this actor. On self-accept the actor and the
|
|
475
|
+
// target are the same identity; on admin direct-grant (separate code
|
|
476
|
+
// path) they differ. Either way `target_actor_id` carries the
|
|
477
|
+
// grantee for actor-grain forensics.
|
|
373
478
|
const permit_grant_event = await query_audit_log(deps, {
|
|
374
479
|
event_type: 'permit_grant',
|
|
375
|
-
actor_id
|
|
480
|
+
actor_id,
|
|
376
481
|
account_id: to_account_id,
|
|
482
|
+
target_account_id: to_account_id,
|
|
483
|
+
target_actor_id: actor_id,
|
|
377
484
|
ip: ip ?? null,
|
|
378
485
|
metadata: {
|
|
379
486
|
role: offer.role,
|
|
@@ -384,10 +491,15 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
384
491
|
});
|
|
385
492
|
const supersede_events = [];
|
|
386
493
|
for (const sibling of superseded) {
|
|
494
|
+
// Supersede inherits the sibling's actor-grain target — actor-grain
|
|
495
|
+
// when the sibling was actor-targeted, account-grain (null) when it
|
|
496
|
+
// was account-level.
|
|
387
497
|
supersede_events.push(await query_audit_log(deps, {
|
|
388
498
|
event_type: 'permit_offer_supersede',
|
|
389
|
-
actor_id
|
|
499
|
+
actor_id,
|
|
390
500
|
account_id: to_account_id,
|
|
501
|
+
target_account_id: to_account_id,
|
|
502
|
+
target_actor_id: sibling.to_actor_id,
|
|
391
503
|
ip: ip ?? null,
|
|
392
504
|
metadata: {
|
|
393
505
|
offer_id: sibling.id,
|
|
@@ -17,7 +17,7 @@ export declare const PERMIT_OFFER_SCOPE_SENTINEL_UUID = "00000000-0000-0000-0000
|
|
|
17
17
|
export declare const PERMIT_OFFER_MESSAGE_LENGTH_MAX = 500;
|
|
18
18
|
/** Default TTL for a newly created offer — 30 days. Matches GitHub org-invite expiry. */
|
|
19
19
|
export declare const PERMIT_OFFER_DEFAULT_TTL_MS: number;
|
|
20
|
-
export declare const PERMIT_OFFER_SCHEMA = "\nCREATE TABLE IF NOT EXISTS permit_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 role TEXT NOT 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_permit_id UUID NULL REFERENCES permit(id) ON DELETE SET NULL,\n CONSTRAINT permit_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 permit_offer_permit_iff_accepted CHECK (\n (accepted_at IS NOT NULL) = (resulting_permit_id IS NOT NULL)\n ),\n CONSTRAINT permit_offer_reason_iff_declined CHECK (\n decline_reason IS NULL OR declined_at IS NOT NULL\n )\n)";
|
|
20
|
+
export declare const PERMIT_OFFER_SCHEMA = "\nCREATE TABLE IF NOT EXISTS permit_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_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_permit_id UUID NULL REFERENCES permit(id) ON DELETE SET NULL,\n CONSTRAINT permit_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 permit_offer_permit_iff_accepted CHECK (\n (accepted_at IS NOT NULL) = (resulting_permit_id IS NOT NULL)\n ),\n CONSTRAINT permit_offer_reason_iff_declined CHECK (\n decline_reason IS NULL OR declined_at IS NOT NULL\n )\n)";
|
|
21
21
|
/**
|
|
22
22
|
* At most one pending offer per (to_account, role, scope, from_actor).
|
|
23
23
|
*
|
|
@@ -37,6 +37,19 @@ export interface PermitOffer {
|
|
|
37
37
|
id: Uuid;
|
|
38
38
|
from_actor_id: Uuid;
|
|
39
39
|
to_account_id: Uuid;
|
|
40
|
+
/**
|
|
41
|
+
* Optional actor-grain target on the recipient account. When set, accept
|
|
42
|
+
* is gated to this specific actor — `query_accept_offer` rejects any
|
|
43
|
+
* other actor with `permit_offer_actor_mismatch` even when they belong
|
|
44
|
+
* to `to_account_id`. When null the offer is account-grain and any
|
|
45
|
+
* actor on `to_account_id` may accept (the v1 default).
|
|
46
|
+
*
|
|
47
|
+
* Drives the audit envelope's `target_actor_id` on offer-shape events
|
|
48
|
+
* (`permit_offer_create` / `_expire` / `_retract` / `_supersede`) — when
|
|
49
|
+
* set, the actor-grain forensic field carries the named actor; when
|
|
50
|
+
* null the offer-shape events leave it null by design.
|
|
51
|
+
*/
|
|
52
|
+
to_actor_id: Uuid | null;
|
|
40
53
|
role: string;
|
|
41
54
|
scope_id: Uuid | null;
|
|
42
55
|
message: string | null;
|
|
@@ -75,6 +88,14 @@ export interface SupersededOffer extends PermitOffer {
|
|
|
75
88
|
export interface CreatePermitOfferInput {
|
|
76
89
|
from_actor_id: Uuid;
|
|
77
90
|
to_account_id: Uuid;
|
|
91
|
+
/**
|
|
92
|
+
* Optional actor-grain target on the recipient account. When set,
|
|
93
|
+
* `query_permit_offer_create` validates that the actor belongs to
|
|
94
|
+
* `to_account_id` and stamps the column; accept then matches against
|
|
95
|
+
* this specific actor. Omit (or pass null) for the account-grain
|
|
96
|
+
* default — any actor on `to_account_id` may accept.
|
|
97
|
+
*/
|
|
98
|
+
to_actor_id?: Uuid | null;
|
|
78
99
|
role: string;
|
|
79
100
|
scope_id?: Uuid | null;
|
|
80
101
|
message?: string | null;
|
|
@@ -85,6 +106,7 @@ export declare const PermitOfferJson: z.ZodObject<{
|
|
|
85
106
|
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
86
107
|
from_actor_id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
87
108
|
to_account_id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
109
|
+
to_actor_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
88
110
|
role: z.ZodString;
|
|
89
111
|
scope_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
90
112
|
message: z.ZodNullable<z.ZodString>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permit_offer_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_offer_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAI5C,mHAAmH;AACnH,eAAO,MAAM,gCAAgC,yCAAyC,CAAC;AAEvF,mEAAmE;AACnE,eAAO,MAAM,+BAA+B,MAAM,CAAC;AAEnD,yFAAyF;AACzF,eAAO,MAAM,2BAA2B,QAA2B,CAAC;AAEpE,eAAO,MAAM,mBAAmB,
|
|
1
|
+
{"version":3,"file":"permit_offer_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_offer_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAI5C,mHAAmH;AACnH,eAAO,MAAM,gCAAgC,yCAAyC,CAAC;AAEvF,mEAAmE;AACnE,eAAO,MAAM,+BAA+B,MAAM,CAAC;AAEnD,yFAAyF;AACzF,eAAO,MAAM,2BAA2B,QAA2B,CAAC;AAEpE,eAAO,MAAM,mBAAmB,8oCA8B9B,CAAC;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iCAAiC,8UAWhB,CAAC;AAE/B,+EAA+E;AAC/E,eAAO,MAAM,wBAAwB,0NAMP,CAAC;AAQ/B,oDAAoD;AACpD,MAAM,WAAW,WAAW;IAC3B,EAAE,EAAE,IAAI,CAAC;IACT,aAAa,EAAE,IAAI,CAAC;IACpB,aAAa,EAAE,IAAI,CAAC;IACpB;;;;;;;;;;;OAWG;IACH,WAAW,EAAE,IAAI,GAAG,IAAI,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B;;;;;OAKG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mBAAmB,EAAE,IAAI,GAAG,IAAI,CAAC;CACjC;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,eAAgB,SAAQ,WAAW;IACnD,eAAe,EAAE,IAAI,CAAC;CACtB;AAED;;;;;GAKG;AACH,MAAM,WAAW,sBAAsB;IACtC,aAAa,EAAE,IAAI,CAAC;IACpB,aAAa,EAAE,IAAI,CAAC;IACpB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,EAAE,IAAI,CAAC;CACjB;AAED,oDAAoD;AACpD,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;kBAgDyD,CAAC;AACtF,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,6DAA6D;AAC7D,eAAO,MAAM,oBAAoB,GAAI,OAAO,WAAW,KAAG,eAgBxD,CAAC"}
|
|
@@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS permit_offer (
|
|
|
23
23
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
24
24
|
from_actor_id UUID NOT NULL REFERENCES actor(id) ON DELETE CASCADE,
|
|
25
25
|
to_account_id UUID NOT NULL REFERENCES account(id) ON DELETE CASCADE,
|
|
26
|
+
to_actor_id UUID NULL REFERENCES actor(id) ON DELETE CASCADE,
|
|
26
27
|
role TEXT NOT NULL,
|
|
27
28
|
scope_id UUID NULL,
|
|
28
29
|
message TEXT NULL,
|
|
@@ -85,6 +86,9 @@ export const PermitOfferJson = z
|
|
|
85
86
|
id: Uuid.meta({ description: 'Offer id.' }),
|
|
86
87
|
from_actor_id: Uuid.meta({ description: 'Actor that issued the offer.' }),
|
|
87
88
|
to_account_id: Uuid.meta({ description: 'Account the offer is directed to.' }),
|
|
89
|
+
to_actor_id: Uuid.nullable().meta({
|
|
90
|
+
description: 'Optional actor-grain target on the recipient account. When set, only this actor may accept; when null any actor on `to_account_id` may accept.',
|
|
91
|
+
}),
|
|
88
92
|
role: RoleName.meta({ description: 'Role being offered.' }),
|
|
89
93
|
scope_id: Uuid.nullable().meta({
|
|
90
94
|
description: 'Scope the offered permit applies to (e.g. a classroom id). `null` for global permits.',
|
|
@@ -128,6 +132,7 @@ export const to_permit_offer_json = (offer) => ({
|
|
|
128
132
|
id: offer.id,
|
|
129
133
|
from_actor_id: offer.from_actor_id,
|
|
130
134
|
to_account_id: offer.to_account_id,
|
|
135
|
+
to_actor_id: offer.to_actor_id,
|
|
131
136
|
role: offer.role,
|
|
132
137
|
scope_id: offer.scope_id,
|
|
133
138
|
message: offer.message,
|
|
@@ -28,23 +28,32 @@ import { type SupersededOffer } from './permit_offer_schema.js';
|
|
|
28
28
|
*/
|
|
29
29
|
export declare const query_grant_permit: (deps: QueryDeps, input: GrantPermitInput) => Promise<Permit>;
|
|
30
30
|
/**
|
|
31
|
-
* Look up the role of an active permit
|
|
31
|
+
* Look up the role of an active permit (constrained to a specific
|
|
32
|
+
* actor) plus the actor's `account_id`.
|
|
32
33
|
*
|
|
33
34
|
* Used by admin routes to inspect the permit's role before acting
|
|
34
35
|
* (e.g., enforcing `web_grantable` on revoke). The actor constraint
|
|
35
36
|
* mirrors `query_revoke_permit` so IDOR protection is consistent:
|
|
36
37
|
* a caller can only see permits belonging to the target actor.
|
|
37
38
|
*
|
|
39
|
+
* The JOIN to `actor` collapses what used to be a second
|
|
40
|
+
* `query_actor_by_id` round-trip in the revoke handler into one read,
|
|
41
|
+
* which closes the small TOCTOU window where the actor row could be
|
|
42
|
+
* deleted between the IDOR check and the actor lookup. The `account_id`
|
|
43
|
+
* is needed by the audit envelope's `target_account_id` field and the
|
|
44
|
+
* SSE/WS socket-close fan-out targeting.
|
|
45
|
+
*
|
|
38
46
|
* Returns `null` if the permit is not found, already revoked, or
|
|
39
47
|
* belongs to a different actor.
|
|
40
48
|
*
|
|
41
49
|
* @param deps - query dependencies
|
|
42
50
|
* @param permit_id - the permit id to look up
|
|
43
51
|
* @param actor_id - the actor that must own the permit
|
|
44
|
-
* @returns `{role}` on a match, or `null`
|
|
52
|
+
* @returns `{role, account_id}` on a match, or `null`
|
|
45
53
|
*/
|
|
46
54
|
export declare const query_permit_find_active_role_for_actor: (deps: QueryDeps, permit_id: string, actor_id: string) => Promise<{
|
|
47
55
|
role: string;
|
|
56
|
+
account_id: Uuid;
|
|
48
57
|
} | null>;
|
|
49
58
|
/** Result of `query_revoke_permit` — the revoked permit plus any pending offers superseded by the revoke. */
|
|
50
59
|
export interface RevokePermitResult {
|
|
@@ -116,14 +125,17 @@ export declare const query_permit_find_account_id_for_role: (deps: QueryDeps, ro
|
|
|
116
125
|
/** Result of `query_permit_revoke_for_scope` — every permit revoked plus every pending offer superseded by the scope-wide cascade. */
|
|
117
126
|
export interface RevokeForScopeResult {
|
|
118
127
|
/**
|
|
119
|
-
* One entry per permit revoked by this call. Carries the revokee's
|
|
120
|
-
* `
|
|
121
|
-
*
|
|
128
|
+
* One entry per permit revoked by this call. Carries both the revokee's
|
|
129
|
+
* `actor_id` (the permit's grantee — drives `target_actor_id` audit
|
|
130
|
+
* envelopes) and `account_id` (the actor's account — drives
|
|
131
|
+
* `target_account_id` for SSE/WS socket-close fan-out). Empty array
|
|
132
|
+
* means no active permit was bound to the scope.
|
|
122
133
|
*/
|
|
123
134
|
revoked: Array<{
|
|
124
135
|
permit_id: Uuid;
|
|
125
136
|
role: string;
|
|
126
137
|
scope_id: Uuid;
|
|
138
|
+
actor_id: Uuid;
|
|
127
139
|
account_id: Uuid;
|
|
128
140
|
}>;
|
|
129
141
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permit_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAEjD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAC,MAAM,EAAE,gBAAgB,EAAC,MAAM,qBAAqB,CAAC;AAElE,OAAO,EAAmC,KAAK,eAAe,EAAC,MAAM,0BAA0B,CAAC;AAEhG;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,kBAAkB,GAC9B,MAAM,SAAS,EACf,OAAO,gBAAgB,KACrB,OAAO,CAAC,MAAM,CA4BhB,CAAC;AAEF
|
|
1
|
+
{"version":3,"file":"permit_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_queries.ts"],"names":[],"mappings":"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"}
|