@fuzdev/fuz_app 0.29.0 → 0.31.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 (210) 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/socket.svelte.d.ts +16 -16
  18. package/dist/actions/socket.svelte.d.ts.map +1 -1
  19. package/dist/actions/socket.svelte.js +15 -15
  20. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  21. package/dist/actions/transports_ws_backend.d.ts +15 -0
  22. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  23. package/dist/actions/transports_ws_backend.js +17 -0
  24. package/dist/auth/CLAUDE.md +923 -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/api_token.d.ts +10 -0
  47. package/dist/auth/api_token.d.ts.map +1 -1
  48. package/dist/auth/api_token.js +9 -0
  49. package/dist/auth/api_token_queries.d.ts +3 -3
  50. package/dist/auth/api_token_queries.js +3 -3
  51. package/dist/auth/app_settings_schema.d.ts +4 -3
  52. package/dist/auth/app_settings_schema.d.ts.map +1 -1
  53. package/dist/auth/app_settings_schema.js +2 -1
  54. package/dist/auth/audit_log_routes.d.ts +14 -6
  55. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  56. package/dist/auth/audit_log_routes.js +22 -79
  57. package/dist/auth/audit_log_schema.d.ts +100 -29
  58. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  59. package/dist/auth/audit_log_schema.js +83 -11
  60. package/dist/auth/bootstrap_routes.d.ts +14 -0
  61. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  62. package/dist/auth/bootstrap_routes.js +10 -3
  63. package/dist/auth/cleanup.d.ts +63 -0
  64. package/dist/auth/cleanup.d.ts.map +1 -0
  65. package/dist/auth/cleanup.js +80 -0
  66. package/dist/auth/invite_schema.d.ts +11 -10
  67. package/dist/auth/invite_schema.d.ts.map +1 -1
  68. package/dist/auth/invite_schema.js +4 -3
  69. package/dist/auth/migrations.d.ts +6 -0
  70. package/dist/auth/migrations.d.ts.map +1 -1
  71. package/dist/auth/migrations.js +28 -0
  72. package/dist/auth/permit_offer_action_specs.d.ts +364 -0
  73. package/dist/auth/permit_offer_action_specs.d.ts.map +1 -0
  74. package/dist/auth/permit_offer_action_specs.js +216 -0
  75. package/dist/auth/permit_offer_actions.d.ts +96 -0
  76. package/dist/auth/permit_offer_actions.d.ts.map +1 -0
  77. package/dist/auth/permit_offer_actions.js +428 -0
  78. package/dist/auth/permit_offer_notifications.d.ts +361 -0
  79. package/dist/auth/permit_offer_notifications.d.ts.map +1 -0
  80. package/dist/auth/permit_offer_notifications.js +179 -0
  81. package/dist/auth/permit_offer_queries.d.ts +165 -0
  82. package/dist/auth/permit_offer_queries.d.ts.map +1 -0
  83. package/dist/auth/permit_offer_queries.js +390 -0
  84. package/dist/auth/permit_offer_schema.d.ts +103 -0
  85. package/dist/auth/permit_offer_schema.d.ts.map +1 -0
  86. package/dist/auth/permit_offer_schema.js +142 -0
  87. package/dist/auth/permit_queries.d.ts +77 -14
  88. package/dist/auth/permit_queries.d.ts.map +1 -1
  89. package/dist/auth/permit_queries.js +119 -24
  90. package/dist/auth/session_queries.d.ts +4 -2
  91. package/dist/auth/session_queries.d.ts.map +1 -1
  92. package/dist/auth/session_queries.js +4 -2
  93. package/dist/auth/signup_routes.d.ts +13 -0
  94. package/dist/auth/signup_routes.d.ts.map +1 -1
  95. package/dist/auth/signup_routes.js +14 -7
  96. package/dist/http/CLAUDE.md +584 -0
  97. package/dist/http/pending_effects.d.ts +29 -0
  98. package/dist/http/pending_effects.d.ts.map +1 -0
  99. package/dist/http/pending_effects.js +31 -0
  100. package/dist/http/route_spec.d.ts.map +1 -1
  101. package/dist/http/route_spec.js +4 -3
  102. package/dist/rate_limiter.d.ts +30 -0
  103. package/dist/rate_limiter.d.ts.map +1 -1
  104. package/dist/rate_limiter.js +25 -2
  105. package/dist/realtime/sse_auth_guard.d.ts +2 -0
  106. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  107. package/dist/realtime/sse_auth_guard.js +5 -3
  108. package/dist/testing/CLAUDE.md +668 -1
  109. package/dist/testing/admin_integration.d.ts +10 -7
  110. package/dist/testing/admin_integration.d.ts.map +1 -1
  111. package/dist/testing/admin_integration.js +382 -482
  112. package/dist/testing/app_server.d.ts +7 -6
  113. package/dist/testing/app_server.d.ts.map +1 -1
  114. package/dist/testing/attack_surface.d.ts +9 -3
  115. package/dist/testing/attack_surface.d.ts.map +1 -1
  116. package/dist/testing/attack_surface.js +4 -4
  117. package/dist/testing/audit_completeness.d.ts +6 -0
  118. package/dist/testing/audit_completeness.d.ts.map +1 -1
  119. package/dist/testing/audit_completeness.js +158 -134
  120. package/dist/testing/auth_apps.d.ts.map +1 -1
  121. package/dist/testing/auth_apps.js +4 -33
  122. package/dist/testing/db.d.ts +1 -1
  123. package/dist/testing/db.d.ts.map +1 -1
  124. package/dist/testing/db.js +2 -0
  125. package/dist/testing/entities.d.ts +35 -13
  126. package/dist/testing/entities.d.ts.map +1 -1
  127. package/dist/testing/entities.js +17 -0
  128. package/dist/testing/integration.d.ts +10 -0
  129. package/dist/testing/integration.d.ts.map +1 -1
  130. package/dist/testing/integration.js +352 -340
  131. package/dist/testing/integration_helpers.d.ts +16 -5
  132. package/dist/testing/integration_helpers.d.ts.map +1 -1
  133. package/dist/testing/integration_helpers.js +24 -4
  134. package/dist/testing/rate_limiting.d.ts +7 -0
  135. package/dist/testing/rate_limiting.d.ts.map +1 -1
  136. package/dist/testing/rate_limiting.js +41 -10
  137. package/dist/testing/rpc_helpers.d.ts +153 -1
  138. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  139. package/dist/testing/rpc_helpers.js +184 -8
  140. package/dist/testing/sse_round_trip.d.ts +8 -0
  141. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  142. package/dist/testing/sse_round_trip.js +10 -3
  143. package/dist/testing/standard.d.ts +9 -1
  144. package/dist/testing/standard.d.ts.map +1 -1
  145. package/dist/testing/standard.js +6 -2
  146. package/dist/testing/surface_invariants.d.ts +7 -3
  147. package/dist/testing/surface_invariants.d.ts.map +1 -1
  148. package/dist/testing/surface_invariants.js +5 -4
  149. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  150. package/dist/testing/ws_round_trip.js +9 -38
  151. package/dist/ui/AccountSessions.svelte +8 -4
  152. package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
  153. package/dist/ui/AdminAccounts.svelte +61 -33
  154. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  155. package/dist/ui/AdminAuditLog.svelte +3 -2
  156. package/dist/ui/AdminAuditLog.svelte.d.ts.map +1 -1
  157. package/dist/ui/AdminInvites.svelte +3 -2
  158. package/dist/ui/AdminInvites.svelte.d.ts.map +1 -1
  159. package/dist/ui/AdminOverview.svelte +14 -9
  160. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  161. package/dist/ui/AdminPermitHistory.svelte +3 -2
  162. package/dist/ui/AdminPermitHistory.svelte.d.ts.map +1 -1
  163. package/dist/ui/AdminSessions.svelte +29 -25
  164. package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
  165. package/dist/ui/CLAUDE.md +351 -0
  166. package/dist/ui/OpenSignupToggle.svelte +6 -3
  167. package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
  168. package/dist/ui/PermitOfferForm.svelte +141 -0
  169. package/dist/ui/PermitOfferForm.svelte.d.ts +14 -0
  170. package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -0
  171. package/dist/ui/PermitOfferHistory.svelte +109 -0
  172. package/dist/ui/PermitOfferHistory.svelte.d.ts +11 -0
  173. package/dist/ui/PermitOfferHistory.svelte.d.ts.map +1 -0
  174. package/dist/ui/PermitOfferInbox.svelte +121 -0
  175. package/dist/ui/PermitOfferInbox.svelte.d.ts +12 -0
  176. package/dist/ui/PermitOfferInbox.svelte.d.ts.map +1 -0
  177. package/dist/ui/account_sessions_state.svelte.d.ts +53 -3
  178. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  179. package/dist/ui/account_sessions_state.svelte.js +39 -16
  180. package/dist/ui/admin_accounts_state.svelte.d.ts +118 -2
  181. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  182. package/dist/ui/admin_accounts_state.svelte.js +99 -23
  183. package/dist/ui/admin_invites_state.svelte.d.ts +47 -1
  184. package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
  185. package/dist/ui/admin_invites_state.svelte.js +38 -26
  186. package/dist/ui/admin_sessions_state.svelte.d.ts +26 -0
  187. package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
  188. package/dist/ui/admin_sessions_state.svelte.js +35 -21
  189. package/dist/ui/app_settings_state.svelte.d.ts +39 -0
  190. package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
  191. package/dist/ui/app_settings_state.svelte.js +34 -18
  192. package/dist/ui/audit_log_state.svelte.d.ts +40 -3
  193. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  194. package/dist/ui/audit_log_state.svelte.js +36 -42
  195. package/dist/ui/auth_state.svelte.d.ts +4 -3
  196. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  197. package/dist/ui/auth_state.svelte.js +4 -1
  198. package/dist/ui/permit_offers_state.svelte.d.ts +125 -0
  199. package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -0
  200. package/dist/ui/permit_offers_state.svelte.js +197 -0
  201. package/package.json +3 -3
  202. package/dist/auth/admin_routes.d.ts +0 -29
  203. package/dist/auth/admin_routes.d.ts.map +0 -1
  204. package/dist/auth/admin_routes.js +0 -226
  205. package/dist/auth/app_settings_routes.d.ts +0 -27
  206. package/dist/auth/app_settings_routes.d.ts.map +0 -1
  207. package/dist/auth/app_settings_routes.js +0 -66
  208. package/dist/auth/invite_routes.d.ts +0 -18
  209. package/dist/auth/invite_routes.d.ts.map +0 -1
  210. package/dist/auth/invite_routes.js +0 -129
@@ -0,0 +1,142 @@
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
+ import { RoleName } from './role_schema.js';
15
+ /** Sentinel UUID used inside the partial unique indexes to collapse `scope_id IS NULL` into a comparable value. */
16
+ export const PERMIT_OFFER_SCOPE_SENTINEL_UUID = '00000000-0000-0000-0000-000000000000';
17
+ /** Maximum length of the optional message attached to an offer. */
18
+ export const PERMIT_OFFER_MESSAGE_LENGTH_MAX = 500;
19
+ /** Default TTL for a newly created offer — 30 days. Matches GitHub org-invite expiry. */
20
+ export const PERMIT_OFFER_DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
21
+ export const PERMIT_OFFER_SCHEMA = `
22
+ CREATE TABLE IF NOT EXISTS permit_offer (
23
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
24
+ from_actor_id UUID NOT NULL REFERENCES actor(id) ON DELETE CASCADE,
25
+ to_account_id UUID NOT NULL REFERENCES account(id) ON DELETE CASCADE,
26
+ role TEXT NOT NULL,
27
+ scope_id UUID NULL,
28
+ message TEXT NULL,
29
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
30
+ expires_at TIMESTAMPTZ NOT NULL,
31
+ accepted_at TIMESTAMPTZ NULL,
32
+ declined_at TIMESTAMPTZ NULL,
33
+ decline_reason TEXT NULL,
34
+ retracted_at TIMESTAMPTZ NULL,
35
+ superseded_at TIMESTAMPTZ NULL,
36
+ resulting_permit_id UUID NULL REFERENCES permit(id) ON DELETE SET NULL,
37
+ CONSTRAINT permit_offer_single_terminal CHECK (
38
+ (accepted_at IS NOT NULL)::int
39
+ + (declined_at IS NOT NULL)::int
40
+ + (retracted_at IS NOT NULL)::int
41
+ + (superseded_at IS NOT NULL)::int
42
+ <= 1
43
+ ),
44
+ CONSTRAINT permit_offer_permit_iff_accepted CHECK (
45
+ (accepted_at IS NOT NULL) = (resulting_permit_id IS NOT NULL)
46
+ ),
47
+ CONSTRAINT permit_offer_reason_iff_declined CHECK (
48
+ decline_reason IS NULL OR declined_at IS NOT NULL
49
+ )
50
+ )`;
51
+ /**
52
+ * At most one pending offer per (to_account, role, scope, from_actor).
53
+ *
54
+ * Including `from_actor_id` in the tuple lets multiple grantors coexist —
55
+ * teacher A and teacher B can each have a pending `classroom_student` offer
56
+ * for the same student and scope. A same-grantor re-offer upserts the
57
+ * existing pending row. `COALESCE` collapses `NULL` scopes into the
58
+ * sentinel UUID so Postgres's NULL-in-unique-index quirk does not allow
59
+ * duplicate global pending offers. The ON CONFLICT target in
60
+ * `query_permit_offer_create` must match this expression literally.
61
+ */
62
+ export const PERMIT_OFFER_PENDING_UNIQUE_INDEX = `
63
+ CREATE UNIQUE INDEX IF NOT EXISTS permit_offer_pending_unique
64
+ ON permit_offer (
65
+ to_account_id,
66
+ role,
67
+ COALESCE(scope_id, '${PERMIT_OFFER_SCOPE_SENTINEL_UUID}'::uuid),
68
+ from_actor_id
69
+ )
70
+ WHERE accepted_at IS NULL
71
+ AND declined_at IS NULL
72
+ AND retracted_at IS NULL
73
+ AND superseded_at IS NULL`;
74
+ /** Inbox lookup — pending offers for an account, ordered by soonest expiry. */
75
+ export const PERMIT_OFFER_INBOX_INDEX = `
76
+ CREATE INDEX IF NOT EXISTS permit_offer_inbox
77
+ ON permit_offer (to_account_id, expires_at)
78
+ WHERE accepted_at IS NULL
79
+ AND declined_at IS NULL
80
+ AND retracted_at IS NULL
81
+ AND superseded_at IS NULL`;
82
+ /** Zod schema for client-safe permit offer data. */
83
+ export const PermitOfferJson = z
84
+ .strictObject({
85
+ id: Uuid.meta({ description: 'Offer id.' }),
86
+ from_actor_id: Uuid.meta({ description: 'Actor that issued the offer.' }),
87
+ to_account_id: Uuid.meta({ description: 'Account the offer is directed to.' }),
88
+ role: RoleName.meta({ description: 'Role being offered.' }),
89
+ scope_id: Uuid.nullable().meta({
90
+ description: 'Scope the offered permit applies to (e.g. a classroom id). `null` for global permits.',
91
+ }),
92
+ message: z
93
+ .string()
94
+ .max(PERMIT_OFFER_MESSAGE_LENGTH_MAX)
95
+ .nullable()
96
+ .meta({ description: 'Optional free-form note from the grantor.' }),
97
+ created_at: z.string().meta({ description: 'ISO timestamp when the offer was created.' }),
98
+ expires_at: z
99
+ .string()
100
+ .meta({ description: 'ISO timestamp after which the offer is no longer valid.' }),
101
+ accepted_at: z
102
+ .string()
103
+ .nullable()
104
+ .meta({ description: 'ISO timestamp when the offer was accepted.' }),
105
+ declined_at: z
106
+ .string()
107
+ .nullable()
108
+ .meta({ description: 'ISO timestamp when the offer was declined.' }),
109
+ decline_reason: z
110
+ .string()
111
+ .max(PERMIT_OFFER_MESSAGE_LENGTH_MAX)
112
+ .nullable()
113
+ .meta({ description: 'Optional reason given on decline.' }),
114
+ retracted_at: z
115
+ .string()
116
+ .nullable()
117
+ .meta({ description: 'ISO timestamp when the grantor retracted the offer.' }),
118
+ superseded_at: z.string().nullable().meta({
119
+ description: 'ISO timestamp when this offer was obsoleted by a sibling accept or by revoke of the resulting permit.',
120
+ }),
121
+ resulting_permit_id: Uuid.nullable().meta({
122
+ description: 'Permit produced by accepting this offer. `null` until/unless accepted.',
123
+ }),
124
+ })
125
+ .meta({ description: 'A permit offer — a pending grant awaiting recipient consent.' });
126
+ /** Convert a `PermitOffer` row to its JSON payload shape. */
127
+ export const to_permit_offer_json = (offer) => ({
128
+ id: offer.id,
129
+ from_actor_id: offer.from_actor_id,
130
+ to_account_id: offer.to_account_id,
131
+ role: offer.role,
132
+ scope_id: offer.scope_id,
133
+ message: offer.message,
134
+ created_at: offer.created_at,
135
+ expires_at: offer.expires_at,
136
+ accepted_at: offer.accepted_at,
137
+ declined_at: offer.declined_at,
138
+ decline_reason: offer.decline_reason,
139
+ retracted_at: offer.retracted_at,
140
+ superseded_at: offer.superseded_at,
141
+ resulting_permit_id: offer.resulting_permit_id,
142
+ });
@@ -7,11 +7,19 @@
7
7
  * @module
8
8
  */
9
9
  import type { QueryDeps } from '../db/query_deps.js';
10
+ import type { Uuid } from '../uuid.js';
10
11
  import type { Permit, GrantPermitInput } from './account_schema.js';
12
+ import { type SupersededOffer } from './permit_offer_schema.js';
11
13
  /**
12
14
  * Grant a permit to an actor.
13
- * Idempotent — if an active permit already exists for this actor and role,
14
- * returns the existing permit instead of creating a duplicate.
15
+ * Idempotent — if an active permit already exists for this actor, role, and
16
+ * scope, returns the existing permit instead of creating a duplicate.
17
+ *
18
+ * The `ON CONFLICT` target and the fallback `SELECT` both collapse `NULL`
19
+ * scopes via the same sentinel used by the partial unique index
20
+ * (`permit_actor_role_scope_active_unique`). The `IS NOT DISTINCT FROM`
21
+ * form on the fallback is deliberate — plain `=` would miss the
22
+ * NULL-scope case where the conflict fired.
15
23
  *
16
24
  * @param deps - query dependencies
17
25
  * @param input - the permit fields
@@ -37,30 +45,57 @@ export declare const query_grant_permit: (deps: QueryDeps, input: GrantPermitInp
37
45
  export declare const query_permit_find_active_role_for_actor: (deps: QueryDeps, permit_id: string, actor_id: string) => Promise<{
38
46
  role: string;
39
47
  } | null>;
48
+ /** Result of `query_revoke_permit` — the revoked permit plus any pending offers superseded by the revoke. */
49
+ export interface RevokePermitResult {
50
+ id: Uuid;
51
+ role: string;
52
+ scope_id: Uuid | null;
53
+ /**
54
+ * Pending offers for the revoked permit's `(account, role, scope)` that
55
+ * were marked superseded as a side effect. Each entry carries its
56
+ * grantor's `from_account_id` so callers can fan out
57
+ * `permit_offer_supersede` notifications without a second round-trip.
58
+ * The caller is responsible for emitting a `permit_offer_supersede`
59
+ * audit event per entry (with `reason: 'permit_revoked'` and
60
+ * `cause_id: <revoked permit id>`).
61
+ */
62
+ superseded_offers: Array<SupersededOffer>;
63
+ }
40
64
  /**
41
65
  * Revoke a permit by id, constrained to a specific actor.
42
66
  *
43
67
  * Requires `actor_id` to prevent cross-account revocation (IDOR guard).
44
68
  * Returns `null` if the permit is not found, already revoked, or belongs
45
- * to a different actor. Returns `{id, role}` on success for audit logging.
69
+ * to a different actor.
70
+ *
71
+ * Supersedes any pending offers for the revoked permit's
72
+ * `(to_account, role, scope)` in the same transaction. Prevents the
73
+ * "accept a pre-revoke offer to bypass the revoke" path — any stale
74
+ * offer becomes terminal at revoke time. A fresh post-revoke grant
75
+ * requires the grantor to call `query_permit_offer_create` again.
46
76
  *
47
77
  * @param deps - query dependencies
48
78
  * @param permit_id - the permit to revoke
49
79
  * @param actor_id - the actor that must own the permit
50
80
  * @param revoked_by - the actor who revoked it (for audit trail)
81
+ * @param reason - optional free-form reason, stamped on `permit.revoked_reason` and surfaced to the revokee notification.
51
82
  */
52
- export declare const query_revoke_permit: (deps: QueryDeps, permit_id: string, actor_id: string, revoked_by: string | null) => Promise<{
53
- id: string;
54
- role: string;
55
- } | null>;
83
+ export declare const query_revoke_permit: (deps: QueryDeps, permit_id: Uuid, actor_id: Uuid, revoked_by: Uuid | null, reason?: string | null) => Promise<RevokePermitResult | null>;
56
84
  /**
57
85
  * Find all active (non-revoked, non-expired) permits for an actor.
58
86
  */
59
87
  export declare const query_permit_find_active_for_actor: (deps: QueryDeps, actor_id: string) => Promise<Array<Permit>>;
60
88
  /**
61
89
  * Check if an actor has an active permit for a given role.
90
+ *
91
+ * The `scope_id` parameter selects between global and scoped checks:
92
+ * - Omitted or `null` — matches a global permit (`scope_id IS NULL`).
93
+ * Pre-scope callers keep their existing semantics.
94
+ * - A scope uuid — matches a permit bound to that exact scope.
95
+ *
96
+ * The `IS NOT DISTINCT FROM` comparison handles the NULL case uniformly.
62
97
  */
63
- export declare const query_permit_has_role: (deps: QueryDeps, actor_id: string, role: string) => Promise<boolean>;
98
+ export declare const query_permit_has_role: (deps: QueryDeps, actor_id: string, role: string, scope_id?: string | null) => Promise<boolean>;
64
99
  /**
65
100
  * List all permits for an actor (including revoked/expired).
66
101
  */
@@ -75,17 +110,45 @@ export declare const query_permit_list_for_actor: (deps: QueryDeps, actor_id: st
75
110
  * @returns the account ID, or `null`
76
111
  */
77
112
  export declare const query_permit_find_account_id_for_role: (deps: QueryDeps, role: string) => Promise<string | null>;
113
+ /** Result of `query_permit_revoke_role` — every permit revoked plus the pending offers superseded by the bulk revoke. */
114
+ export interface RevokeRoleResult {
115
+ /**
116
+ * One entry per permit revoked by this call. Carries the revokee's
117
+ * `account_id` so callers can fan out a `permit_revoke` notification per
118
+ * scope-instance. Empty array means nothing was active for `(actor, role)`.
119
+ */
120
+ revoked: Array<{
121
+ permit_id: string;
122
+ role: string;
123
+ scope_id: string | null;
124
+ account_id: string;
125
+ }>;
126
+ /**
127
+ * Pending offers for the actor's account+role (all scopes) superseded by
128
+ * the bulk revoke. Each entry carries its grantor's `from_account_id` so
129
+ * callers can fan out `permit_offer_supersede` notifications without a
130
+ * second round-trip.
131
+ */
132
+ superseded_offers: Array<SupersededOffer>;
133
+ }
78
134
  /**
79
- * Revoke the active permit for an actor with a given role.
135
+ * Revoke every active permit an actor holds for a given role.
136
+ *
137
+ * With scoped permits a single actor+role tuple can hold several active
138
+ * permits (one per scope), so this revokes all of them. Pass
139
+ * `query_revoke_permit(permit_id, ...)` when a single scoped permit
140
+ * is the target.
80
141
  *
81
- * Due to the unique partial index on `(actor_id, role) WHERE revoked_at IS NULL`,
82
- * at most one active permit exists per actor+role combination.
142
+ * Also supersedes pending offers for the actor's account across every
143
+ * scope of this role (the actor can no longer hold the role, so any
144
+ * pending offer of the same role is a bypass vector).
83
145
  *
84
146
  * @param deps - query dependencies
85
- * @param actor_id - the actor whose permit to revoke
147
+ * @param actor_id - the actor whose permits to revoke
86
148
  * @param role - the role to revoke
87
149
  * @param revoked_by - the actor who revoked it (for audit trail)
88
- * @returns `true` if a permit was revoked, `false` if none was active
150
+ * @param reason - optional free-form reason, stamped on `permit.revoked_reason`.
151
+ * @returns the list of revoked permits (empty if none were active) and superseded pending offers
89
152
  */
90
- export declare const query_permit_revoke_role: (deps: QueryDeps, actor_id: string, role: string, revoked_by: string | null) => Promise<boolean>;
153
+ export declare const query_permit_revoke_role: (deps: QueryDeps, actor_id: string, role: string, revoked_by: string | null, reason?: string | null) => Promise<RevokeRoleResult>;
91
154
  //# sourceMappingURL=permit_queries.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"permit_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAC,MAAM,EAAE,gBAAgB,EAAC,MAAM,qBAAqB,CAAC;AAGlE;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC9B,MAAM,SAAS,EACf,OAAO,gBAAgB,KACrB,OAAO,CAAC,MAAM,CAiBhB,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,uCAAuC,GACnD,MAAM,SAAS,EACf,WAAW,MAAM,EACjB,UAAU,MAAM,KACd,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,GAAG,IAAI,CAO/B,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB,GAC/B,MAAM,SAAS,EACf,WAAW,MAAM,EACjB,UAAU,MAAM,EAChB,YAAY,MAAM,GAAG,IAAI,KACvB,OAAO,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,GAAG,IAAI,CAQ3C,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,kCAAkC,GAC9C,MAAM,SAAS,EACf,UAAU,MAAM,KACd,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CASvB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,SAAS,EACf,UAAU,MAAM,EAChB,MAAM,MAAM,KACV,OAAO,CAAC,OAAO,CAYjB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,SAAS,EACf,UAAU,MAAM,KACd,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAKvB,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,qCAAqC,GACjD,MAAM,SAAS,EACf,MAAM,MAAM,KACV,OAAO,CAAC,MAAM,GAAG,IAAI,CAavB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,wBAAwB,GACpC,MAAM,SAAS,EACf,UAAU,MAAM,EAChB,MAAM,MAAM,EACZ,YAAY,MAAM,GAAG,IAAI,KACvB,OAAO,CAAC,OAAO,CAQjB,CAAC"}
1
+ {"version":3,"file":"permit_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,YAAY,CAAC;AACrC,OAAO,KAAK,EAAC,MAAM,EAAE,gBAAgB,EAAC,MAAM,qBAAqB,CAAC;AAElE,OAAO,EAAmC,KAAK,eAAe,EAAC,MAAM,0BAA0B,CAAC;AAEhG;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,kBAAkB,GAC9B,MAAM,SAAS,EACf,OAAO,gBAAgB,KACrB,OAAO,CAAC,MAAM,CA4BhB,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,uCAAuC,GACnD,MAAM,SAAS,EACf,WAAW,MAAM,EACjB,UAAU,MAAM,KACd,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,GAAG,IAAI,CAO/B,CAAC;AAEF,6GAA6G;AAC7G,MAAM,WAAW,kBAAkB;IAClC,EAAE,EAAE,IAAI,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IACtB;;;;;;;;OAQG;IACH,iBAAiB,EAAE,KAAK,CAAC,eAAe,CAAC,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,mBAAmB,GAC/B,MAAM,SAAS,EACf,WAAW,IAAI,EACf,UAAU,IAAI,EACd,YAAY,IAAI,GAAG,IAAI,EACvB,SAAS,MAAM,GAAG,IAAI,KACpB,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAsCnC,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,kCAAkC,GAC9C,MAAM,SAAS,EACf,UAAU,MAAM,KACd,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CASvB,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,SAAS,EACf,UAAU,MAAM,EAChB,MAAM,MAAM,EACZ,WAAW,MAAM,GAAG,IAAI,KACtB,OAAO,CAAC,OAAO,CAajB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,SAAS,EACf,UAAU,MAAM,KACd,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAKvB,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,qCAAqC,GACjD,MAAM,SAAS,EACf,MAAM,MAAM,KACV,OAAO,CAAC,MAAM,GAAG,IAAI,CAavB,CAAC;AAEF,yHAAyH;AACzH,MAAM,WAAW,gBAAgB;IAChC;;;;OAIG;IACH,OAAO,EAAE,KAAK,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAC,CAAC,CAAC;IAC/F;;;;;OAKG;IACH,iBAAiB,EAAE,KAAK,CAAC,eAAe,CAAC,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,wBAAwB,GACpC,MAAM,SAAS,EACf,UAAU,MAAM,EAChB,MAAM,MAAM,EACZ,YAAY,MAAM,GAAG,IAAI,EACzB,SAAS,MAAM,GAAG,IAAI,KACpB,OAAO,CAAC,gBAAgB,CA2C1B,CAAC"}
@@ -7,26 +7,44 @@
7
7
  * @module
8
8
  */
9
9
  import { assert_row } from '../db/assert_row.js';
10
+ import { PERMIT_OFFER_SCOPE_SENTINEL_UUID } from './permit_offer_schema.js';
10
11
  /**
11
12
  * Grant a permit to an actor.
12
- * Idempotent — if an active permit already exists for this actor and role,
13
- * returns the existing permit instead of creating a duplicate.
13
+ * Idempotent — if an active permit already exists for this actor, role, and
14
+ * scope, returns the existing permit instead of creating a duplicate.
15
+ *
16
+ * The `ON CONFLICT` target and the fallback `SELECT` both collapse `NULL`
17
+ * scopes via the same sentinel used by the partial unique index
18
+ * (`permit_actor_role_scope_active_unique`). The `IS NOT DISTINCT FROM`
19
+ * form on the fallback is deliberate — plain `=` would miss the
20
+ * NULL-scope case where the conflict fired.
14
21
  *
15
22
  * @param deps - query dependencies
16
23
  * @param input - the permit fields
17
24
  * @returns the created or existing active permit
18
25
  */
19
26
  export const query_grant_permit = async (deps, input) => {
20
- const inserted = await deps.db.query_one(`INSERT INTO permit (actor_id, role, expires_at, granted_by)
21
- VALUES ($1, $2, $3, $4)
22
- ON CONFLICT (actor_id, role) WHERE revoked_at IS NULL
27
+ const inserted = await deps.db.query_one(`INSERT INTO permit (actor_id, role, scope_id, expires_at, granted_by, source_offer_id)
28
+ VALUES ($1, $2, $3, $4, $5, $6)
29
+ ON CONFLICT (actor_id, role, COALESCE(scope_id, '${PERMIT_OFFER_SCOPE_SENTINEL_UUID}'::uuid))
30
+ WHERE revoked_at IS NULL
23
31
  DO NOTHING
24
- RETURNING *`, [input.actor_id, input.role, input.expires_at?.toISOString() ?? null, input.granted_by ?? null]);
32
+ RETURNING *`, [
33
+ input.actor_id,
34
+ input.role,
35
+ input.scope_id ?? null,
36
+ input.expires_at?.toISOString() ?? null,
37
+ input.granted_by ?? null,
38
+ input.source_offer_id ?? null,
39
+ ]);
25
40
  if (inserted)
26
41
  return inserted;
27
42
  // Active permit already exists — return it (idempotent grant).
28
43
  const existing = await deps.db.query_one(`SELECT * FROM permit
29
- WHERE actor_id = $1 AND role = $2 AND revoked_at IS NULL`, [input.actor_id, input.role]);
44
+ WHERE actor_id = $1
45
+ AND role = $2
46
+ AND scope_id IS NOT DISTINCT FROM $3
47
+ AND revoked_at IS NULL`, [input.actor_id, input.role, input.scope_id ?? null]);
30
48
  return assert_row(existing, 'idempotent permit grant');
31
49
  };
32
50
  /**
@@ -55,18 +73,53 @@ export const query_permit_find_active_role_for_actor = async (deps, permit_id, a
55
73
  *
56
74
  * Requires `actor_id` to prevent cross-account revocation (IDOR guard).
57
75
  * Returns `null` if the permit is not found, already revoked, or belongs
58
- * to a different actor. Returns `{id, role}` on success for audit logging.
76
+ * to a different actor.
77
+ *
78
+ * Supersedes any pending offers for the revoked permit's
79
+ * `(to_account, role, scope)` in the same transaction. Prevents the
80
+ * "accept a pre-revoke offer to bypass the revoke" path — any stale
81
+ * offer becomes terminal at revoke time. A fresh post-revoke grant
82
+ * requires the grantor to call `query_permit_offer_create` again.
59
83
  *
60
84
  * @param deps - query dependencies
61
85
  * @param permit_id - the permit to revoke
62
86
  * @param actor_id - the actor that must own the permit
63
87
  * @param revoked_by - the actor who revoked it (for audit trail)
88
+ * @param reason - optional free-form reason, stamped on `permit.revoked_reason` and surfaced to the revokee notification.
64
89
  */
65
- export const query_revoke_permit = async (deps, permit_id, actor_id, revoked_by) => {
66
- const rows = await deps.db.query(`UPDATE permit SET revoked_at = NOW(), revoked_by = $3
90
+ export const query_revoke_permit = async (deps, permit_id, actor_id, revoked_by, reason) => {
91
+ const rows = await deps.db.query(`UPDATE permit SET revoked_at = NOW(), revoked_by = $3, revoked_reason = $4
67
92
  WHERE id = $1 AND actor_id = $2 AND revoked_at IS NULL
68
- RETURNING id, role`, [permit_id, actor_id, revoked_by ?? null]);
69
- return rows[0] ?? null;
93
+ RETURNING id, role, scope_id`, [permit_id, actor_id, revoked_by ?? null, reason ?? null]);
94
+ const revoked = rows[0];
95
+ if (!revoked)
96
+ return null;
97
+ // CTE joins `actor` after the UPDATE so each superseded row carries the
98
+ // grantor's `account_id` — callers fan out `permit_offer_supersede`
99
+ // notifications to that account without a second round-trip.
100
+ const superseded_offers = await deps.db.query(`WITH updated AS (
101
+ UPDATE permit_offer o
102
+ SET superseded_at = NOW()
103
+ FROM actor a
104
+ WHERE a.id = $1
105
+ AND o.to_account_id = a.account_id
106
+ AND o.role = $2
107
+ AND o.scope_id IS NOT DISTINCT FROM $3
108
+ AND o.accepted_at IS NULL
109
+ AND o.declined_at IS NULL
110
+ AND o.retracted_at IS NULL
111
+ AND o.superseded_at IS NULL
112
+ RETURNING o.*
113
+ )
114
+ SELECT u.*, grantor.account_id AS from_account_id
115
+ FROM updated u
116
+ JOIN actor grantor ON grantor.id = u.from_actor_id`, [actor_id, revoked.role, revoked.scope_id]);
117
+ return {
118
+ id: revoked.id,
119
+ role: revoked.role,
120
+ scope_id: revoked.scope_id,
121
+ superseded_offers,
122
+ };
70
123
  };
71
124
  /**
72
125
  * Find all active (non-revoked, non-expired) permits for an actor.
@@ -80,15 +133,23 @@ export const query_permit_find_active_for_actor = async (deps, actor_id) => {
80
133
  };
81
134
  /**
82
135
  * Check if an actor has an active permit for a given role.
136
+ *
137
+ * The `scope_id` parameter selects between global and scoped checks:
138
+ * - Omitted or `null` — matches a global permit (`scope_id IS NULL`).
139
+ * Pre-scope callers keep their existing semantics.
140
+ * - A scope uuid — matches a permit bound to that exact scope.
141
+ *
142
+ * The `IS NOT DISTINCT FROM` comparison handles the NULL case uniformly.
83
143
  */
84
- export const query_permit_has_role = async (deps, actor_id, role) => {
144
+ export const query_permit_has_role = async (deps, actor_id, role, scope_id) => {
85
145
  const row = await deps.db.query_one(`SELECT EXISTS(
86
146
  SELECT 1 FROM permit
87
147
  WHERE actor_id = $1
88
148
  AND role = $2
149
+ AND scope_id IS NOT DISTINCT FROM $3
89
150
  AND revoked_at IS NULL
90
151
  AND (expires_at IS NULL OR expires_at > NOW())
91
- ) AS exists`, [actor_id, role]);
152
+ ) AS exists`, [actor_id, role, scope_id ?? null]);
92
153
  return row?.exists ?? false;
93
154
  };
94
155
  /**
@@ -118,20 +179,54 @@ export const query_permit_find_account_id_for_role = async (deps, role) => {
118
179
  return row?.account_id ?? null;
119
180
  };
120
181
  /**
121
- * Revoke the active permit for an actor with a given role.
182
+ * Revoke every active permit an actor holds for a given role.
183
+ *
184
+ * With scoped permits a single actor+role tuple can hold several active
185
+ * permits (one per scope), so this revokes all of them. Pass
186
+ * `query_revoke_permit(permit_id, ...)` when a single scoped permit
187
+ * is the target.
122
188
  *
123
- * Due to the unique partial index on `(actor_id, role) WHERE revoked_at IS NULL`,
124
- * at most one active permit exists per actor+role combination.
189
+ * Also supersedes pending offers for the actor's account across every
190
+ * scope of this role (the actor can no longer hold the role, so any
191
+ * pending offer of the same role is a bypass vector).
125
192
  *
126
193
  * @param deps - query dependencies
127
- * @param actor_id - the actor whose permit to revoke
194
+ * @param actor_id - the actor whose permits to revoke
128
195
  * @param role - the role to revoke
129
196
  * @param revoked_by - the actor who revoked it (for audit trail)
130
- * @returns `true` if a permit was revoked, `false` if none was active
197
+ * @param reason - optional free-form reason, stamped on `permit.revoked_reason`.
198
+ * @returns the list of revoked permits (empty if none were active) and superseded pending offers
131
199
  */
132
- export const query_permit_revoke_role = async (deps, actor_id, role, revoked_by) => {
133
- const rows = await deps.db.query(`UPDATE permit SET revoked_at = NOW(), revoked_by = $3
134
- WHERE actor_id = $1 AND role = $2 AND revoked_at IS NULL
135
- RETURNING id`, [actor_id, role, revoked_by ?? null]);
136
- return rows.length > 0;
200
+ export const query_permit_revoke_role = async (deps, actor_id, role, revoked_by, reason) => {
201
+ // CTE pulls the revokee's `account_id` via a join on `actor` so callers
202
+ // can address the revokee without an extra round-trip.
203
+ const revoked = await deps.db.query(`WITH updated AS (
204
+ UPDATE permit
205
+ SET revoked_at = NOW(), revoked_by = $3, revoked_reason = $4
206
+ WHERE actor_id = $1 AND role = $2 AND revoked_at IS NULL
207
+ RETURNING id, role, scope_id, actor_id
208
+ )
209
+ SELECT u.id AS permit_id, u.role, u.scope_id, a.account_id
210
+ FROM updated u
211
+ JOIN actor a ON a.id = u.actor_id`, [actor_id, role, revoked_by ?? null, reason ?? null]);
212
+ if (revoked.length === 0) {
213
+ return { revoked: [], superseded_offers: [] };
214
+ }
215
+ const superseded_offers = await deps.db.query(`WITH updated AS (
216
+ UPDATE permit_offer o
217
+ SET superseded_at = NOW()
218
+ FROM actor a
219
+ WHERE a.id = $1
220
+ AND o.to_account_id = a.account_id
221
+ AND o.role = $2
222
+ AND o.accepted_at IS NULL
223
+ AND o.declined_at IS NULL
224
+ AND o.retracted_at IS NULL
225
+ AND o.superseded_at IS NULL
226
+ RETURNING o.*
227
+ )
228
+ SELECT u.*, grantor.account_id AS from_account_id
229
+ FROM updated u
230
+ JOIN actor grantor ON grantor.id = u.from_actor_id`, [actor_id, role]);
231
+ return { revoked, superseded_offers };
137
232
  };
@@ -88,8 +88,10 @@ export declare const query_session_list_for_account: (deps: QueryDeps, account_i
88
88
  *
89
89
  * Race safety: this function must run inside a transaction alongside the
90
90
  * INSERT that created the new session. All callers satisfy this requirement:
91
- * - `POST /login` and `POST /tokens/create` use the default `transaction: true`
92
- * (framework-managed transaction wrapping in `apply_route_specs`)
91
+ * - `POST /login` uses the default `transaction: true` (framework-managed
92
+ * transaction wrapping in `apply_route_specs`)
93
+ * - The `account_token_create` RPC handler runs under the dispatcher's
94
+ * transaction path because its spec declares `side_effects: true`
93
95
  * - `POST /bootstrap` and `POST /signup` manage their own transactions
94
96
  * and pass the transaction-scoped `deps` to `create_session_and_set_cookie`
95
97
  *
@@ -1 +1 @@
1
- {"version":3,"file":"session_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/session_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAGpD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,qBAAqB,CAAC;AAErD,kDAAkD;AAClD,eAAO,MAAM,wBAAwB,QAA2B,CAAC;AAEjE,yEAAyE;AACzE,eAAO,MAAM,gCAAgC,QAAsB,CAAC;AAEpE;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,MAAM,KAAG,MAElD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,QAAO,MAEzC,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,EAClB,YAAY,IAAI,KACd,OAAO,CAAC,IAAI,CAMd,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GACnC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,WAAW,GAAG,SAAS,CAKjC,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,GAAU,MAAM,SAAS,EAAE,YAAY,MAAM,KAAG,OAAO,CAAC,IAAI,CAY3F,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,GACxC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,IAAI,CAEd,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,KAChB,OAAO,CAAC,OAAO,CAMjB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,oCAAoC,GAChD,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,MAAM,CAMhB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAU,KACR,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAK5B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAc,MAAM,KAClB,OAAO,CAAC,MAAM,CAYhB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,cAAW,KACT,OAAO,CAAC,KAAK,CAAC,WAAW,GAAG;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAC,CAAC,CASjD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GAAU,MAAM,SAAS,KAAG,OAAO,CAAC,MAAM,CAKnF,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,iBAAiB,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,EACjD,KAAK,MAAM,KACT,OAAO,CAAC,IAAI,CAMd,CAAC"}
1
+ {"version":3,"file":"session_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/session_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAGpD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,qBAAqB,CAAC;AAErD,kDAAkD;AAClD,eAAO,MAAM,wBAAwB,QAA2B,CAAC;AAEjE,yEAAyE;AACzE,eAAO,MAAM,gCAAgC,QAAsB,CAAC;AAEpE;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,MAAM,KAAG,MAElD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,QAAO,MAEzC,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,EAClB,YAAY,IAAI,KACd,OAAO,CAAC,IAAI,CAMd,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GACnC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,WAAW,GAAG,SAAS,CAKjC,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,GAAU,MAAM,SAAS,EAAE,YAAY,MAAM,KAAG,OAAO,CAAC,IAAI,CAY3F,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,GACxC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,IAAI,CAEd,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,KAChB,OAAO,CAAC,OAAO,CAMjB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,oCAAoC,GAChD,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,MAAM,CAMhB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAU,KACR,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAK5B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAc,MAAM,KAClB,OAAO,CAAC,MAAM,CAYhB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,cAAW,KACT,OAAO,CAAC,KAAK,CAAC,WAAW,GAAG;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAC,CAAC,CASjD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GAAU,MAAM,SAAS,KAAG,OAAO,CAAC,MAAM,CAKnF,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,iBAAiB,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,EACjD,KAAK,MAAM,KACT,OAAO,CAAC,IAAI,CAMd,CAAC"}
@@ -118,8 +118,10 @@ export const query_session_list_for_account = async (deps, account_id, limit = 5
118
118
  *
119
119
  * Race safety: this function must run inside a transaction alongside the
120
120
  * INSERT that created the new session. All callers satisfy this requirement:
121
- * - `POST /login` and `POST /tokens/create` use the default `transaction: true`
122
- * (framework-managed transaction wrapping in `apply_route_specs`)
121
+ * - `POST /login` uses the default `transaction: true` (framework-managed
122
+ * transaction wrapping in `apply_route_specs`)
123
+ * - The `account_token_create` RPC handler runs under the dispatcher's
124
+ * transaction path because its spec declares `side_effects: true`
123
125
  * - `POST /bootstrap` and `POST /signup` manage their own transactions
124
126
  * and pass the transaction-scoped `deps` to `create_session_and_set_cookie`
125
127
  *
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * @module
9
9
  */
10
+ import { z } from 'zod';
10
11
  import { type RouteSpec } from '../http/route_spec.js';
11
12
  import { type RateLimiter } from '../rate_limiter.js';
12
13
  import type { RouteFactoryDeps } from './deps.js';
@@ -21,6 +22,18 @@ export interface SignupRouteOptions extends AuthSessionRouteOptions {
21
22
  /** Mutable ref to app settings — when `open_signup` is true, invite check is skipped. */
22
23
  app_settings: AppSettings;
23
24
  }
25
+ /** Input for `POST /signup`. `email` is optional and must match any referenced invite. */
26
+ export declare const SignupInput: z.ZodObject<{
27
+ username: z.ZodString;
28
+ password: z.ZodString;
29
+ email: z.ZodOptional<z.ZodEmail>;
30
+ }, z.core.$strict>;
31
+ export type SignupInput = z.infer<typeof SignupInput>;
32
+ /** Output for `POST /signup`. Session cookie is the operative side effect. */
33
+ export declare const SignupOutput: z.ZodObject<{
34
+ ok: z.ZodLiteral<true>;
35
+ }, z.core.$strict>;
36
+ export type SignupOutput = z.infer<typeof SignupOutput>;
24
37
  /**
25
38
  * Create signup route specs for account creation.
26
39
  *
@@ -1 +1 @@
1
- {"version":3,"file":"signup_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAUH,OAAO,EAAkB,KAAK,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAEtE,OAAO,EAA+B,KAAK,WAAW,EAAC,MAAM,oBAAoB,CAAC;AAClF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAGhD,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,0BAA0B,CAAC;AAE1D,OAAO,KAAK,EAAC,uBAAuB,EAAC,MAAM,qBAAqB,CAAC;AAEjE;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,uBAAuB;IAClE,6FAA6F;IAC7F,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD,yFAAyF;IACzF,YAAY,EAAE,WAAW,CAAC;CAC1B;AAED;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,gBAAgB,EACtB,SAAS,kBAAkB,KACzB,KAAK,CAAC,SAAS,CAqIjB,CAAC"}
1
+ {"version":3,"file":"signup_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAStB,OAAO,EAAkB,KAAK,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAEtE,OAAO,EAA+B,KAAK,WAAW,EAAC,MAAM,oBAAoB,CAAC;AAClF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAGhD,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,0BAA0B,CAAC;AAE1D,OAAO,KAAK,EAAC,uBAAuB,EAAC,MAAM,qBAAqB,CAAC;AAEjE;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,uBAAuB;IAClE,6FAA6F;IAC7F,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD,yFAAyF;IACzF,YAAY,EAAE,WAAW,CAAC;CAC1B;AAID,0FAA0F;AAC1F,eAAO,MAAM,WAAW;;;;kBAItB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,8EAA8E;AAC9E,eAAO,MAAM,YAAY;;kBAEvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,gBAAgB,EACtB,SAAS,kBAAkB,KACzB,KAAK,CAAC,SAAS,CAyHjB,CAAC"}
@@ -19,6 +19,17 @@ import { rate_limit_exceeded_response } from '../rate_limiter.js';
19
19
  import { ERROR_NO_MATCHING_INVITE, ERROR_SIGNUP_CONFLICT } from '../http/error_schemas.js';
20
20
  import { audit_log_fire_and_forget } from './audit_log_queries.js';
21
21
  import { is_pg_unique_violation } from '../db/pg_error.js';
22
+ // -- Input/output schemas ---------------------------------------------------
23
+ /** Input for `POST /signup`. `email` is optional and must match any referenced invite. */
24
+ export const SignupInput = z.strictObject({
25
+ username: Username,
26
+ password: Password,
27
+ email: Email.optional(),
28
+ });
29
+ /** Output for `POST /signup`. Session cookie is the operative side effect. */
30
+ export const SignupOutput = z.strictObject({
31
+ ok: z.literal(true),
32
+ });
22
33
  /**
23
34
  * Create signup route specs for account creation.
24
35
  *
@@ -36,12 +47,8 @@ export const create_signup_route_specs = (deps, options) => {
36
47
  auth: { type: 'none' },
37
48
  description: 'Create account (invite-gated or open signup)',
38
49
  transaction: false, // manages its own transaction for TOCTOU safety
39
- input: z.strictObject({
40
- username: Username,
41
- password: Password,
42
- email: Email.optional(),
43
- }),
44
- output: z.strictObject({ ok: z.literal(true) }),
50
+ input: SignupInput,
51
+ output: SignupOutput,
45
52
  rate_limit: signup_account_rate_limiter ? 'both' : 'ip',
46
53
  errors: {
47
54
  403: z.looseObject({ error: z.literal(ERROR_NO_MATCHING_INVITE) }),
@@ -56,7 +63,7 @@ export const create_signup_route_specs = (deps, options) => {
56
63
  return rate_limit_exceeded_response(c, check.retry_after);
57
64
  }
58
65
  }
59
- const { username, password: pw, email, } = get_route_input(c);
66
+ const { username, password: pw, email } = get_route_input(c);
60
67
  // Per-account rate limit check (after input parsing, before DB work)
61
68
  const account_key = username.toLowerCase();
62
69
  if (signup_account_rate_limiter) {