@fuzdev/fuz_app 0.30.0 → 0.32.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 (222) hide show
  1. package/dist/actions/CLAUDE.md +630 -0
  2. package/dist/actions/action_rpc.d.ts +29 -0
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +42 -6
  5. package/dist/actions/action_types.d.ts +2 -2
  6. package/dist/actions/cancel.d.ts +12 -13
  7. package/dist/actions/cancel.d.ts.map +1 -1
  8. package/dist/actions/cancel.js +10 -13
  9. package/dist/actions/heartbeat.d.ts +8 -13
  10. package/dist/actions/heartbeat.d.ts.map +1 -1
  11. package/dist/actions/heartbeat.js +5 -8
  12. package/dist/actions/register_action_ws.d.ts +3 -3
  13. package/dist/actions/register_action_ws.js +2 -2
  14. package/dist/actions/register_ws_endpoint.d.ts +4 -4
  15. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  16. package/dist/actions/register_ws_endpoint.js +3 -3
  17. package/dist/actions/rpc_client.d.ts +29 -0
  18. package/dist/actions/rpc_client.d.ts.map +1 -1
  19. package/dist/actions/rpc_client.js +31 -0
  20. package/dist/actions/socket.svelte.d.ts +16 -16
  21. package/dist/actions/socket.svelte.d.ts.map +1 -1
  22. package/dist/actions/socket.svelte.js +15 -15
  23. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  24. package/dist/auth/CLAUDE.md +945 -0
  25. package/dist/auth/account_action_specs.d.ts +216 -0
  26. package/dist/auth/account_action_specs.d.ts.map +1 -0
  27. package/dist/auth/account_action_specs.js +159 -0
  28. package/dist/auth/account_actions.d.ts +51 -0
  29. package/dist/auth/account_actions.d.ts.map +1 -0
  30. package/dist/auth/account_actions.js +119 -0
  31. package/dist/auth/account_queries.d.ts +6 -2
  32. package/dist/auth/account_queries.d.ts.map +1 -1
  33. package/dist/auth/account_queries.js +40 -4
  34. package/dist/auth/account_routes.d.ts +94 -16
  35. package/dist/auth/account_routes.d.ts.map +1 -1
  36. package/dist/auth/account_routes.js +108 -180
  37. package/dist/auth/account_schema.d.ts +85 -30
  38. package/dist/auth/account_schema.d.ts.map +1 -1
  39. package/dist/auth/account_schema.js +40 -8
  40. package/dist/auth/admin_action_specs.d.ts +674 -0
  41. package/dist/auth/admin_action_specs.d.ts.map +1 -0
  42. package/dist/auth/admin_action_specs.js +287 -0
  43. package/dist/auth/admin_actions.d.ts +69 -0
  44. package/dist/auth/admin_actions.d.ts.map +1 -0
  45. package/dist/auth/admin_actions.js +256 -0
  46. package/dist/auth/admin_rpc_actions.d.ts +49 -0
  47. package/dist/auth/admin_rpc_actions.d.ts.map +1 -0
  48. package/dist/auth/admin_rpc_actions.js +32 -0
  49. package/dist/auth/api_token.d.ts +10 -0
  50. package/dist/auth/api_token.d.ts.map +1 -1
  51. package/dist/auth/api_token.js +9 -0
  52. package/dist/auth/api_token_queries.d.ts +3 -3
  53. package/dist/auth/api_token_queries.js +3 -3
  54. package/dist/auth/app_settings_schema.d.ts +4 -3
  55. package/dist/auth/app_settings_schema.d.ts.map +1 -1
  56. package/dist/auth/app_settings_schema.js +2 -1
  57. package/dist/auth/audit_log_routes.d.ts +14 -6
  58. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  59. package/dist/auth/audit_log_routes.js +22 -79
  60. package/dist/auth/audit_log_schema.d.ts +100 -29
  61. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  62. package/dist/auth/audit_log_schema.js +83 -11
  63. package/dist/auth/bootstrap_routes.d.ts +14 -0
  64. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  65. package/dist/auth/bootstrap_routes.js +10 -3
  66. package/dist/auth/cleanup.d.ts +63 -0
  67. package/dist/auth/cleanup.d.ts.map +1 -0
  68. package/dist/auth/cleanup.js +80 -0
  69. package/dist/auth/invite_schema.d.ts +11 -10
  70. package/dist/auth/invite_schema.d.ts.map +1 -1
  71. package/dist/auth/invite_schema.js +4 -3
  72. package/dist/auth/migrations.d.ts +6 -0
  73. package/dist/auth/migrations.d.ts.map +1 -1
  74. package/dist/auth/migrations.js +28 -0
  75. package/dist/auth/permit_offer_action_specs.d.ts +364 -0
  76. package/dist/auth/permit_offer_action_specs.d.ts.map +1 -0
  77. package/dist/auth/permit_offer_action_specs.js +216 -0
  78. package/dist/auth/permit_offer_actions.d.ts +96 -0
  79. package/dist/auth/permit_offer_actions.d.ts.map +1 -0
  80. package/dist/auth/permit_offer_actions.js +428 -0
  81. package/dist/auth/permit_offer_notifications.d.ts +361 -0
  82. package/dist/auth/permit_offer_notifications.d.ts.map +1 -0
  83. package/dist/auth/permit_offer_notifications.js +179 -0
  84. package/dist/auth/permit_offer_queries.d.ts +165 -0
  85. package/dist/auth/permit_offer_queries.d.ts.map +1 -0
  86. package/dist/auth/permit_offer_queries.js +390 -0
  87. package/dist/auth/permit_offer_schema.d.ts +103 -0
  88. package/dist/auth/permit_offer_schema.d.ts.map +1 -0
  89. package/dist/auth/permit_offer_schema.js +142 -0
  90. package/dist/auth/permit_queries.d.ts +77 -14
  91. package/dist/auth/permit_queries.d.ts.map +1 -1
  92. package/dist/auth/permit_queries.js +119 -24
  93. package/dist/auth/session_queries.d.ts +4 -2
  94. package/dist/auth/session_queries.d.ts.map +1 -1
  95. package/dist/auth/session_queries.js +4 -2
  96. package/dist/auth/signup_routes.d.ts +13 -0
  97. package/dist/auth/signup_routes.d.ts.map +1 -1
  98. package/dist/auth/signup_routes.js +14 -7
  99. package/dist/http/CLAUDE.md +584 -0
  100. package/dist/http/pending_effects.d.ts +29 -0
  101. package/dist/http/pending_effects.d.ts.map +1 -0
  102. package/dist/http/pending_effects.js +31 -0
  103. package/dist/http/route_spec.d.ts.map +1 -1
  104. package/dist/http/route_spec.js +4 -3
  105. package/dist/rate_limiter.d.ts +30 -0
  106. package/dist/rate_limiter.d.ts.map +1 -1
  107. package/dist/rate_limiter.js +25 -2
  108. package/dist/realtime/sse_auth_guard.d.ts +2 -0
  109. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  110. package/dist/realtime/sse_auth_guard.js +5 -3
  111. package/dist/server/app_server.d.ts +13 -2
  112. package/dist/server/app_server.d.ts.map +1 -1
  113. package/dist/server/app_server.js +12 -1
  114. package/dist/testing/CLAUDE.md +668 -1
  115. package/dist/testing/admin_integration.d.ts +10 -7
  116. package/dist/testing/admin_integration.d.ts.map +1 -1
  117. package/dist/testing/admin_integration.js +382 -482
  118. package/dist/testing/app_server.d.ts +7 -6
  119. package/dist/testing/app_server.d.ts.map +1 -1
  120. package/dist/testing/attack_surface.d.ts +9 -3
  121. package/dist/testing/attack_surface.d.ts.map +1 -1
  122. package/dist/testing/attack_surface.js +4 -4
  123. package/dist/testing/audit_completeness.d.ts +11 -0
  124. package/dist/testing/audit_completeness.d.ts.map +1 -1
  125. package/dist/testing/audit_completeness.js +169 -134
  126. package/dist/testing/auth_apps.d.ts.map +1 -1
  127. package/dist/testing/auth_apps.js +4 -33
  128. package/dist/testing/db.d.ts +1 -1
  129. package/dist/testing/db.d.ts.map +1 -1
  130. package/dist/testing/db.js +2 -0
  131. package/dist/testing/entities.d.ts +35 -13
  132. package/dist/testing/entities.d.ts.map +1 -1
  133. package/dist/testing/entities.js +17 -0
  134. package/dist/testing/integration.d.ts +10 -0
  135. package/dist/testing/integration.d.ts.map +1 -1
  136. package/dist/testing/integration.js +352 -340
  137. package/dist/testing/integration_helpers.d.ts +16 -5
  138. package/dist/testing/integration_helpers.d.ts.map +1 -1
  139. package/dist/testing/integration_helpers.js +24 -4
  140. package/dist/testing/rate_limiting.d.ts +7 -0
  141. package/dist/testing/rate_limiting.d.ts.map +1 -1
  142. package/dist/testing/rate_limiting.js +41 -10
  143. package/dist/testing/rpc_helpers.d.ts +153 -1
  144. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  145. package/dist/testing/rpc_helpers.js +184 -8
  146. package/dist/testing/sse_round_trip.d.ts +8 -0
  147. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  148. package/dist/testing/sse_round_trip.js +10 -3
  149. package/dist/testing/standard.d.ts +9 -1
  150. package/dist/testing/standard.d.ts.map +1 -1
  151. package/dist/testing/standard.js +6 -2
  152. package/dist/testing/stubs.d.ts +10 -2
  153. package/dist/testing/stubs.d.ts.map +1 -1
  154. package/dist/testing/stubs.js +17 -2
  155. package/dist/testing/surface_invariants.d.ts +7 -3
  156. package/dist/testing/surface_invariants.d.ts.map +1 -1
  157. package/dist/testing/surface_invariants.js +5 -4
  158. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  159. package/dist/testing/ws_round_trip.js +9 -38
  160. package/dist/ui/AccountSessions.svelte +8 -4
  161. package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
  162. package/dist/ui/AdminAccounts.svelte +61 -33
  163. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  164. package/dist/ui/AdminAuditLog.svelte +3 -2
  165. package/dist/ui/AdminAuditLog.svelte.d.ts.map +1 -1
  166. package/dist/ui/AdminInvites.svelte +3 -2
  167. package/dist/ui/AdminInvites.svelte.d.ts.map +1 -1
  168. package/dist/ui/AdminOverview.svelte +14 -9
  169. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  170. package/dist/ui/AdminPermitHistory.svelte +3 -2
  171. package/dist/ui/AdminPermitHistory.svelte.d.ts.map +1 -1
  172. package/dist/ui/AdminSessions.svelte +29 -25
  173. package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
  174. package/dist/ui/CLAUDE.md +363 -0
  175. package/dist/ui/OpenSignupToggle.svelte +6 -3
  176. package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
  177. package/dist/ui/PermitOfferForm.svelte +141 -0
  178. package/dist/ui/PermitOfferForm.svelte.d.ts +14 -0
  179. package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -0
  180. package/dist/ui/PermitOfferHistory.svelte +109 -0
  181. package/dist/ui/PermitOfferHistory.svelte.d.ts +11 -0
  182. package/dist/ui/PermitOfferHistory.svelte.d.ts.map +1 -0
  183. package/dist/ui/PermitOfferInbox.svelte +121 -0
  184. package/dist/ui/PermitOfferInbox.svelte.d.ts +12 -0
  185. package/dist/ui/PermitOfferInbox.svelte.d.ts.map +1 -0
  186. package/dist/ui/account_sessions_state.svelte.d.ts +53 -3
  187. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  188. package/dist/ui/account_sessions_state.svelte.js +39 -16
  189. package/dist/ui/admin_accounts_state.svelte.d.ts +118 -2
  190. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  191. package/dist/ui/admin_accounts_state.svelte.js +99 -23
  192. package/dist/ui/admin_invites_state.svelte.d.ts +47 -1
  193. package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
  194. package/dist/ui/admin_invites_state.svelte.js +38 -26
  195. package/dist/ui/admin_rpc_adapters.d.ts +94 -0
  196. package/dist/ui/admin_rpc_adapters.d.ts.map +1 -0
  197. package/dist/ui/admin_rpc_adapters.js +100 -0
  198. package/dist/ui/admin_sessions_state.svelte.d.ts +26 -0
  199. package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
  200. package/dist/ui/admin_sessions_state.svelte.js +35 -21
  201. package/dist/ui/app_settings_state.svelte.d.ts +39 -0
  202. package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
  203. package/dist/ui/app_settings_state.svelte.js +34 -18
  204. package/dist/ui/audit_log_state.svelte.d.ts +40 -3
  205. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  206. package/dist/ui/audit_log_state.svelte.js +36 -42
  207. package/dist/ui/auth_state.svelte.d.ts +4 -3
  208. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  209. package/dist/ui/auth_state.svelte.js +4 -1
  210. package/dist/ui/permit_offers_state.svelte.d.ts +125 -0
  211. package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -0
  212. package/dist/ui/permit_offers_state.svelte.js +197 -0
  213. package/package.json +3 -3
  214. package/dist/auth/admin_routes.d.ts +0 -29
  215. package/dist/auth/admin_routes.d.ts.map +0 -1
  216. package/dist/auth/admin_routes.js +0 -226
  217. package/dist/auth/app_settings_routes.d.ts +0 -27
  218. package/dist/auth/app_settings_routes.d.ts.map +0 -1
  219. package/dist/auth/app_settings_routes.js +0 -66
  220. package/dist/auth/invite_routes.d.ts +0 -18
  221. package/dist/auth/invite_routes.d.ts.map +0 -1
  222. package/dist/auth/invite_routes.js +0 -129
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Permit offer database queries.
3
+ *
4
+ * Covers the offer side of the consentful-permits flow: create (with
5
+ * re-offer upsert), decline, retract, list, find-pending, sweep-expired,
6
+ * and the atomic `query_accept_offer` that bridges offer → permit.
7
+ *
8
+ * IDOR guards are expressed in each helper's signature — decline/accept
9
+ * require the recipient's `to_account_id`, retract requires the grantor's
10
+ * `from_actor_id`.
11
+ *
12
+ * @module
13
+ */
14
+ import type { QueryDeps } from '../db/query_deps.js';
15
+ import type { Uuid } from '../uuid.js';
16
+ import type { Permit } from './account_schema.js';
17
+ import { type CreatePermitOfferInput, type PermitOffer, type SupersededOffer } from './permit_offer_schema.js';
18
+ import type { AuditLogEvent } from './audit_log_schema.js';
19
+ /**
20
+ * Error thrown by offer-lifecycle queries when the offer is in a non-pending
21
+ * state (accepted / declined / retracted / superseded) and therefore not
22
+ * actionable. Distinct from `PermitOfferExpiredError` — expiry has its own
23
+ * user-facing story ("ask the grantor to re-send") so it travels separately.
24
+ */
25
+ export declare class PermitOfferAlreadyTerminalError extends Error {
26
+ constructor(offer_id: string);
27
+ }
28
+ /**
29
+ * Error thrown when an offer's `expires_at` has passed. The accept path
30
+ * enforces this independently of the sweep — a stale offer past its expiry
31
+ * must not be accepted, even in the race window between expiry and the
32
+ * sweep stamping the audit event.
33
+ */
34
+ export declare class PermitOfferExpiredError extends Error {
35
+ constructor(offer_id: string);
36
+ }
37
+ /**
38
+ * Error thrown when an offer cannot be located for the caller. Covers both
39
+ * "offer does not exist" and "offer belongs to a different recipient"
40
+ * (IDOR guard) — the standard 404-over-403 pattern that avoids disclosing
41
+ * whether an offer id exists.
42
+ */
43
+ export declare class PermitOfferNotFoundError extends Error {
44
+ constructor(offer_id: string);
45
+ }
46
+ /**
47
+ * Error thrown when a grantor attempts to offer a permit to their own account.
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.
52
+ */
53
+ export declare class PermitOfferSelfTargetError extends Error {
54
+ constructor();
55
+ }
56
+ /**
57
+ * Create a new permit offer, or refresh an existing pending offer for the
58
+ * same `(to_account_id, role, scope_id, from_actor_id)` tuple.
59
+ *
60
+ * Re-offer semantics: a second call by the same grantor with the same
61
+ * `(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.
66
+ *
67
+ * Self-offer rejection: throws `PermitOfferSelfTargetError` if the offering
68
+ * actor belongs to the recipient account.
69
+ */
70
+ export declare const query_permit_offer_create: (deps: QueryDeps, input: CreatePermitOfferInput) => Promise<PermitOffer>;
71
+ /**
72
+ * Mark an offer declined.
73
+ *
74
+ * Guarded by `to_account_id` (IDOR). Returns `null` if the offer does not
75
+ * exist or belongs to a different account. Throws
76
+ * `PermitOfferAlreadyTerminalError` if the offer exists for the caller but
77
+ * is already in a terminal state.
78
+ */
79
+ export declare const query_permit_offer_decline: (deps: QueryDeps, offer_id: string, to_account_id: string, reason: string | null) => Promise<PermitOffer | null>;
80
+ /**
81
+ * Mark an offer retracted by the grantor.
82
+ *
83
+ * Guarded by `from_actor_id` (IDOR). Returns `null` if the offer does not
84
+ * exist or was issued by a different actor. Throws
85
+ * `PermitOfferAlreadyTerminalError` if the offer exists for this grantor
86
+ * but is already in a terminal state.
87
+ */
88
+ export declare const query_permit_offer_retract: (deps: QueryDeps, offer_id: string, from_actor_id: string) => Promise<PermitOffer | null>;
89
+ /**
90
+ * List pending, non-expired offers for an account, soonest expiry first.
91
+ *
92
+ * Expired offers are filtered server-side (`expires_at > NOW()`) so the
93
+ * inbox never surfaces a row that can no longer be accepted. The periodic
94
+ * sweep (`query_permit_offer_sweep_expired`) handles audit tombstoning.
95
+ */
96
+ export declare const query_permit_offer_list: (deps: QueryDeps, to_account_id: string) => Promise<Array<PermitOffer>>;
97
+ /**
98
+ * List every offer involving an account (either direction), newest first.
99
+ *
100
+ * Includes terminal offers — used by the grantor-side admin / history view.
101
+ */
102
+ export declare const query_permit_offer_history_for_account: (deps: QueryDeps, account_id: string, limit?: number, offset?: number) => Promise<Array<PermitOffer>>;
103
+ /**
104
+ * Look up a pending offer by id. Returns `null` if the offer is terminal,
105
+ * expired (server-side filter), or missing.
106
+ */
107
+ export declare const query_permit_offer_find_pending: (deps: QueryDeps, offer_id: string) => Promise<PermitOffer | null>;
108
+ /**
109
+ * Return pending offers whose `expires_at` has passed.
110
+ *
111
+ * Callers fire `permit_offer_expire` audit events for each row. The schema
112
+ * does not tombstone the row, so callers are responsible for their own
113
+ * idempotency (e.g. check whether a `permit_offer_expire` audit event
114
+ * already exists for the offer id).
115
+ */
116
+ export declare const query_permit_offer_sweep_expired: (deps: QueryDeps) => Promise<Array<PermitOffer>>;
117
+ /** Input for `query_accept_offer`. */
118
+ export interface AcceptOfferInput {
119
+ offer_id: Uuid;
120
+ /** Account of the accepting recipient — IDOR guard against another account accepting the offer. */
121
+ to_account_id: Uuid;
122
+ /** Optional IP to stamp on the audit events. */
123
+ ip?: string | null;
124
+ }
125
+ /** Result of `query_accept_offer` — the permit produced (new or pre-existing on race), plus the (now-accepted) offer. */
126
+ export interface AcceptOfferResult {
127
+ permit: Permit;
128
+ offer: PermitOffer;
129
+ /** `true` if this call is the one that accepted the offer (new permit inserted); `false` on a race returning the already-created permit. */
130
+ created: boolean;
131
+ /**
132
+ * Sibling offers superseded by this accept — empty on the race-loser path.
133
+ * Each entry carries its grantor's `from_account_id` so the caller can
134
+ * fan out `permit_offer_supersede` notifications without a second
135
+ * round-trip.
136
+ */
137
+ superseded_offers: Array<SupersededOffer>;
138
+ /** Audit events emitted in-transaction — fed back through the normal `on_audit_event` broadcast chain by the caller. Includes one `permit_offer_supersede` per superseded sibling. */
139
+ audit_events: Array<AuditLogEvent>;
140
+ }
141
+ /**
142
+ * Accept an offer atomically: mark accepted, insert the permit, stamp
143
+ * `resulting_permit_id`, supersede sibling pending offers for the same
144
+ * `(to_account, role, scope)`, and emit `permit_offer_accept` +
145
+ * `permit_grant` + one `permit_offer_supersede` per sibling. Must run
146
+ * inside a transaction — the caller's route spec should declare
147
+ * `transaction: true` (or wrap explicitly).
148
+ *
149
+ * Idempotent on race: if a second concurrent call observes the offer
150
+ * already accepted, returns the existing permit rather than creating a
151
+ * duplicate or throwing.
152
+ *
153
+ * Error map:
154
+ * - `PermitOfferNotFoundError` — offer does not exist, or belongs to a
155
+ * different recipient (IDOR guard). The offer row is untouched.
156
+ * - `PermitOfferAlreadyTerminalError` — offer is declined, retracted, or
157
+ * superseded.
158
+ * - `PermitOfferExpiredError` — offer is pending but past `expires_at`.
159
+ *
160
+ * Sibling supersede is what closes the "accept a pre-revoke sibling offer
161
+ * to bypass a revoke" path: once A is accepted, B/C/... can no longer be
162
+ * accepted even if the resulting permit is later revoked.
163
+ */
164
+ export declare const query_accept_offer: (deps: QueryDeps, input: AcceptOfferInput) => Promise<AcceptOfferResult>;
165
+ //# sourceMappingURL=permit_offer_queries.d.ts.map
@@ -0,0 +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,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAEnD,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,YAAY,CAAC;AACrC,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;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,SAAS,EACf,OAAO,sBAAsB,KAC3B,OAAO,CAAC,WAAW,CAyBrB,CAAC;AAEF;;;;;;;GAOG;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;;;;;;;GAOG;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;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,kBAAkB,GAC9B,MAAM,SAAS,EACf,OAAO,gBAAgB,KACrB,OAAO,CAAC,iBAAiB,CAqK3B,CAAC"}
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Permit offer database queries.
3
+ *
4
+ * Covers the offer side of the consentful-permits flow: create (with
5
+ * re-offer upsert), decline, retract, list, find-pending, sweep-expired,
6
+ * and the atomic `query_accept_offer` that bridges offer → permit.
7
+ *
8
+ * IDOR guards are expressed in each helper's signature — decline/accept
9
+ * require the recipient's `to_account_id`, retract requires the grantor's
10
+ * `from_actor_id`.
11
+ *
12
+ * @module
13
+ */
14
+ import { assert_row } from '../db/assert_row.js';
15
+ import { query_actor_by_account } from './account_queries.js';
16
+ import { PERMIT_OFFER_SCOPE_SENTINEL_UUID, } from './permit_offer_schema.js';
17
+ import { query_audit_log } from './audit_log_queries.js';
18
+ /**
19
+ * Error thrown by offer-lifecycle queries when the offer is in a non-pending
20
+ * state (accepted / declined / retracted / superseded) and therefore not
21
+ * actionable. Distinct from `PermitOfferExpiredError` — expiry has its own
22
+ * user-facing story ("ask the grantor to re-send") so it travels separately.
23
+ */
24
+ export class PermitOfferAlreadyTerminalError extends Error {
25
+ constructor(offer_id) {
26
+ super(`Offer ${offer_id} is already in a terminal state`);
27
+ this.name = 'PermitOfferAlreadyTerminalError';
28
+ }
29
+ }
30
+ /**
31
+ * Error thrown when an offer's `expires_at` has passed. The accept path
32
+ * enforces this independently of the sweep — a stale offer past its expiry
33
+ * must not be accepted, even in the race window between expiry and the
34
+ * sweep stamping the audit event.
35
+ */
36
+ export class PermitOfferExpiredError extends Error {
37
+ constructor(offer_id) {
38
+ super(`Offer ${offer_id} has expired`);
39
+ this.name = 'PermitOfferExpiredError';
40
+ }
41
+ }
42
+ /**
43
+ * Error thrown when an offer cannot be located for the caller. Covers both
44
+ * "offer does not exist" and "offer belongs to a different recipient"
45
+ * (IDOR guard) — the standard 404-over-403 pattern that avoids disclosing
46
+ * whether an offer id exists.
47
+ */
48
+ export class PermitOfferNotFoundError extends Error {
49
+ constructor(offer_id) {
50
+ super(`Offer ${offer_id} not found`);
51
+ this.name = 'PermitOfferNotFoundError';
52
+ }
53
+ }
54
+ /**
55
+ * Error thrown when a grantor attempts to offer a permit to their own account.
56
+ *
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.
60
+ */
61
+ export class PermitOfferSelfTargetError extends Error {
62
+ constructor() {
63
+ super('Cannot offer a permit to your own account');
64
+ this.name = 'PermitOfferSelfTargetError';
65
+ }
66
+ }
67
+ /**
68
+ * Create a new permit offer, or refresh an existing pending offer for the
69
+ * same `(to_account_id, role, scope_id, from_actor_id)` tuple.
70
+ *
71
+ * Re-offer semantics: a second call by the same grantor with the same
72
+ * `(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.
77
+ *
78
+ * Self-offer rejection: throws `PermitOfferSelfTargetError` if the offering
79
+ * actor belongs to the recipient account.
80
+ */
81
+ export const query_permit_offer_create = async (deps, input) => {
82
+ const actor = await query_actor_by_account(deps, input.to_account_id);
83
+ if (actor && actor.id === input.from_actor_id) {
84
+ throw new PermitOfferSelfTargetError();
85
+ }
86
+ const row = await deps.db.query_one(`INSERT INTO permit_offer
87
+ (from_actor_id, to_account_id, role, scope_id, message, expires_at)
88
+ VALUES ($1, $2, $3, $4, $5, $6)
89
+ ON CONFLICT (to_account_id, role, COALESCE(scope_id, '${PERMIT_OFFER_SCOPE_SENTINEL_UUID}'::uuid), from_actor_id)
90
+ WHERE accepted_at IS NULL AND declined_at IS NULL AND retracted_at IS NULL AND superseded_at IS NULL
91
+ DO UPDATE SET
92
+ message = EXCLUDED.message,
93
+ expires_at = EXCLUDED.expires_at
94
+ RETURNING *`, [
95
+ input.from_actor_id,
96
+ input.to_account_id,
97
+ input.role,
98
+ input.scope_id ?? null,
99
+ input.message ?? null,
100
+ input.expires_at.toISOString(),
101
+ ]);
102
+ return assert_row(row, 'INSERT INTO permit_offer');
103
+ };
104
+ /**
105
+ * Mark an offer declined.
106
+ *
107
+ * Guarded by `to_account_id` (IDOR). Returns `null` if the offer does not
108
+ * exist or belongs to a different account. Throws
109
+ * `PermitOfferAlreadyTerminalError` if the offer exists for the caller but
110
+ * is already in a terminal state.
111
+ */
112
+ export const query_permit_offer_decline = async (deps, offer_id, to_account_id, reason) => {
113
+ const updated = await deps.db.query_one(`UPDATE permit_offer
114
+ SET declined_at = NOW(), decline_reason = $3
115
+ WHERE id = $1
116
+ AND to_account_id = $2
117
+ AND accepted_at IS NULL
118
+ AND declined_at IS NULL
119
+ AND retracted_at IS NULL
120
+ AND superseded_at IS NULL
121
+ RETURNING *`, [offer_id, to_account_id, reason ?? null]);
122
+ if (updated)
123
+ return updated;
124
+ return resolve_terminal_or_missing(deps, offer_id, { to_account_id });
125
+ };
126
+ /**
127
+ * Mark an offer retracted by the grantor.
128
+ *
129
+ * Guarded by `from_actor_id` (IDOR). Returns `null` if the offer does not
130
+ * exist or was issued by a different actor. Throws
131
+ * `PermitOfferAlreadyTerminalError` if the offer exists for this grantor
132
+ * but is already in a terminal state.
133
+ */
134
+ export const query_permit_offer_retract = async (deps, offer_id, from_actor_id) => {
135
+ const updated = await deps.db.query_one(`UPDATE permit_offer
136
+ SET retracted_at = NOW()
137
+ WHERE id = $1
138
+ AND from_actor_id = $2
139
+ AND accepted_at IS NULL
140
+ AND declined_at IS NULL
141
+ AND retracted_at IS NULL
142
+ AND superseded_at IS NULL
143
+ RETURNING *`, [offer_id, from_actor_id]);
144
+ if (updated)
145
+ return updated;
146
+ return resolve_terminal_or_missing(deps, offer_id, { from_actor_id });
147
+ };
148
+ /** Helper: distinguish "not found / different owner" from "already terminal". */
149
+ const resolve_terminal_or_missing = async (deps, offer_id, scope) => {
150
+ const conditions = ['id = $1'];
151
+ const params = [offer_id];
152
+ let idx = 2;
153
+ if (scope.to_account_id) {
154
+ conditions.push(`to_account_id = $${idx++}`);
155
+ params.push(scope.to_account_id);
156
+ }
157
+ if (scope.from_actor_id) {
158
+ conditions.push(`from_actor_id = $${idx++}`);
159
+ params.push(scope.from_actor_id);
160
+ }
161
+ const row = await deps.db.query_one(`SELECT * FROM permit_offer WHERE ${conditions.join(' AND ')}`, params);
162
+ if (!row)
163
+ return null;
164
+ if (row.accepted_at || row.declined_at || row.retracted_at || row.superseded_at) {
165
+ throw new PermitOfferAlreadyTerminalError(offer_id);
166
+ }
167
+ return null;
168
+ };
169
+ /**
170
+ * List pending, non-expired offers for an account, soonest expiry first.
171
+ *
172
+ * Expired offers are filtered server-side (`expires_at > NOW()`) so the
173
+ * inbox never surfaces a row that can no longer be accepted. The periodic
174
+ * sweep (`query_permit_offer_sweep_expired`) handles audit tombstoning.
175
+ */
176
+ export const query_permit_offer_list = async (deps, to_account_id) => {
177
+ return deps.db.query(`SELECT * FROM permit_offer
178
+ WHERE to_account_id = $1
179
+ AND accepted_at IS NULL
180
+ AND declined_at IS NULL
181
+ AND retracted_at IS NULL
182
+ AND superseded_at IS NULL
183
+ AND expires_at > NOW()
184
+ ORDER BY expires_at ASC`, [to_account_id]);
185
+ };
186
+ /**
187
+ * List every offer involving an account (either direction), newest first.
188
+ *
189
+ * Includes terminal offers — used by the grantor-side admin / history view.
190
+ */
191
+ export const query_permit_offer_history_for_account = async (deps, account_id, limit = 100, offset = 0) => {
192
+ return deps.db.query(`SELECT o.* FROM permit_offer o
193
+ LEFT JOIN actor a ON a.id = o.from_actor_id
194
+ WHERE o.to_account_id = $1 OR a.account_id = $1
195
+ ORDER BY o.created_at DESC
196
+ LIMIT $2 OFFSET $3`, [account_id, limit, offset]);
197
+ };
198
+ /**
199
+ * Look up a pending offer by id. Returns `null` if the offer is terminal,
200
+ * expired (server-side filter), or missing.
201
+ */
202
+ export const query_permit_offer_find_pending = async (deps, offer_id) => {
203
+ const row = await deps.db.query_one(`SELECT * FROM permit_offer
204
+ WHERE id = $1
205
+ AND accepted_at IS NULL
206
+ AND declined_at IS NULL
207
+ AND retracted_at IS NULL
208
+ AND superseded_at IS NULL
209
+ AND expires_at > NOW()`, [offer_id]);
210
+ return row ?? null;
211
+ };
212
+ /**
213
+ * Return pending offers whose `expires_at` has passed.
214
+ *
215
+ * Callers fire `permit_offer_expire` audit events for each row. The schema
216
+ * does not tombstone the row, so callers are responsible for their own
217
+ * idempotency (e.g. check whether a `permit_offer_expire` audit event
218
+ * already exists for the offer id).
219
+ */
220
+ export const query_permit_offer_sweep_expired = async (deps) => {
221
+ return deps.db.query(`SELECT * FROM permit_offer
222
+ WHERE accepted_at IS NULL
223
+ AND declined_at IS NULL
224
+ AND retracted_at IS NULL
225
+ AND superseded_at IS NULL
226
+ AND expires_at <= NOW()
227
+ ORDER BY expires_at ASC`);
228
+ };
229
+ /**
230
+ * Accept an offer atomically: mark accepted, insert the permit, stamp
231
+ * `resulting_permit_id`, supersede sibling pending offers for the same
232
+ * `(to_account, role, scope)`, and emit `permit_offer_accept` +
233
+ * `permit_grant` + one `permit_offer_supersede` per sibling. Must run
234
+ * inside a transaction — the caller's route spec should declare
235
+ * `transaction: true` (or wrap explicitly).
236
+ *
237
+ * Idempotent on race: if a second concurrent call observes the offer
238
+ * already accepted, returns the existing permit rather than creating a
239
+ * duplicate or throwing.
240
+ *
241
+ * Error map:
242
+ * - `PermitOfferNotFoundError` — offer does not exist, or belongs to a
243
+ * different recipient (IDOR guard). The offer row is untouched.
244
+ * - `PermitOfferAlreadyTerminalError` — offer is declined, retracted, or
245
+ * superseded.
246
+ * - `PermitOfferExpiredError` — offer is pending but past `expires_at`.
247
+ *
248
+ * Sibling supersede is what closes the "accept a pre-revoke sibling offer
249
+ * to bypass a revoke" path: once A is accepted, B/C/... can no longer be
250
+ * accepted even if the resulting permit is later revoked.
251
+ */
252
+ export const query_accept_offer = async (deps, input) => {
253
+ const { offer_id, to_account_id, ip } = input;
254
+ // Claim the offer with a row-level lock. Subsequent concurrent callers
255
+ // block on the lock until this transaction commits/rolls back; after commit
256
+ // they see the new state (accepted or terminal) and branch idempotently.
257
+ // We defer writing `accepted_at` until the permit row exists — the
258
+ // `permit_offer_permit_iff_accepted` CHECK constraint demands both be set
259
+ // (or neither) at row-visibility time.
260
+ const locked = await deps.db.query_one(`SELECT * FROM permit_offer
261
+ WHERE id = $1 AND to_account_id = $2
262
+ FOR UPDATE`, [offer_id, to_account_id]);
263
+ if (!locked) {
264
+ throw new PermitOfferNotFoundError(offer_id);
265
+ }
266
+ if (locked.accepted_at) {
267
+ // Race winner already committed; return the pre-existing permit.
268
+ // `permit_offer_permit_iff_accepted` CHECK guarantees resulting_permit_id is non-null.
269
+ const permit = await deps.db.query_one(`SELECT * FROM permit WHERE id = $1`, [
270
+ locked.resulting_permit_id,
271
+ ]);
272
+ return {
273
+ permit: assert_row(permit, 'resulting_permit lookup'),
274
+ offer: locked,
275
+ created: false,
276
+ superseded_offers: [],
277
+ audit_events: [],
278
+ };
279
+ }
280
+ if (locked.declined_at || locked.retracted_at || locked.superseded_at) {
281
+ throw new PermitOfferAlreadyTerminalError(offer_id);
282
+ }
283
+ // Expiry check AFTER the accepted-path: a validly-accepted offer past its
284
+ // expires_at still returns the permit idempotently. Only pending offers
285
+ // past expiry reach this branch.
286
+ if (new Date(locked.expires_at) <= new Date()) {
287
+ throw new PermitOfferExpiredError(offer_id);
288
+ }
289
+ // Resolve the accepting actor (1:1 account→actor in v1).
290
+ const actor = await query_actor_by_account(deps, to_account_id);
291
+ if (!actor) {
292
+ throw new Error(`No actor for account ${to_account_id} accepting offer ${offer_id}`);
293
+ }
294
+ // Insert the permit. Uses the normal grant idempotency — if another
295
+ // code path already granted the same (actor, role, scope), reuse it.
296
+ const granted_permit = await deps.db.query_one(`INSERT INTO permit (actor_id, role, scope_id, granted_by, source_offer_id)
297
+ VALUES ($1, $2, $3, $4, $5)
298
+ ON CONFLICT (actor_id, role, COALESCE(scope_id, '${PERMIT_OFFER_SCOPE_SENTINEL_UUID}'::uuid))
299
+ WHERE revoked_at IS NULL
300
+ DO NOTHING
301
+ RETURNING *`, [actor.id, locked.role, locked.scope_id, locked.from_actor_id, locked.id]);
302
+ let permit;
303
+ if (granted_permit) {
304
+ permit = granted_permit;
305
+ }
306
+ else {
307
+ const existing = await deps.db.query_one(`SELECT * FROM permit
308
+ WHERE actor_id = $1
309
+ AND role = $2
310
+ AND scope_id IS NOT DISTINCT FROM $3
311
+ AND revoked_at IS NULL`, [actor.id, locked.role, locked.scope_id]);
312
+ permit = assert_row(existing, 'query_accept_offer idempotent permit lookup');
313
+ }
314
+ // Single UPDATE sets both sides of the CHECK constraint at once.
315
+ const offer_accepted = await deps.db.query_one(`UPDATE permit_offer
316
+ SET accepted_at = NOW(), resulting_permit_id = $2
317
+ WHERE id = $1
318
+ RETURNING *`, [locked.id, permit.id]);
319
+ const offer = assert_row(offer_accepted, 'mark offer accepted');
320
+ // Supersede sibling pending offers for the same (to_account, role, scope).
321
+ // Forecloses the "accept this other sibling later to get the role back
322
+ // after a revoke" path — any pending offer for this tuple at accept time
323
+ // is obsoleted by the accept. CTE joins `actor` to surface each sibling's
324
+ // grantor `account_id` for the caller's notification fan-out.
325
+ const superseded = await deps.db.query(`WITH updated AS (
326
+ UPDATE permit_offer
327
+ SET superseded_at = NOW()
328
+ WHERE to_account_id = $1
329
+ AND role = $2
330
+ AND scope_id IS NOT DISTINCT FROM $3
331
+ AND id <> $4
332
+ AND accepted_at IS NULL
333
+ AND declined_at IS NULL
334
+ AND retracted_at IS NULL
335
+ AND superseded_at IS NULL
336
+ RETURNING *
337
+ )
338
+ SELECT u.*, grantor.account_id AS from_account_id
339
+ FROM updated u
340
+ JOIN actor grantor ON grantor.id = u.from_actor_id`, [to_account_id, offer.role, offer.scope_id, offer.id]);
341
+ // Emit audit events in-transaction (atomic with the permit insert).
342
+ // `RETURNING *` after the SET guarantees `offer.resulting_permit_id === permit.id`.
343
+ const offer_accept_event = await query_audit_log(deps, {
344
+ event_type: 'permit_offer_accept',
345
+ actor_id: actor.id,
346
+ account_id: to_account_id,
347
+ ip: ip ?? null,
348
+ metadata: {
349
+ offer_id: offer.id,
350
+ permit_id: permit.id,
351
+ role: offer.role,
352
+ scope_id: offer.scope_id,
353
+ },
354
+ });
355
+ const permit_grant_event = await query_audit_log(deps, {
356
+ event_type: 'permit_grant',
357
+ actor_id: actor.id,
358
+ account_id: to_account_id,
359
+ ip: ip ?? null,
360
+ metadata: {
361
+ role: offer.role,
362
+ permit_id: permit.id,
363
+ scope_id: offer.scope_id,
364
+ source_offer_id: offer.id,
365
+ },
366
+ });
367
+ const supersede_events = [];
368
+ for (const sibling of superseded) {
369
+ supersede_events.push(await query_audit_log(deps, {
370
+ event_type: 'permit_offer_supersede',
371
+ actor_id: actor.id,
372
+ account_id: to_account_id,
373
+ ip: ip ?? null,
374
+ metadata: {
375
+ offer_id: sibling.id,
376
+ role: sibling.role,
377
+ scope_id: sibling.scope_id,
378
+ reason: 'sibling_accepted',
379
+ cause_id: offer.id,
380
+ },
381
+ }));
382
+ }
383
+ return {
384
+ permit,
385
+ offer,
386
+ created: true,
387
+ superseded_offers: superseded,
388
+ audit_events: [offer_accept_event, permit_grant_event, ...supersede_events],
389
+ };
390
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Permit offer DDL, types, and client-safe schemas.
3
+ *
4
+ * An offer is a pending grant awaiting recipient consent. Lifecycle states
5
+ * are mutually exclusive via a CHECK constraint (`permit_offer_single_terminal`):
6
+ * at most one of `accepted_at` / `declined_at` / `retracted_at` may be set.
7
+ * On accept, the offer's `resulting_permit_id` links to the permit row
8
+ * produced by `query_accept_offer`.
9
+ *
10
+ * @module
11
+ */
12
+ import { z } from 'zod';
13
+ import { Uuid } from '../uuid.js';
14
+ /** Sentinel UUID used inside the partial unique indexes to collapse `scope_id IS NULL` into a comparable value. */
15
+ export declare const PERMIT_OFFER_SCOPE_SENTINEL_UUID = "00000000-0000-0000-0000-000000000000";
16
+ /** Maximum length of the optional message attached to an offer. */
17
+ export declare const PERMIT_OFFER_MESSAGE_LENGTH_MAX = 500;
18
+ /** Default TTL for a newly created offer — 30 days. Matches GitHub org-invite expiry. */
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)";
21
+ /**
22
+ * At most one pending offer per (to_account, role, scope, from_actor).
23
+ *
24
+ * Including `from_actor_id` in the tuple lets multiple grantors coexist —
25
+ * teacher A and teacher B can each have a pending `classroom_student` offer
26
+ * for the same student and scope. A same-grantor re-offer upserts the
27
+ * existing pending row. `COALESCE` collapses `NULL` scopes into the
28
+ * sentinel UUID so Postgres's NULL-in-unique-index quirk does not allow
29
+ * duplicate global pending offers. The ON CONFLICT target in
30
+ * `query_permit_offer_create` must match this expression literally.
31
+ */
32
+ export declare const PERMIT_OFFER_PENDING_UNIQUE_INDEX = "\nCREATE UNIQUE INDEX IF NOT EXISTS permit_offer_pending_unique\n ON permit_offer (\n to_account_id,\n role,\n COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'::uuid),\n from_actor_id\n )\n WHERE accepted_at IS NULL\n AND declined_at IS NULL\n AND retracted_at IS NULL\n AND superseded_at IS NULL";
33
+ /** Inbox lookup — pending offers for an account, ordered by soonest expiry. */
34
+ export declare const PERMIT_OFFER_INBOX_INDEX = "\nCREATE INDEX IF NOT EXISTS permit_offer_inbox\n ON permit_offer (to_account_id, expires_at)\n WHERE accepted_at IS NULL\n AND declined_at IS NULL\n AND retracted_at IS NULL\n AND superseded_at IS NULL";
35
+ /** Permit offer row as returned by the database. */
36
+ export interface PermitOffer {
37
+ id: Uuid;
38
+ from_actor_id: Uuid;
39
+ to_account_id: Uuid;
40
+ role: string;
41
+ scope_id: Uuid | null;
42
+ message: string | null;
43
+ created_at: string;
44
+ expires_at: string;
45
+ accepted_at: string | null;
46
+ declined_at: string | null;
47
+ decline_reason: string | null;
48
+ retracted_at: string | null;
49
+ /**
50
+ * Set when the offer was obsoleted by an external event — a sibling
51
+ * offer was accepted (yielding the permit this offer's role+scope maps to)
52
+ * or the resulting permit for this (to_account, role, scope) was revoked.
53
+ * Closes the "accept a pre-revoke offer to bypass the revoke" path.
54
+ */
55
+ superseded_at: string | null;
56
+ resulting_permit_id: Uuid | null;
57
+ }
58
+ /**
59
+ * A superseded offer row annotated with the grantor's `account_id`.
60
+ *
61
+ * Carried by `superseded_offers` in accept/revoke query results so callers
62
+ * can fan out `permit_offer_supersede` notifications to the grantor's
63
+ * sockets without a second round-trip. Populated via a CTE join on `actor`
64
+ * in the supersede UPDATE.
65
+ */
66
+ export interface SupersededOffer extends PermitOffer {
67
+ from_account_id: Uuid;
68
+ }
69
+ /**
70
+ * Input for `query_permit_offer_create`.
71
+ *
72
+ * `expires_at` must be supplied — the query layer does not apply a default,
73
+ * so callers can thread their own TTL (typically `PERMIT_OFFER_DEFAULT_TTL_MS`).
74
+ */
75
+ export interface CreatePermitOfferInput {
76
+ from_actor_id: Uuid;
77
+ to_account_id: Uuid;
78
+ role: string;
79
+ scope_id?: Uuid | null;
80
+ message?: string | null;
81
+ expires_at: Date;
82
+ }
83
+ /** Zod schema for client-safe permit offer data. */
84
+ export declare const PermitOfferJson: z.ZodObject<{
85
+ id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
86
+ from_actor_id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
87
+ to_account_id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
88
+ role: z.ZodString;
89
+ scope_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
90
+ message: z.ZodNullable<z.ZodString>;
91
+ created_at: z.ZodString;
92
+ expires_at: z.ZodString;
93
+ accepted_at: z.ZodNullable<z.ZodString>;
94
+ declined_at: z.ZodNullable<z.ZodString>;
95
+ decline_reason: z.ZodNullable<z.ZodString>;
96
+ retracted_at: z.ZodNullable<z.ZodString>;
97
+ superseded_at: z.ZodNullable<z.ZodString>;
98
+ resulting_permit_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
99
+ }, z.core.$strict>;
100
+ export type PermitOfferJson = z.infer<typeof PermitOfferJson>;
101
+ /** Convert a `PermitOffer` row to its JSON payload shape. */
102
+ export declare const to_permit_offer_json: (offer: PermitOffer) => PermitOfferJson;
103
+ //# sourceMappingURL=permit_offer_schema.d.ts.map
@@ -0,0 +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;AAEtB,OAAO,EAAC,IAAI,EAAC,MAAM,YAAY,CAAC;AAGhC,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"}