@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.
Files changed (144) hide show
  1. package/dist/actions/CLAUDE.md +68 -13
  2. package/dist/actions/action_codegen.d.ts +13 -0
  3. package/dist/actions/action_codegen.d.ts.map +1 -1
  4. package/dist/actions/action_codegen.js +15 -1
  5. package/dist/actions/action_rpc.d.ts +60 -7
  6. package/dist/actions/action_rpc.d.ts.map +1 -1
  7. package/dist/actions/action_rpc.js +158 -44
  8. package/dist/actions/register_action_ws.d.ts +4 -4
  9. package/dist/actions/register_action_ws.js +6 -6
  10. package/dist/actions/register_ws_endpoint.d.ts +20 -7
  11. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  12. package/dist/actions/register_ws_endpoint.js +30 -5
  13. package/dist/actions/transports.d.ts.map +1 -1
  14. package/dist/actions/transports.js +0 -4
  15. package/dist/auth/CLAUDE.md +230 -63
  16. package/dist/auth/account_actions.d.ts +6 -6
  17. package/dist/auth/account_actions.d.ts.map +1 -1
  18. package/dist/auth/account_actions.js +8 -11
  19. package/dist/auth/account_queries.d.ts +6 -3
  20. package/dist/auth/account_queries.d.ts.map +1 -1
  21. package/dist/auth/account_queries.js +14 -5
  22. package/dist/auth/account_routes.d.ts +7 -10
  23. package/dist/auth/account_routes.d.ts.map +1 -1
  24. package/dist/auth/account_routes.js +70 -23
  25. package/dist/auth/account_schema.d.ts +19 -0
  26. package/dist/auth/account_schema.d.ts.map +1 -1
  27. package/dist/auth/account_schema.js +20 -0
  28. package/dist/auth/admin_action_specs.d.ts +45 -11
  29. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  30. package/dist/auth/admin_action_specs.js +23 -8
  31. package/dist/auth/admin_actions.d.ts +8 -7
  32. package/dist/auth/admin_actions.d.ts.map +1 -1
  33. package/dist/auth/admin_actions.js +11 -18
  34. package/dist/auth/audit_log_queries.d.ts +53 -14
  35. package/dist/auth/audit_log_queries.d.ts.map +1 -1
  36. package/dist/auth/audit_log_queries.js +45 -2
  37. package/dist/auth/audit_log_schema.d.ts +55 -1
  38. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  39. package/dist/auth/audit_log_schema.js +19 -3
  40. package/dist/auth/bearer_auth.d.ts +9 -7
  41. package/dist/auth/bearer_auth.d.ts.map +1 -1
  42. package/dist/auth/bearer_auth.js +13 -21
  43. package/dist/auth/cleanup.d.ts.map +1 -1
  44. package/dist/auth/cleanup.js +5 -0
  45. package/dist/auth/daemon_token_middleware.d.ts +23 -11
  46. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  47. package/dist/auth/daemon_token_middleware.js +26 -20
  48. package/dist/auth/deps.d.ts +14 -0
  49. package/dist/auth/deps.d.ts.map +1 -1
  50. package/dist/auth/middleware.d.ts.map +1 -1
  51. package/dist/auth/middleware.js +4 -2
  52. package/dist/auth/migrations.d.ts +15 -7
  53. package/dist/auth/migrations.d.ts.map +1 -1
  54. package/dist/auth/migrations.js +15 -7
  55. package/dist/auth/permit_offer_action_specs.d.ts +45 -6
  56. package/dist/auth/permit_offer_action_specs.d.ts.map +1 -1
  57. package/dist/auth/permit_offer_action_specs.js +38 -7
  58. package/dist/auth/permit_offer_actions.d.ts +2 -2
  59. package/dist/auth/permit_offer_actions.d.ts.map +1 -1
  60. package/dist/auth/permit_offer_actions.js +106 -95
  61. package/dist/auth/permit_offer_notifications.d.ts +10 -0
  62. package/dist/auth/permit_offer_notifications.d.ts.map +1 -1
  63. package/dist/auth/permit_offer_queries.d.ts +68 -9
  64. package/dist/auth/permit_offer_queries.d.ts.map +1 -1
  65. package/dist/auth/permit_offer_queries.js +147 -35
  66. package/dist/auth/permit_offer_schema.d.ts +23 -1
  67. package/dist/auth/permit_offer_schema.d.ts.map +1 -1
  68. package/dist/auth/permit_offer_schema.js +5 -0
  69. package/dist/auth/permit_queries.d.ts +17 -5
  70. package/dist/auth/permit_queries.d.ts.map +1 -1
  71. package/dist/auth/permit_queries.js +19 -8
  72. package/dist/auth/request_context.d.ts +360 -32
  73. package/dist/auth/request_context.d.ts.map +1 -1
  74. package/dist/auth/request_context.js +442 -60
  75. package/dist/auth/route_guards.d.ts +10 -4
  76. package/dist/auth/route_guards.d.ts.map +1 -1
  77. package/dist/auth/route_guards.js +14 -8
  78. package/dist/auth/self_service_role_action_specs.d.ts +2 -0
  79. package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
  80. package/dist/auth/self_service_role_action_specs.js +2 -0
  81. package/dist/auth/self_service_role_actions.d.ts +6 -5
  82. package/dist/auth/self_service_role_actions.d.ts.map +1 -1
  83. package/dist/auth/self_service_role_actions.js +32 -19
  84. package/dist/db/migrate.d.ts +11 -7
  85. package/dist/db/migrate.d.ts.map +1 -1
  86. package/dist/db/migrate.js +9 -6
  87. package/dist/dev/setup.d.ts.map +1 -1
  88. package/dist/dev/setup.js +5 -3
  89. package/dist/hono_context.d.ts +77 -0
  90. package/dist/hono_context.d.ts.map +1 -1
  91. package/dist/hono_context.js +50 -0
  92. package/dist/http/CLAUDE.md +80 -17
  93. package/dist/http/error_schemas.d.ts +92 -1
  94. package/dist/http/error_schemas.d.ts.map +1 -1
  95. package/dist/http/error_schemas.js +73 -16
  96. package/dist/http/jsonrpc_errors.d.ts +27 -2
  97. package/dist/http/jsonrpc_errors.d.ts.map +1 -1
  98. package/dist/http/jsonrpc_errors.js +26 -2
  99. package/dist/http/route_spec.d.ts +62 -4
  100. package/dist/http/route_spec.d.ts.map +1 -1
  101. package/dist/http/route_spec.js +117 -21
  102. package/dist/http/schema_helpers.d.ts +13 -1
  103. package/dist/http/schema_helpers.d.ts.map +1 -1
  104. package/dist/http/schema_helpers.js +21 -2
  105. package/dist/http/surface.d.ts +10 -1
  106. package/dist/http/surface.d.ts.map +1 -1
  107. package/dist/http/surface.js +2 -2
  108. package/dist/server/app_server.d.ts.map +1 -1
  109. package/dist/server/app_server.js +11 -1
  110. package/dist/testing/CLAUDE.md +23 -17
  111. package/dist/testing/admin_integration.d.ts.map +1 -1
  112. package/dist/testing/admin_integration.js +15 -13
  113. package/dist/testing/adversarial_headers.js +1 -1
  114. package/dist/testing/app_server.js +2 -2
  115. package/dist/testing/audit_completeness.d.ts.map +1 -1
  116. package/dist/testing/audit_completeness.js +21 -7
  117. package/dist/testing/auth_apps.d.ts.map +1 -1
  118. package/dist/testing/auth_apps.js +6 -3
  119. package/dist/testing/entities.d.ts +2 -1
  120. package/dist/testing/entities.d.ts.map +1 -1
  121. package/dist/testing/entities.js +1 -0
  122. package/dist/testing/integration_helpers.d.ts +4 -2
  123. package/dist/testing/integration_helpers.d.ts.map +1 -1
  124. package/dist/testing/integration_helpers.js +9 -5
  125. package/dist/testing/middleware.d.ts +12 -8
  126. package/dist/testing/middleware.d.ts.map +1 -1
  127. package/dist/testing/middleware.js +67 -25
  128. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  129. package/dist/testing/rpc_helpers.js +3 -1
  130. package/dist/testing/schema_generators.d.ts.map +1 -1
  131. package/dist/testing/schema_generators.js +12 -0
  132. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  133. package/dist/testing/ws_round_trip.js +5 -1
  134. package/dist/ui/CLAUDE.md +16 -10
  135. package/dist/ui/PermitOfferForm.svelte +14 -0
  136. package/dist/ui/PermitOfferForm.svelte.d.ts +6 -0
  137. package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -1
  138. package/dist/ui/admin_accounts_state.svelte.d.ts +8 -1
  139. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  140. package/dist/ui/admin_accounts_state.svelte.js +14 -3
  141. package/dist/ui/permit_offers_state.svelte.d.ts +9 -1
  142. package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -1
  143. package/dist/ui/permit_offers_state.svelte.js +7 -1
  144. 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 here (rather than via a CHECK constraint) so the constraint can
50
- * be expressed as a cross-row JOIN on `actor.account_id` without requiring
51
- * denormalized columns.
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`. A different grantor offering the
63
- * same `(to_account, role, scope)` creates a distinct row multiple
64
- * pending grantors coexist. After a terminal state, a re-offer is a fresh
65
- * INSERT.
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<PermitOffer | null>;
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 account has no actor (1:1 invariant) or invariant assertions fail
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;AAEhD,OAAO,EAEN,KAAK,sBAAsB,EAC3B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,uBAAuB,CAAC;AAEzD;;;;;GAKG;AACH,qBAAa,+BAAgC,SAAQ,KAAK;gBAC7C,QAAQ,EAAE,MAAM;CAI5B;AAED;;;;;GAKG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;gBACrC,QAAQ,EAAE,MAAM;CAI5B;AAED;;;;;GAKG;AACH,qBAAa,wBAAyB,SAAQ,KAAK;gBACtC,QAAQ,EAAE,MAAM;CAI5B;AAED;;;;;;GAMG;AACH,qBAAa,0BAA2B,SAAQ,KAAK;;CAKpD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,SAAS,EACf,OAAO,sBAAsB,KAC3B,OAAO,CAAC,WAAW,CAyBrB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,SAAS,EACf,UAAU,MAAM,EAChB,eAAe,MAAM,EACrB,QAAQ,MAAM,GAAG,IAAI,KACnB,OAAO,CAAC,WAAW,GAAG,IAAI,CAe5B,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,SAAS,EACf,UAAU,MAAM,EAChB,eAAe,MAAM,KACnB,OAAO,CAAC,WAAW,GAAG,IAAI,CAe5B,CAAC;AA8BF;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,GACnC,MAAM,SAAS,EACf,eAAe,MAAM,KACnB,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAY5B,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sCAAsC,GAClD,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAW,EACX,eAAU,KACR,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAS5B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,+BAA+B,GAC3C,MAAM,SAAS,EACf,UAAU,MAAM,KACd,OAAO,CAAC,WAAW,GAAG,IAAI,CAY5B,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,KACb,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAU5B,CAAC;AAEF,sCAAsC;AACtC,MAAM,WAAW,gBAAgB;IAChC,QAAQ,EAAE,IAAI,CAAC;IACf,mGAAmG;IACnG,aAAa,EAAE,IAAI,CAAC;IACpB,gDAAgD;IAChD,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACnB;AAED,yHAAyH;AACzH,MAAM,WAAW,iBAAiB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,CAAC;IACnB,4IAA4I;IAC5I,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;OAKG;IACH,iBAAiB,EAAE,KAAK,CAAC,eAAe,CAAC,CAAC;IAC1C,sLAAsL;IACtL,YAAY,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,eAAO,MAAM,kBAAkB,GAC9B,MAAM,SAAS,EACf,OAAO,gBAAgB,KACrB,OAAO,CAAC,iBAAiB,CAqK3B,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 here (rather than via a CHECK constraint) so the constraint can
58
- * be expressed as a cross-row JOIN on `actor.account_id` without requiring
59
- * denormalized columns.
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`. A different grantor offering the
74
- * same `(to_account, role, scope)` creates a distinct row multiple
75
- * pending grantors coexist. After a terminal state, a re-offer is a fresh
76
- * INSERT.
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
- const actor = await query_actor_by_account(deps, input.to_account_id);
86
- if (actor && actor.id === input.from_actor_id) {
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(`UPDATE permit_offer
120
- SET declined_at = NOW(), decline_reason = $3
121
- WHERE id = $1
122
- AND to_account_id = $2
123
- AND accepted_at IS NULL
124
- AND declined_at IS NULL
125
- AND retracted_at IS NULL
126
- AND superseded_at IS NULL
127
- RETURNING *`, [offer_id, to_account_id, reason ?? null]);
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 account has no actor (1:1 invariant) or invariant assertions fail
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: assert_row(permit, 'resulting_permit lookup'),
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
- // Resolve the accepting actor (1:1 account→actor in v1).
308
- const actor = await query_actor_by_account(deps, to_account_id);
309
- if (!actor) {
310
- throw new Error(`No actor for account ${to_account_id} accepting offer ${offer_id}`);
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 *`, [actor.id, locked.role, locked.scope_id, locked.from_actor_id, locked.id]);
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`, [actor.id, locked.role, locked.scope_id]);
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: 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: 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: 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,6kCA6B9B,CAAC;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iCAAiC,8UAWhB,CAAC;AAE/B,+EAA+E;AAC/E,eAAO,MAAM,wBAAwB,0NAMP,CAAC;AAE/B,oDAAoD;AACpD,MAAM,WAAW,WAAW;IAC3B,EAAE,EAAE,IAAI,CAAC;IACT,aAAa,EAAE,IAAI,CAAC;IACpB,aAAa,EAAE,IAAI,CAAC;IACpB,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,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;;;;;;;;;;;;;;;kBA4CyD,CAAC;AACtF,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,6DAA6D;AAC7D,eAAO,MAAM,oBAAoB,GAAI,OAAO,WAAW,KAAG,eAexD,CAAC"}
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, constrained to a specific actor.
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
- * `account_id` so callers can fan out a `permit_revoke` notification per
121
- * permit. Empty array means no active permit was bound to the scope.
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":"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"}
1
+ {"version":3,"file":"permit_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_queries.ts"],"names":[],"mappings":"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"}