@opengeni/db 3.2.0-canary.0 → 3.4.0-canary.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 (81) hide show
  1. package/dist/canonical-human-identities.js +3 -3
  2. package/dist/{chunk-SIBYBA2D.js → chunk-6DPJA36C.js} +30 -11
  3. package/dist/chunk-6DPJA36C.js.map +1 -0
  4. package/dist/{chunk-KRLPEMLH.js → chunk-C2AFP7SB.js} +184 -6
  5. package/dist/chunk-C2AFP7SB.js.map +1 -0
  6. package/dist/{chunk-AZHW7R5V.js → chunk-H7MXYG7R.js} +12 -8
  7. package/dist/chunk-H7MXYG7R.js.map +1 -0
  8. package/dist/{chunk-2PRS4J5Q.js → chunk-HY3F5Q4E.js} +3 -3
  9. package/dist/{chunk-NAB6FKFN.js → chunk-O6FTELKH.js} +3 -3
  10. package/dist/{chunk-UZNXG44K.js → chunk-PYMY3AJW.js} +324 -8
  11. package/dist/chunk-PYMY3AJW.js.map +1 -0
  12. package/dist/{chunk-6HMM2R5T.js → chunk-TRBOWUG3.js} +98 -4
  13. package/dist/chunk-TRBOWUG3.js.map +1 -0
  14. package/dist/{chunk-E6CLMKEE.js → chunk-V3TRUXFJ.js} +2 -2
  15. package/dist/{chunk-2EH4WRHL.js → chunk-ZWRIPCTR.js} +2 -2
  16. package/dist/editable-artifact-durable-export.js +2 -2
  17. package/dist/editable-artifacts.js +3 -3
  18. package/dist/governed-learning-activation.d.ts +16 -0
  19. package/dist/index.d.ts +108 -7
  20. package/dist/index.js +3605 -1417
  21. package/dist/index.js.map +1 -1
  22. package/dist/insights-model-bundle.d.ts +30 -0
  23. package/dist/insights-usage-bundle.d.ts +32 -0
  24. package/dist/insights.d.ts +10 -2
  25. package/dist/lossless-json.d.ts +1 -1
  26. package/dist/managed-user-setup.d.ts +82 -0
  27. package/dist/memory-domain.d.ts +2 -2
  28. package/dist/migrate.d.ts +3 -1
  29. package/dist/migrate.js +1 -1
  30. package/dist/new-session-drafts.d.ts +4 -4
  31. package/dist/organization-membership-lifecycle.d.ts +60 -1
  32. package/dist/pr-review.d.ts +44 -3
  33. package/dist/provision-roles.js +1 -1
  34. package/dist/runtime-posture.d.ts +12 -7
  35. package/dist/schema.d.ts +839 -101
  36. package/dist/schema.js +9 -1
  37. package/dist/session-background-commands.js +3 -3
  38. package/dist/session-tenancy.d.ts +12 -1
  39. package/dist/session-tenancy.js +3 -3
  40. package/dist/video-generation.js +3 -3
  41. package/drizzle/0348_named_signup_and_user_setup.sql +838 -0
  42. package/drizzle/0349_greenfield_session_tenancy_activation.sql +792 -0
  43. package/drizzle/0350_organization_shared_workspace_administration.sql +889 -0
  44. package/drizzle/0351_organization_user_setup_delivery.sql +978 -0
  45. package/drizzle/0352_session_variable_set_attachments.sql +1661 -0
  46. package/drizzle/0353_automatic_session_title_policy_fence.sql +476 -0
  47. package/drizzle/0354_automatic_session_title_quarantine_index.sql +9 -0
  48. package/drizzle/0355_automatic_session_title_quarantine.sql +92 -0
  49. package/drizzle/0356_set_based_insights_session_visibility.sql +237 -0
  50. package/drizzle/0357_rig_platform_base_only.sql +44 -0
  51. package/drizzle/0358_pr_review_managed_github_app.sql +108 -0
  52. package/drizzle/0359_insights_force_rls_read_capability.sql +936 -0
  53. package/drizzle/0360_organization_identity_confirmation_prompt.sql +103 -0
  54. package/drizzle/0361_remember_knowledge_memory_materialization.sql +249 -0
  55. package/package.json +6 -6
  56. package/src/database.ts +12 -8
  57. package/src/governed-learning-activation.ts +74 -2
  58. package/src/index.ts +879 -92
  59. package/src/insights-model-bundle.ts +681 -0
  60. package/src/insights-usage-bundle.ts +240 -0
  61. package/src/insights.ts +265 -152
  62. package/src/lossless-json.ts +2 -1
  63. package/src/managed-user-setup.ts +316 -0
  64. package/src/memory-domain.ts +3 -3
  65. package/src/migrate.ts +38 -14
  66. package/src/new-session-drafts.ts +22 -15
  67. package/src/organization-membership-lifecycle.ts +253 -1
  68. package/src/pr-review.ts +449 -6
  69. package/src/provision-roles.ts +92 -0
  70. package/src/runtime-posture.ts +247 -4
  71. package/src/schema.ts +210 -4
  72. package/src/session-tenancy.ts +120 -2
  73. package/dist/chunk-6HMM2R5T.js.map +0 -1
  74. package/dist/chunk-AZHW7R5V.js.map +0 -1
  75. package/dist/chunk-KRLPEMLH.js.map +0 -1
  76. package/dist/chunk-SIBYBA2D.js.map +0 -1
  77. package/dist/chunk-UZNXG44K.js.map +0 -1
  78. /package/dist/{chunk-2PRS4J5Q.js.map → chunk-HY3F5Q4E.js.map} +0 -0
  79. /package/dist/{chunk-NAB6FKFN.js.map → chunk-O6FTELKH.js.map} +0 -0
  80. /package/dist/{chunk-E6CLMKEE.js.map → chunk-V3TRUXFJ.js.map} +0 -0
  81. /package/dist/{chunk-2EH4WRHL.js.map → chunk-ZWRIPCTR.js.map} +0 -0
@@ -0,0 +1,978 @@
1
+ -- deployment-mode: rolling
2
+ -- Durable, idempotent invited-user setup delivery. Setup bearers remain
3
+ -- digest-only and are derived by the application only after a durable claim.
4
+
5
+ CREATE TABLE organization_user_setup_deliveries (
6
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7
+ account_id uuid NOT NULL REFERENCES managed_accounts(id) ON DELETE CASCADE,
8
+ invitation_id uuid NOT NULL,
9
+ invitation_operation_id uuid NOT NULL,
10
+ created_by_membership_id uuid NOT NULL,
11
+ setup_intent_id uuid REFERENCES organization_user_setup_intents(id) ON DELETE RESTRICT,
12
+ provider_key text NOT NULL UNIQUE,
13
+ recipient_email text NOT NULL,
14
+ recipient_name text,
15
+ organization_name text NOT NULL,
16
+ organization_role text NOT NULL,
17
+ shared_workspace_access jsonb NOT NULL DEFAULT '[]'::jsonb,
18
+ token_digest text,
19
+ payload_digest text,
20
+ state text NOT NULL DEFAULT 'pending',
21
+ revision bigint NOT NULL DEFAULT 1,
22
+ claim_holder_id uuid,
23
+ claim_expires_at timestamptz,
24
+ attempt_count integer NOT NULL DEFAULT 0,
25
+ provider_idempotency_scope text,
26
+ provider_retry_safe_until timestamptz,
27
+ provider_retry_window_started_at timestamptz,
28
+ provider_started_at timestamptz,
29
+ unresolved_provider_outcome_at timestamptz,
30
+ error_class text,
31
+ provider_message_id text,
32
+ sent_at timestamptz,
33
+ failed_at timestamptz,
34
+ outcome_unknown_at timestamptz,
35
+ revoked_at timestamptz,
36
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
37
+ updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
38
+ CONSTRAINT organization_user_setup_deliveries_invitation_fk
39
+ FOREIGN KEY (invitation_id, account_id)
40
+ REFERENCES organization_membership_invitations(id, account_id) ON DELETE RESTRICT,
41
+ CONSTRAINT organization_user_setup_deliveries_creator_fk
42
+ FOREIGN KEY (created_by_membership_id, account_id)
43
+ REFERENCES organization_memberships(id, account_id) ON DELETE RESTRICT,
44
+ CONSTRAINT organization_user_setup_deliveries_invitation_uq
45
+ UNIQUE (account_id, invitation_id),
46
+ CONSTRAINT organization_user_setup_deliveries_id_account_uq
47
+ UNIQUE (id, account_id),
48
+ CONSTRAINT organization_user_setup_deliveries_role_check
49
+ CHECK (organization_role IN ('owner', 'admin', 'member')),
50
+ CONSTRAINT organization_user_setup_deliveries_state_check
51
+ CHECK (state IN ('pending', 'sent', 'failed', 'outcome_unknown', 'revoked')),
52
+ CONSTRAINT organization_user_setup_deliveries_digest_check CHECK (
53
+ (token_digest IS NULL OR token_digest ~ '^[0-9a-f]{64}$')
54
+ AND (payload_digest IS NULL OR payload_digest ~ '^[0-9a-f]{64}$')
55
+ ),
56
+ CONSTRAINT organization_user_setup_deliveries_bounds_check CHECK (
57
+ recipient_email = lower(btrim(recipient_email))
58
+ AND octet_length(convert_to(recipient_email, 'UTF8')) BETWEEN 3 AND 320
59
+ AND (recipient_name IS NULL OR octet_length(convert_to(recipient_name, 'UTF8')) BETWEEN 1 AND 120)
60
+ AND octet_length(convert_to(organization_name, 'UTF8')) BETWEEN 1 AND 120
61
+ AND octet_length(convert_to(provider_key, 'UTF8')) BETWEEN 1 AND 200
62
+ AND (
63
+ provider_idempotency_scope IS NULL
64
+ OR (
65
+ octet_length(convert_to(provider_idempotency_scope, 'UTF8')) BETWEEN 1 AND 200
66
+ AND provider_idempotency_scope ~ '^[a-z0-9][a-z0-9:._-]*$'
67
+ )
68
+ )
69
+ AND jsonb_typeof(shared_workspace_access) = 'array'
70
+ AND jsonb_array_length(shared_workspace_access) <= 100
71
+ AND (error_class IS NULL OR octet_length(convert_to(error_class, 'UTF8')) BETWEEN 1 AND 64)
72
+ AND (provider_message_id IS NULL OR octet_length(convert_to(provider_message_id, 'UTF8')) BETWEEN 1 AND 255)
73
+ AND attempt_count >= 0
74
+ AND revision > 0
75
+ ),
76
+ CONSTRAINT organization_user_setup_deliveries_claim_check CHECK (
77
+ (claim_holder_id IS NULL AND claim_expires_at IS NULL)
78
+ OR (claim_holder_id IS NOT NULL AND claim_expires_at IS NOT NULL)
79
+ ),
80
+ CONSTRAINT organization_user_setup_deliveries_provider_fence_check CHECK (
81
+ (
82
+ provider_idempotency_scope IS NULL
83
+ AND provider_retry_safe_until IS NULL
84
+ AND provider_retry_window_started_at IS NULL
85
+ )
86
+ OR (
87
+ provider_idempotency_scope IS NOT NULL
88
+ AND provider_retry_safe_until IS NOT NULL
89
+ AND provider_retry_window_started_at IS NOT NULL
90
+ AND provider_retry_safe_until >= provider_retry_window_started_at
91
+ )
92
+ ),
93
+ CONSTRAINT organization_user_setup_deliveries_unresolved_outcome_check CHECK (
94
+ unresolved_provider_outcome_at IS NULL
95
+ OR (
96
+ provider_idempotency_scope IS NOT NULL
97
+ AND state IN ('pending', 'outcome_unknown')
98
+ )
99
+ ),
100
+ CONSTRAINT organization_user_setup_deliveries_terminal_check CHECK (
101
+ (state = 'pending' AND sent_at IS NULL AND failed_at IS NULL
102
+ AND outcome_unknown_at IS NULL AND revoked_at IS NULL)
103
+ OR (state = 'sent' AND sent_at IS NOT NULL AND failed_at IS NULL
104
+ AND outcome_unknown_at IS NULL AND revoked_at IS NULL)
105
+ OR (state = 'failed' AND sent_at IS NULL AND failed_at IS NOT NULL
106
+ AND outcome_unknown_at IS NULL AND revoked_at IS NULL)
107
+ OR (state = 'outcome_unknown' AND sent_at IS NULL AND failed_at IS NULL
108
+ AND outcome_unknown_at IS NOT NULL AND revoked_at IS NULL)
109
+ OR (state = 'revoked' AND revoked_at IS NOT NULL)
110
+ )
111
+ );
112
+
113
+ CREATE INDEX organization_user_setup_deliveries_account_updated_idx
114
+ ON organization_user_setup_deliveries (account_id, updated_at DESC, id DESC);
115
+ CREATE UNIQUE INDEX organization_user_setup_deliveries_token_digest_uq
116
+ ON organization_user_setup_deliveries (token_digest) WHERE token_digest IS NOT NULL;
117
+
118
+ CREATE TABLE organization_user_setup_delivery_attempts (
119
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
120
+ account_id uuid NOT NULL REFERENCES managed_accounts(id) ON DELETE CASCADE,
121
+ delivery_id uuid NOT NULL,
122
+ operation_id uuid NOT NULL,
123
+ attempt_number integer NOT NULL,
124
+ claim_holder_id uuid NOT NULL,
125
+ claimed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
126
+ claim_expires_at timestamptz NOT NULL,
127
+ provider_started_at timestamptz,
128
+ result text,
129
+ error_class text,
130
+ provider_message_id text,
131
+ settled_at timestamptz,
132
+ CONSTRAINT organization_user_setup_delivery_attempts_operation_uq
133
+ UNIQUE (delivery_id, operation_id),
134
+ CONSTRAINT organization_user_setup_delivery_attempts_delivery_fk
135
+ FOREIGN KEY (delivery_id, account_id)
136
+ REFERENCES organization_user_setup_deliveries(id, account_id) ON DELETE RESTRICT,
137
+ CONSTRAINT organization_user_setup_delivery_attempts_number_uq
138
+ UNIQUE (delivery_id, attempt_number),
139
+ CONSTRAINT organization_user_setup_delivery_attempts_bounds_check CHECK (
140
+ attempt_number > 0
141
+ AND claim_expires_at > claimed_at
142
+ AND (result IS NULL OR result IN ('sent', 'failed', 'outcome_unknown', 'revoked'))
143
+ AND (error_class IS NULL OR octet_length(convert_to(error_class, 'UTF8')) BETWEEN 1 AND 64)
144
+ AND (provider_message_id IS NULL OR octet_length(convert_to(provider_message_id, 'UTF8')) BETWEEN 1 AND 255)
145
+ ),
146
+ CONSTRAINT organization_user_setup_delivery_attempts_settlement_check CHECK (
147
+ (result IS NULL AND settled_at IS NULL)
148
+ OR (result IS NOT NULL AND settled_at IS NOT NULL)
149
+ )
150
+ );
151
+
152
+ ALTER TABLE organization_user_setup_deliveries ENABLE ROW LEVEL SECURITY;
153
+ ALTER TABLE organization_user_setup_deliveries FORCE ROW LEVEL SECURITY;
154
+ CREATE POLICY organization_tenancy_lifecycle ON organization_user_setup_deliveries
155
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
156
+ = 'organization_membership_lifecycle')
157
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
158
+ = 'organization_membership_lifecycle');
159
+
160
+ ALTER TABLE organization_user_setup_delivery_attempts ENABLE ROW LEVEL SECURITY;
161
+ ALTER TABLE organization_user_setup_delivery_attempts FORCE ROW LEVEL SECURITY;
162
+ CREATE POLICY organization_tenancy_lifecycle ON organization_user_setup_delivery_attempts
163
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
164
+ = 'organization_membership_lifecycle')
165
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
166
+ = 'organization_membership_lifecycle');
167
+
168
+ CREATE FUNCTION opengeni_private.organization_user_setup_delivery_json(
169
+ p_delivery organization_user_setup_deliveries
170
+ ) RETURNS jsonb
171
+ LANGUAGE sql STABLE
172
+ SET search_path = pg_catalog
173
+ AS $body$
174
+ SELECT pg_catalog.jsonb_build_object(
175
+ 'id', p_delivery.id,
176
+ 'state', CASE
177
+ WHEN p_delivery.state = 'pending'
178
+ AND p_delivery.claim_expires_at <= pg_catalog.clock_timestamp()
179
+ THEN CASE
180
+ WHEN p_delivery.provider_started_at IS NULL
181
+ AND p_delivery.unresolved_provider_outcome_at IS NULL
182
+ THEN 'failed'
183
+ ELSE 'outcome_unknown'
184
+ END
185
+ ELSE p_delivery.state
186
+ END,
187
+ 'attemptCount', p_delivery.attempt_count,
188
+ 'revision', p_delivery.revision,
189
+ 'errorClass', CASE
190
+ WHEN p_delivery.state = 'pending'
191
+ AND p_delivery.claim_expires_at <= pg_catalog.clock_timestamp()
192
+ THEN CASE
193
+ WHEN p_delivery.provider_started_at IS NULL
194
+ AND p_delivery.unresolved_provider_outcome_at IS NULL
195
+ THEN 'claim_expired_before_provider'
196
+ WHEN p_delivery.provider_started_at IS NULL
197
+ THEN 'prior_provider_outcome_unresolved'
198
+ ELSE 'provider_started_claim_expired'
199
+ END
200
+ ELSE p_delivery.error_class
201
+ END,
202
+ 'retryState', CASE
203
+ WHEN p_delivery.state = 'failed' THEN 'available'
204
+ WHEN p_delivery.state = 'pending'
205
+ AND p_delivery.claim_expires_at <= pg_catalog.clock_timestamp()
206
+ AND p_delivery.provider_started_at IS NULL
207
+ AND p_delivery.unresolved_provider_outcome_at IS NULL
208
+ THEN 'available'
209
+ WHEN (
210
+ p_delivery.state = 'outcome_unknown'
211
+ OR (
212
+ p_delivery.state = 'pending'
213
+ AND p_delivery.claim_expires_at <= pg_catalog.clock_timestamp()
214
+ AND (
215
+ p_delivery.provider_started_at IS NOT NULL
216
+ OR p_delivery.unresolved_provider_outcome_at IS NOT NULL
217
+ )
218
+ )
219
+ )
220
+ THEN CASE
221
+ WHEN p_delivery.provider_retry_safe_until IS NOT NULL
222
+ AND p_delivery.provider_retry_safe_until > pg_catalog.clock_timestamp()
223
+ THEN 'available'
224
+ ELSE 'reconciliation_required'
225
+ END
226
+ ELSE 'unavailable'
227
+ END,
228
+ 'sentAt', p_delivery.sent_at,
229
+ 'updatedAt', p_delivery.updated_at
230
+ )
231
+ $body$;
232
+
233
+ CREATE OR REPLACE FUNCTION opengeni_private.organization_invitation_row_json(
234
+ p_invitation organization_membership_invitations
235
+ ) RETURNS jsonb
236
+ LANGUAGE sql STABLE
237
+ SET search_path FROM CURRENT
238
+ AS $body$
239
+ SELECT pg_catalog.jsonb_build_object(
240
+ 'id', p_invitation.id,
241
+ 'organizationId', p_invitation.account_id,
242
+ 'targetEmail', p_invitation.target_email,
243
+ 'targetName', p_invitation.target_name,
244
+ 'initialWorkspaceIds', p_invitation.initial_workspace_ids,
245
+ 'role', p_invitation.role,
246
+ 'status', CASE
247
+ WHEN p_invitation.status = 'pending'
248
+ AND p_invitation.expires_at <= pg_catalog.clock_timestamp()
249
+ THEN 'expired'
250
+ ELSE p_invitation.status
251
+ END,
252
+ 'revision', p_invitation.revision,
253
+ 'expiresAt', p_invitation.expires_at,
254
+ 'acceptedMembershipId', p_invitation.accepted_membership_id,
255
+ 'createdAt', p_invitation.created_at,
256
+ 'updatedAt', p_invitation.updated_at,
257
+ 'delivery', (
258
+ SELECT opengeni_private.organization_user_setup_delivery_json(delivery)
259
+ FROM organization_user_setup_deliveries delivery
260
+ WHERE delivery.account_id = p_invitation.account_id
261
+ AND delivery.invitation_id = p_invitation.id
262
+ )
263
+ )
264
+ $body$;
265
+
266
+ CREATE FUNCTION claim_organization_user_setup_delivery(p_command jsonb)
267
+ RETURNS jsonb
268
+ LANGUAGE plpgsql SECURITY DEFINER
269
+ SET search_path FROM CURRENT
270
+ AS $body$
271
+ DECLARE
272
+ account_id_value uuid := nullif(p_command ->> 'organizationId', '')::uuid;
273
+ actor_subject text := p_command ->> 'actorSubjectId';
274
+ invitation_id_value uuid := nullif(p_command ->> 'invitationId', '')::uuid;
275
+ invitation_operation_id_value uuid := nullif(p_command ->> 'invitationOperationId', '')::uuid;
276
+ operation_id_value uuid := nullif(p_command ->> 'operationId', '')::uuid;
277
+ actor organization_memberships%ROWTYPE;
278
+ invitation organization_membership_invitations%ROWTYPE;
279
+ delivery organization_user_setup_deliveries%ROWTYPE;
280
+ attempt organization_user_setup_delivery_attempts%ROWTYPE;
281
+ normalized_email text;
282
+ workspace_snapshot jsonb;
283
+ now_value timestamptz := clock_timestamp();
284
+ delivery_id_value uuid;
285
+ verified_invitation_operation_id uuid;
286
+ BEGIN
287
+ IF account_id_value IS NULL
288
+ OR account_id_value IS DISTINCT FROM opengeni_private.current_account_id()
289
+ OR actor_subject IS NULL
290
+ OR actor_subject IS DISTINCT FROM opengeni_private.current_subject_id()
291
+ OR invitation_id_value IS NULL
292
+ OR operation_id_value IS NULL
293
+ THEN
294
+ RAISE EXCEPTION 'organization setup delivery authority is invalid' USING ERRCODE = '42501';
295
+ END IF;
296
+ PERFORM set_config(
297
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
298
+ );
299
+ SELECT lower(btrim(candidate.target_email)) INTO normalized_email
300
+ FROM organization_membership_invitations candidate
301
+ WHERE candidate.account_id = account_id_value AND candidate.id = invitation_id_value;
302
+ IF normalized_email IS NULL THEN
303
+ RAISE EXCEPTION 'organization invitation is unavailable' USING ERRCODE = 'P0002';
304
+ END IF;
305
+ PERFORM pg_advisory_xact_lock(
306
+ hashtextextended('organization-invitation-email:' || normalized_email, 0)
307
+ );
308
+ PERFORM pg_advisory_xact_lock(
309
+ hashtextextended('organization-membership:' || account_id_value::text, 0)
310
+ );
311
+ PERFORM 1 FROM managed_accounts account
312
+ WHERE account.id = account_id_value FOR KEY SHARE;
313
+ IF NOT FOUND THEN
314
+ RAISE EXCEPTION 'organization not found' USING ERRCODE = 'P0002';
315
+ END IF;
316
+ SELECT * INTO actor FROM organization_memberships membership
317
+ WHERE membership.account_id = account_id_value
318
+ AND membership.subject_id = actor_subject FOR UPDATE;
319
+ IF NOT FOUND OR actor.status <> 'active' OR actor.role NOT IN ('owner', 'admin') THEN
320
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
321
+ END IF;
322
+ SELECT * INTO invitation FROM organization_membership_invitations candidate
323
+ WHERE candidate.account_id = account_id_value AND candidate.id = invitation_id_value
324
+ FOR UPDATE;
325
+ IF NOT FOUND THEN
326
+ RAISE EXCEPTION 'organization invitation is unavailable' USING ERRCODE = 'P0002';
327
+ END IF;
328
+
329
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
330
+ 'workspaceId', workspace.id,
331
+ 'workspaceName', workspace.name,
332
+ 'role', 'member'
333
+ ) ORDER BY lower(workspace.name), workspace.id), '[]'::jsonb)
334
+ INTO workspace_snapshot
335
+ FROM unnest(invitation.initial_workspace_ids) requested(workspace_id)
336
+ JOIN workspaces workspace
337
+ ON workspace.account_id = account_id_value AND workspace.id = requested.workspace_id
338
+ WHERE NOT EXISTS (
339
+ SELECT 1 FROM organization_memberships personal_owner
340
+ WHERE personal_owner.account_id = account_id_value
341
+ AND personal_owner.personal_workspace_id = workspace.id
342
+ );
343
+ IF jsonb_array_length(workspace_snapshot) <> cardinality(invitation.initial_workspace_ids) THEN
344
+ RAISE EXCEPTION 'organization invitation workspace snapshot is invalid'
345
+ USING ERRCODE = '55000';
346
+ END IF;
347
+
348
+ SELECT * INTO delivery FROM organization_user_setup_deliveries candidate
349
+ WHERE candidate.account_id = account_id_value
350
+ AND candidate.invitation_id = invitation_id_value FOR UPDATE;
351
+ IF NOT FOUND THEN
352
+ -- An authorized retry can recover a crash after the invitation commit but
353
+ -- before this journal was created. Resolve the immutable invite receipt
354
+ -- server-side; a supplied operation id must still match it exactly.
355
+ SELECT receipt.operation_id INTO verified_invitation_operation_id
356
+ FROM organization_membership_operation_receipts receipt
357
+ WHERE receipt.account_id = account_id_value
358
+ AND receipt.action = 'invite'
359
+ AND receipt.result ->> 'id' = invitation_id_value::text
360
+ AND (
361
+ invitation_operation_id_value IS NULL
362
+ OR receipt.operation_id = invitation_operation_id_value
363
+ )
364
+ LIMIT 1;
365
+ IF verified_invitation_operation_id IS NULL THEN
366
+ RAISE EXCEPTION 'organization invitation operation is unavailable'
367
+ USING ERRCODE = '42501';
368
+ END IF;
369
+ invitation_operation_id_value := verified_invitation_operation_id;
370
+ delivery_id_value := gen_random_uuid();
371
+ INSERT INTO organization_user_setup_deliveries (
372
+ id, account_id, invitation_id, invitation_operation_id,
373
+ created_by_membership_id, provider_key, recipient_email, recipient_name,
374
+ organization_name, organization_role, shared_workspace_access
375
+ ) SELECT
376
+ delivery_id_value, account_id_value, invitation.id, invitation_operation_id_value,
377
+ invitation.created_by_membership_id,
378
+ 'opengeni-organization-setup-v1-' || delivery_id_value::text,
379
+ normalized_email, invitation.target_name, account.name, invitation.role,
380
+ workspace_snapshot
381
+ FROM managed_accounts account WHERE account.id = account_id_value
382
+ RETURNING * INTO delivery;
383
+ ELSIF invitation_operation_id_value IS NOT NULL
384
+ AND delivery.invitation_operation_id IS DISTINCT FROM invitation_operation_id_value
385
+ THEN
386
+ RAISE EXCEPTION 'organization setup delivery identity changed' USING ERRCODE = '23505';
387
+ END IF;
388
+
389
+ -- Reconcile an expired lease before returning a terminal invitation. An
390
+ -- invitation can expire or be accepted after a worker has claimed it; once
391
+ -- that lease expires, leaving the attempt open would permanently retain a
392
+ -- live-looking claim that no administrator can safely recover.
393
+ IF delivery.claim_holder_id IS NOT NULL AND delivery.claim_expires_at <= now_value THEN
394
+ UPDATE organization_user_setup_delivery_attempts SET
395
+ result = CASE WHEN provider_started_at IS NULL THEN 'failed' ELSE 'outcome_unknown' END,
396
+ error_class = CASE
397
+ WHEN provider_started_at IS NULL THEN 'claim_expired_before_provider'
398
+ ELSE 'provider_started_claim_expired'
399
+ END,
400
+ settled_at = now_value
401
+ WHERE delivery_id = delivery.id AND claim_holder_id = delivery.claim_holder_id
402
+ AND result IS NULL;
403
+ UPDATE organization_user_setup_deliveries SET
404
+ state = CASE
405
+ WHEN provider_started_at IS NULL AND unresolved_provider_outcome_at IS NULL
406
+ THEN 'failed'
407
+ ELSE 'outcome_unknown'
408
+ END,
409
+ failed_at = CASE
410
+ WHEN provider_started_at IS NULL AND unresolved_provider_outcome_at IS NULL
411
+ THEN now_value
412
+ ELSE NULL
413
+ END,
414
+ outcome_unknown_at = CASE
415
+ WHEN provider_started_at IS NOT NULL OR unresolved_provider_outcome_at IS NOT NULL
416
+ THEN coalesce(unresolved_provider_outcome_at, now_value)
417
+ ELSE NULL
418
+ END,
419
+ unresolved_provider_outcome_at = CASE
420
+ WHEN provider_started_at IS NOT NULL
421
+ THEN coalesce(unresolved_provider_outcome_at, now_value)
422
+ ELSE unresolved_provider_outcome_at
423
+ END,
424
+ error_class = CASE
425
+ WHEN provider_started_at IS NULL AND unresolved_provider_outcome_at IS NULL
426
+ THEN 'claim_expired_before_provider'
427
+ WHEN provider_started_at IS NULL THEN 'prior_provider_outcome_unresolved'
428
+ ELSE 'provider_started_claim_expired'
429
+ END,
430
+ claim_holder_id = NULL, claim_expires_at = NULL,
431
+ revision = revision + 1, updated_at = now_value
432
+ WHERE id = delivery.id RETURNING * INTO delivery;
433
+ END IF;
434
+
435
+ IF invitation.status <> 'pending' OR invitation.expires_at <= now_value THEN
436
+ IF invitation.status = 'revoked' AND delivery.state <> 'revoked' THEN
437
+ UPDATE organization_user_setup_deliveries SET
438
+ state = 'revoked', revision = revision + 1, revoked_at = now_value,
439
+ claim_holder_id = NULL, claim_expires_at = NULL,
440
+ unresolved_provider_outcome_at = NULL,
441
+ error_class = NULL, updated_at = now_value
442
+ WHERE id = delivery.id RETURNING * INTO delivery;
443
+ END IF;
444
+ RETURN jsonb_build_object(
445
+ 'claimed', false,
446
+ 'delivery', opengeni_private.organization_user_setup_delivery_json(delivery)
447
+ );
448
+ END IF;
449
+ IF delivery.state IN ('sent', 'revoked') THEN
450
+ RETURN jsonb_build_object(
451
+ 'claimed', false,
452
+ 'delivery', opengeni_private.organization_user_setup_delivery_json(delivery)
453
+ );
454
+ END IF;
455
+
456
+ SELECT * INTO attempt FROM organization_user_setup_delivery_attempts candidate
457
+ WHERE candidate.delivery_id = delivery.id AND candidate.operation_id = operation_id_value;
458
+ IF FOUND THEN
459
+ RETURN jsonb_build_object(
460
+ 'claimed', false,
461
+ 'delivery', opengeni_private.organization_user_setup_delivery_json(delivery),
462
+ 'attemptId', attempt.id,
463
+ 'claimHolderId', attempt.claim_holder_id,
464
+ 'invitationId', delivery.invitation_id,
465
+ 'providerKey', delivery.provider_key,
466
+ 'recipientEmail', delivery.recipient_email,
467
+ 'recipientName', delivery.recipient_name,
468
+ 'organizationName', delivery.organization_name,
469
+ 'organizationRole', delivery.organization_role,
470
+ 'sharedWorkspaceAccess', delivery.shared_workspace_access,
471
+ 'expiresAt', invitation.expires_at
472
+ );
473
+ END IF;
474
+
475
+ IF delivery.unresolved_provider_outcome_at IS NOT NULL
476
+ AND (
477
+ delivery.provider_retry_safe_until IS NULL
478
+ OR delivery.provider_retry_safe_until <= now_value
479
+ )
480
+ THEN
481
+ RAISE EXCEPTION 'organization setup delivery requires provider reconciliation'
482
+ USING ERRCODE = '55000';
483
+ END IF;
484
+
485
+ IF delivery.claim_holder_id IS NOT NULL THEN
486
+ RAISE EXCEPTION 'organization setup delivery is already claimed' USING ERRCODE = '55000';
487
+ END IF;
488
+
489
+ INSERT INTO organization_user_setup_delivery_attempts (
490
+ account_id, delivery_id, operation_id, attempt_number,
491
+ claim_holder_id, claim_expires_at
492
+ ) VALUES (
493
+ account_id_value, delivery.id, operation_id_value, delivery.attempt_count + 1,
494
+ gen_random_uuid(), now_value + interval '2 minutes'
495
+ ) RETURNING * INTO attempt;
496
+ UPDATE organization_user_setup_deliveries SET
497
+ state = 'pending', revision = revision + 1,
498
+ claim_holder_id = attempt.claim_holder_id,
499
+ claim_expires_at = attempt.claim_expires_at,
500
+ attempt_count = attempt.attempt_number,
501
+ provider_started_at = NULL, error_class = NULL, provider_message_id = NULL,
502
+ sent_at = NULL, failed_at = NULL, outcome_unknown_at = NULL, revoked_at = NULL,
503
+ updated_at = now_value
504
+ WHERE id = delivery.id RETURNING * INTO delivery;
505
+ RETURN jsonb_build_object(
506
+ 'claimed', true,
507
+ 'delivery', opengeni_private.organization_user_setup_delivery_json(delivery),
508
+ 'attemptId', attempt.id,
509
+ 'claimHolderId', attempt.claim_holder_id,
510
+ 'invitationId', delivery.invitation_id,
511
+ 'providerKey', delivery.provider_key,
512
+ 'recipientEmail', delivery.recipient_email,
513
+ 'recipientName', delivery.recipient_name,
514
+ 'organizationName', delivery.organization_name,
515
+ 'organizationRole', delivery.organization_role,
516
+ 'sharedWorkspaceAccess', delivery.shared_workspace_access,
517
+ 'expiresAt', invitation.expires_at
518
+ );
519
+ END
520
+ $body$;
521
+
522
+ CREATE FUNCTION prepare_organization_user_setup_delivery(p_command jsonb)
523
+ RETURNS jsonb
524
+ LANGUAGE plpgsql SECURITY DEFINER
525
+ SET search_path FROM CURRENT
526
+ AS $body$
527
+ DECLARE
528
+ delivery_id_value uuid := nullif(p_command ->> 'deliveryId', '')::uuid;
529
+ attempt_id_value uuid := nullif(p_command ->> 'attemptId', '')::uuid;
530
+ claim_holder_id_value uuid := nullif(p_command ->> 'claimHolderId', '')::uuid;
531
+ actor_subject text := p_command ->> 'actorSubjectId';
532
+ token_digest_value text := p_command ->> 'tokenDigest';
533
+ payload_digest_value text := p_command ->> 'payloadDigest';
534
+ provider_idempotency_scope_value text := btrim(p_command ->> 'providerIdempotencyScope');
535
+ provider_idempotency_retention_seconds_value integer :=
536
+ nullif(p_command ->> 'providerIdempotencyRetentionSeconds', '')::integer;
537
+ delivery organization_user_setup_deliveries%ROWTYPE;
538
+ invitation organization_membership_invitations%ROWTYPE;
539
+ setup organization_user_setup_intents%ROWTYPE;
540
+ normalized_email text;
541
+ account_id_value uuid;
542
+ invitation_id_value uuid;
543
+ now_value timestamptz := clock_timestamp();
544
+ BEGIN
545
+ IF delivery_id_value IS NULL OR attempt_id_value IS NULL OR claim_holder_id_value IS NULL
546
+ OR actor_subject IS NULL OR actor_subject IS DISTINCT FROM opengeni_private.current_subject_id()
547
+ OR token_digest_value !~ '^[0-9a-f]{64}$'
548
+ OR payload_digest_value !~ '^[0-9a-f]{64}$'
549
+ OR provider_idempotency_scope_value IS NULL
550
+ OR provider_idempotency_scope_value !~ '^[a-z0-9][a-z0-9:._-]*$'
551
+ OR octet_length(convert_to(provider_idempotency_scope_value, 'UTF8')) > 200
552
+ OR provider_idempotency_retention_seconds_value IS NULL
553
+ OR provider_idempotency_retention_seconds_value NOT BETWEEN 0 AND 31536000
554
+ THEN
555
+ RAISE EXCEPTION 'organization setup delivery preparation is invalid' USING ERRCODE = '42501';
556
+ END IF;
557
+ PERFORM set_config(
558
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
559
+ );
560
+ SELECT recipient_email, account_id, invitation_id
561
+ INTO normalized_email, account_id_value, invitation_id_value
562
+ FROM organization_user_setup_deliveries WHERE id = delivery_id_value;
563
+ IF normalized_email IS NULL THEN
564
+ RAISE EXCEPTION 'organization setup delivery is unavailable' USING ERRCODE = 'P0002';
565
+ END IF;
566
+ IF account_id_value IS DISTINCT FROM opengeni_private.current_account_id() THEN
567
+ RAISE EXCEPTION 'organization setup delivery authority is invalid' USING ERRCODE = '42501';
568
+ END IF;
569
+ PERFORM pg_advisory_xact_lock(hashtextextended(
570
+ 'organization-invitation-email:' || normalized_email, 0
571
+ ));
572
+ PERFORM pg_advisory_xact_lock(hashtextextended(
573
+ 'organization-membership:' || account_id_value::text, 0
574
+ ));
575
+ PERFORM 1 FROM managed_accounts account WHERE account.id = account_id_value FOR KEY SHARE;
576
+ PERFORM 1 FROM organization_memberships membership
577
+ WHERE membership.account_id = account_id_value AND membership.subject_id = actor_subject
578
+ AND membership.status = 'active' AND membership.role IN ('owner', 'admin') FOR UPDATE;
579
+ IF NOT FOUND THEN
580
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
581
+ END IF;
582
+ SELECT * INTO invitation FROM organization_membership_invitations candidate
583
+ WHERE candidate.account_id = account_id_value AND candidate.id = invitation_id_value
584
+ FOR UPDATE;
585
+ IF NOT FOUND THEN
586
+ RAISE EXCEPTION 'organization invitation is unavailable' USING ERRCODE = 'P0002';
587
+ END IF;
588
+ SELECT * INTO delivery FROM organization_user_setup_deliveries candidate
589
+ WHERE candidate.account_id = account_id_value
590
+ AND candidate.id = delivery_id_value
591
+ AND candidate.invitation_id = invitation.id
592
+ FOR UPDATE;
593
+ IF NOT FOUND THEN
594
+ RAISE EXCEPTION 'organization setup delivery is unavailable' USING ERRCODE = 'P0002';
595
+ END IF;
596
+ IF delivery.state <> 'pending'
597
+ OR delivery.claim_holder_id IS DISTINCT FROM claim_holder_id_value
598
+ OR delivery.claim_expires_at <= now_value
599
+ OR invitation.status <> 'pending'
600
+ OR invitation.expires_at <= now_value
601
+ THEN
602
+ RAISE EXCEPTION 'organization setup delivery claim is unavailable' USING ERRCODE = '55000';
603
+ END IF;
604
+ PERFORM 1 FROM organization_user_setup_delivery_attempts attempt
605
+ WHERE attempt.id = attempt_id_value AND attempt.delivery_id = delivery.id
606
+ AND attempt.claim_holder_id = claim_holder_id_value AND attempt.result IS NULL
607
+ FOR UPDATE;
608
+ IF NOT FOUND THEN
609
+ RAISE EXCEPTION 'organization setup delivery attempt is unavailable' USING ERRCODE = '55000';
610
+ END IF;
611
+ IF delivery.token_digest IS NOT NULL
612
+ AND delivery.token_digest IS DISTINCT FROM token_digest_value
613
+ THEN
614
+ RAISE EXCEPTION 'organization setup bearer changed under retry' USING ERRCODE = '23505';
615
+ END IF;
616
+ IF delivery.provider_idempotency_scope IS NOT NULL
617
+ AND delivery.provider_idempotency_scope IS DISTINCT FROM provider_idempotency_scope_value
618
+ THEN
619
+ RAISE EXCEPTION 'organization setup provider scope changed under retry'
620
+ USING ERRCODE = '23505';
621
+ END IF;
622
+ IF delivery.payload_digest IS NOT NULL
623
+ AND delivery.payload_digest IS DISTINCT FROM payload_digest_value
624
+ THEN
625
+ RAISE EXCEPTION 'organization setup payload changed under retry' USING ERRCODE = '23505';
626
+ END IF;
627
+ IF delivery.unresolved_provider_outcome_at IS NOT NULL
628
+ AND (
629
+ delivery.provider_retry_safe_until IS NULL
630
+ OR delivery.provider_retry_safe_until <= now_value
631
+ )
632
+ THEN
633
+ RAISE EXCEPTION 'organization setup delivery requires provider reconciliation'
634
+ USING ERRCODE = '55000';
635
+ END IF;
636
+ -- The public 0348 seam remains creator-bound. A later active administrator
637
+ -- may retry this creator-bound durable delivery, so create its immutable
638
+ -- setup intent only after the invitation -> delivery -> attempt locks above.
639
+ INSERT INTO organization_user_setup_intents (
640
+ account_id, invitation_id, token_digest, expires_at
641
+ ) VALUES (
642
+ delivery.account_id, delivery.invitation_id, token_digest_value, invitation.expires_at
643
+ ) ON CONFLICT (account_id, invitation_id) DO NOTHING;
644
+ SELECT * INTO setup FROM organization_user_setup_intents candidate
645
+ WHERE candidate.account_id = delivery.account_id
646
+ AND candidate.invitation_id = delivery.invitation_id;
647
+ IF NOT FOUND
648
+ OR setup.token_digest IS DISTINCT FROM token_digest_value
649
+ OR setup.expires_at IS DISTINCT FROM invitation.expires_at
650
+ THEN
651
+ RAISE EXCEPTION 'organization user setup intent changed under retry'
652
+ USING ERRCODE = '23505';
653
+ END IF;
654
+ UPDATE organization_user_setup_deliveries SET
655
+ setup_intent_id = setup.id,
656
+ token_digest = token_digest_value,
657
+ payload_digest = payload_digest_value,
658
+ provider_idempotency_scope = coalesce(
659
+ provider_idempotency_scope, provider_idempotency_scope_value
660
+ ),
661
+ provider_retry_safe_until = CASE
662
+ WHEN unresolved_provider_outcome_at IS NULL
663
+ THEN now_value + make_interval(secs => provider_idempotency_retention_seconds_value)
664
+ ELSE provider_retry_safe_until
665
+ END,
666
+ provider_retry_window_started_at = CASE
667
+ WHEN unresolved_provider_outcome_at IS NULL THEN now_value
668
+ ELSE coalesce(provider_retry_window_started_at, now_value)
669
+ END,
670
+ provider_started_at = now_value,
671
+ revision = revision + 1,
672
+ updated_at = now_value
673
+ WHERE id = delivery.id RETURNING * INTO delivery;
674
+ UPDATE organization_user_setup_delivery_attempts SET provider_started_at = now_value
675
+ WHERE id = attempt_id_value;
676
+ RETURN jsonb_build_object(
677
+ 'providerKey', delivery.provider_key,
678
+ 'recipientEmail', delivery.recipient_email,
679
+ 'recipientName', delivery.recipient_name,
680
+ 'organizationName', delivery.organization_name,
681
+ 'organizationRole', delivery.organization_role,
682
+ 'sharedWorkspaceAccess', delivery.shared_workspace_access
683
+ );
684
+ END
685
+ $body$;
686
+
687
+ CREATE FUNCTION settle_organization_user_setup_delivery(p_command jsonb)
688
+ RETURNS jsonb
689
+ LANGUAGE plpgsql SECURITY DEFINER
690
+ SET search_path FROM CURRENT
691
+ AS $body$
692
+ DECLARE
693
+ delivery_id_value uuid := nullif(p_command ->> 'deliveryId', '')::uuid;
694
+ attempt_id_value uuid := nullif(p_command ->> 'attemptId', '')::uuid;
695
+ claim_holder_id_value uuid := nullif(p_command ->> 'claimHolderId', '')::uuid;
696
+ actor_subject text := p_command ->> 'actorSubjectId';
697
+ outcome_value text := p_command ->> 'outcome';
698
+ error_class_value text := nullif(btrim(p_command ->> 'errorClass'), '');
699
+ provider_message_id_value text := nullif(btrim(p_command ->> 'providerMessageId'), '');
700
+ delivery organization_user_setup_deliveries%ROWTYPE;
701
+ invitation organization_membership_invitations%ROWTYPE;
702
+ now_value timestamptz := clock_timestamp();
703
+ BEGIN
704
+ IF delivery_id_value IS NULL OR attempt_id_value IS NULL OR claim_holder_id_value IS NULL
705
+ OR actor_subject IS NULL OR actor_subject IS DISTINCT FROM opengeni_private.current_subject_id()
706
+ OR outcome_value NOT IN ('sent', 'failed', 'outcome_unknown')
707
+ OR (outcome_value <> 'sent' AND (
708
+ error_class_value IS NULL OR octet_length(convert_to(error_class_value, 'UTF8')) > 64
709
+ ))
710
+ OR (provider_message_id_value IS NOT NULL
711
+ AND octet_length(convert_to(provider_message_id_value, 'UTF8')) > 255)
712
+ THEN
713
+ RAISE EXCEPTION 'organization setup delivery settlement is invalid' USING ERRCODE = '22023';
714
+ END IF;
715
+ PERFORM set_config(
716
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
717
+ );
718
+ SELECT * INTO delivery FROM organization_user_setup_deliveries candidate
719
+ WHERE candidate.id = delivery_id_value;
720
+ IF NOT FOUND THEN
721
+ RAISE EXCEPTION 'organization setup delivery is unavailable' USING ERRCODE = 'P0002';
722
+ END IF;
723
+ IF delivery.account_id IS DISTINCT FROM opengeni_private.current_account_id() THEN
724
+ RAISE EXCEPTION 'organization setup delivery authority is invalid' USING ERRCODE = '42501';
725
+ END IF;
726
+ PERFORM pg_advisory_xact_lock(hashtextextended(
727
+ 'organization-invitation-email:' || delivery.recipient_email, 0
728
+ ));
729
+ PERFORM pg_advisory_xact_lock(hashtextextended(
730
+ 'organization-membership:' || delivery.account_id::text, 0
731
+ ));
732
+ PERFORM 1 FROM managed_accounts account WHERE account.id = delivery.account_id FOR KEY SHARE;
733
+ PERFORM 1 FROM organization_memberships membership
734
+ WHERE membership.account_id = delivery.account_id AND membership.subject_id = actor_subject
735
+ AND membership.status = 'active' AND membership.role IN ('owner', 'admin') FOR UPDATE;
736
+ IF NOT FOUND THEN
737
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
738
+ END IF;
739
+ SELECT * INTO invitation FROM organization_membership_invitations candidate
740
+ WHERE candidate.account_id = delivery.account_id AND candidate.id = delivery.invitation_id
741
+ FOR UPDATE;
742
+ SELECT * INTO delivery FROM organization_user_setup_deliveries candidate
743
+ WHERE candidate.id = delivery_id_value FOR UPDATE;
744
+ IF delivery.state = 'revoked' AND delivery.claim_holder_id IS NULL THEN
745
+ PERFORM 1 FROM organization_user_setup_delivery_attempts attempt
746
+ WHERE attempt.id = attempt_id_value AND attempt.delivery_id = delivery.id
747
+ AND attempt.claim_holder_id = claim_holder_id_value AND attempt.result = 'revoked';
748
+ IF FOUND THEN
749
+ RETURN opengeni_private.organization_user_setup_delivery_json(delivery);
750
+ END IF;
751
+ END IF;
752
+ IF delivery.claim_holder_id IS DISTINCT FROM claim_holder_id_value THEN
753
+ RAISE EXCEPTION 'organization setup delivery claim changed' USING ERRCODE = '40001';
754
+ END IF;
755
+ PERFORM 1 FROM organization_user_setup_delivery_attempts attempt
756
+ WHERE attempt.id = attempt_id_value AND attempt.delivery_id = delivery.id
757
+ AND attempt.claim_holder_id = claim_holder_id_value AND attempt.result IS NULL
758
+ AND attempt.provider_started_at IS NOT NULL
759
+ FOR UPDATE;
760
+ IF NOT FOUND OR delivery.provider_started_at IS NULL THEN
761
+ RAISE EXCEPTION 'organization setup delivery attempt changed' USING ERRCODE = '40001';
762
+ END IF;
763
+ UPDATE organization_user_setup_delivery_attempts SET
764
+ result = CASE WHEN invitation.status = 'pending' THEN outcome_value ELSE 'revoked' END,
765
+ error_class = error_class_value,
766
+ provider_message_id = provider_message_id_value,
767
+ settled_at = now_value
768
+ WHERE id = attempt_id_value;
769
+ IF invitation.status <> 'pending' OR delivery.state = 'revoked' THEN
770
+ UPDATE organization_user_setup_deliveries SET
771
+ state = 'revoked', revoked_at = coalesce(revoked_at, now_value),
772
+ unresolved_provider_outcome_at = NULL,
773
+ claim_holder_id = NULL, claim_expires_at = NULL,
774
+ revision = revision + 1, updated_at = now_value
775
+ WHERE id = delivery.id RETURNING * INTO delivery;
776
+ ELSE
777
+ UPDATE organization_user_setup_deliveries SET
778
+ state = outcome_value,
779
+ error_class = CASE WHEN outcome_value = 'sent' THEN NULL ELSE error_class_value END,
780
+ provider_message_id = provider_message_id_value,
781
+ sent_at = CASE WHEN outcome_value = 'sent' THEN now_value ELSE NULL END,
782
+ failed_at = CASE WHEN outcome_value = 'failed' THEN now_value ELSE NULL END,
783
+ outcome_unknown_at = CASE WHEN outcome_value = 'outcome_unknown' THEN now_value ELSE NULL END,
784
+ unresolved_provider_outcome_at = CASE
785
+ WHEN outcome_value = 'outcome_unknown'
786
+ THEN coalesce(unresolved_provider_outcome_at, now_value)
787
+ ELSE NULL
788
+ END,
789
+ revoked_at = NULL,
790
+ claim_holder_id = NULL, claim_expires_at = NULL,
791
+ revision = revision + 1, updated_at = now_value
792
+ WHERE id = delivery.id RETURNING * INTO delivery;
793
+ END IF;
794
+ RETURN opengeni_private.organization_user_setup_delivery_json(delivery);
795
+ END
796
+ $body$;
797
+
798
+ CREATE FUNCTION get_organization_invitation_for_administration(
799
+ p_account_id uuid,
800
+ p_actor_subject_id text,
801
+ p_invitation_id uuid
802
+ ) RETURNS jsonb
803
+ LANGUAGE plpgsql SECURITY DEFINER
804
+ SET search_path FROM CURRENT
805
+ AS $body$
806
+ DECLARE
807
+ invitation organization_membership_invitations%ROWTYPE;
808
+ BEGIN
809
+ IF p_account_id IS NULL
810
+ OR p_account_id IS DISTINCT FROM opengeni_private.current_account_id()
811
+ OR p_actor_subject_id IS NULL
812
+ OR p_actor_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
813
+ OR p_invitation_id IS NULL
814
+ THEN
815
+ RAISE EXCEPTION 'organization invitation authority is invalid' USING ERRCODE = '42501';
816
+ END IF;
817
+ PERFORM set_config(
818
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
819
+ );
820
+ PERFORM 1 FROM organization_memberships membership
821
+ WHERE membership.account_id = p_account_id
822
+ AND membership.subject_id = p_actor_subject_id
823
+ AND membership.status = 'active'
824
+ AND membership.role IN ('owner', 'admin');
825
+ IF NOT FOUND THEN
826
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
827
+ END IF;
828
+ SELECT * INTO invitation FROM organization_membership_invitations candidate
829
+ WHERE candidate.account_id = p_account_id AND candidate.id = p_invitation_id;
830
+ IF NOT FOUND THEN
831
+ RAISE EXCEPTION 'organization invitation is unavailable' USING ERRCODE = 'P0002';
832
+ END IF;
833
+ RETURN opengeni_private.organization_invitation_row_json(invitation);
834
+ END
835
+ $body$;
836
+
837
+ CREATE FUNCTION preview_organization_user_setup(p_token_digest text)
838
+ RETURNS jsonb
839
+ LANGUAGE plpgsql SECURITY DEFINER
840
+ SET search_path FROM CURRENT
841
+ AS $body$
842
+ DECLARE
843
+ delivery organization_user_setup_deliveries%ROWTYPE;
844
+ invitation organization_membership_invitations%ROWTYPE;
845
+ setup organization_user_setup_intents%ROWTYPE;
846
+ BEGIN
847
+ IF p_token_digest IS NULL OR p_token_digest !~ '^[0-9a-f]{64}$' THEN
848
+ RETURN jsonb_build_object('state', 'unavailable');
849
+ END IF;
850
+ PERFORM set_config(
851
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
852
+ );
853
+ SELECT * INTO delivery FROM organization_user_setup_deliveries candidate
854
+ WHERE candidate.token_digest = p_token_digest;
855
+ IF NOT FOUND THEN RETURN jsonb_build_object('state', 'unavailable'); END IF;
856
+ SELECT * INTO setup FROM organization_user_setup_intents candidate
857
+ WHERE candidate.id = delivery.setup_intent_id;
858
+ IF NOT FOUND THEN RETURN jsonb_build_object('state', 'unavailable'); END IF;
859
+ IF setup.status = 'completed' THEN
860
+ RETURN jsonb_build_object('state', 'completed');
861
+ END IF;
862
+ SELECT * INTO invitation FROM organization_membership_invitations candidate
863
+ WHERE candidate.account_id = delivery.account_id AND candidate.id = delivery.invitation_id;
864
+ IF NOT FOUND THEN RETURN jsonb_build_object('state', 'unavailable'); END IF;
865
+ IF invitation.status = 'revoked' OR delivery.state = 'revoked' THEN
866
+ RETURN jsonb_build_object('state', 'revoked');
867
+ END IF;
868
+ IF invitation.status = 'accepted' THEN RETURN jsonb_build_object('state', 'completed'); END IF;
869
+ IF invitation.status <> 'pending' THEN RETURN jsonb_build_object('state', 'unavailable'); END IF;
870
+ IF invitation.expires_at <= clock_timestamp() OR setup.expires_at <= clock_timestamp() THEN
871
+ RETURN jsonb_build_object('state', 'expired');
872
+ END IF;
873
+ RETURN jsonb_build_object(
874
+ 'state', 'pending',
875
+ 'organizationId', delivery.account_id,
876
+ 'organizationName', delivery.organization_name,
877
+ 'targetEmail', delivery.recipient_email,
878
+ 'targetName', delivery.recipient_name,
879
+ 'organizationRole', delivery.organization_role,
880
+ 'sharedWorkspaceAccess', delivery.shared_workspace_access,
881
+ 'expiresAt', invitation.expires_at
882
+ );
883
+ END
884
+ $body$;
885
+
886
+ CREATE FUNCTION sync_organization_user_setup_delivery_revocation()
887
+ RETURNS trigger
888
+ LANGUAGE plpgsql
889
+ SET search_path FROM CURRENT
890
+ AS $body$
891
+ DECLARE
892
+ delivery organization_user_setup_deliveries%ROWTYPE;
893
+ now_value timestamptz := clock_timestamp();
894
+ BEGIN
895
+ IF NEW.status = 'revoked' AND OLD.status IS DISTINCT FROM NEW.status THEN
896
+ SELECT * INTO delivery FROM organization_user_setup_deliveries candidate
897
+ WHERE candidate.account_id = NEW.account_id AND candidate.invitation_id = NEW.id
898
+ FOR UPDATE;
899
+ IF NOT FOUND THEN RETURN NULL; END IF;
900
+ UPDATE organization_user_setup_delivery_attempts SET
901
+ result = 'revoked', settled_at = now_value
902
+ WHERE delivery_id = delivery.id
903
+ AND claim_holder_id = delivery.claim_holder_id
904
+ AND result IS NULL;
905
+ UPDATE organization_user_setup_deliveries SET
906
+ state = 'revoked', revoked_at = coalesce(revoked_at, now_value),
907
+ claim_holder_id = NULL, claim_expires_at = NULL,
908
+ unresolved_provider_outcome_at = NULL,
909
+ error_class = NULL, revision = revision + 1, updated_at = now_value
910
+ WHERE id = delivery.id AND state <> 'revoked';
911
+ END IF;
912
+ RETURN NULL;
913
+ END
914
+ $body$;
915
+
916
+ CREATE TRIGGER organization_user_setup_delivery_revocation
917
+ AFTER UPDATE OF status ON organization_membership_invitations
918
+ FOR EACH ROW EXECUTE FUNCTION sync_organization_user_setup_delivery_revocation();
919
+
920
+ DO $pin_and_grant$
921
+ DECLARE data_schema text := current_schema();
922
+ BEGIN
923
+ EXECUTE format(
924
+ 'ALTER FUNCTION opengeni_private.organization_invitation_row_json(%I.organization_membership_invitations) SET search_path = pg_catalog, %I, pg_temp',
925
+ data_schema, data_schema
926
+ );
927
+ EXECUTE format(
928
+ 'ALTER FUNCTION %I.claim_organization_user_setup_delivery(jsonb) SET search_path = pg_catalog, %I, pg_temp',
929
+ data_schema, data_schema
930
+ );
931
+ EXECUTE format(
932
+ 'ALTER FUNCTION %I.prepare_organization_user_setup_delivery(jsonb) SET search_path = pg_catalog, %I, pg_temp',
933
+ data_schema, data_schema
934
+ );
935
+ EXECUTE format(
936
+ 'ALTER FUNCTION %I.settle_organization_user_setup_delivery(jsonb) SET search_path = pg_catalog, %I, pg_temp',
937
+ data_schema, data_schema
938
+ );
939
+ EXECUTE format(
940
+ 'ALTER FUNCTION %I.preview_organization_user_setup(text) SET search_path = pg_catalog, %I, pg_temp',
941
+ data_schema, data_schema
942
+ );
943
+ EXECUTE format(
944
+ 'ALTER FUNCTION %I.get_organization_invitation_for_administration(uuid,text,uuid) SET search_path = pg_catalog, %I, pg_temp',
945
+ data_schema, data_schema
946
+ );
947
+ EXECUTE format(
948
+ 'ALTER FUNCTION %I.sync_organization_user_setup_delivery_revocation() SET search_path = pg_catalog, %I, pg_temp',
949
+ data_schema, data_schema
950
+ );
951
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
952
+ EXECUTE format('REVOKE ALL ON TABLE %I.organization_user_setup_deliveries FROM opengeni_app', data_schema);
953
+ EXECUTE format('REVOKE ALL ON TABLE %I.organization_user_setup_delivery_attempts FROM opengeni_app', data_schema);
954
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.claim_organization_user_setup_delivery(jsonb) TO opengeni_app', data_schema);
955
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.prepare_organization_user_setup_delivery(jsonb) TO opengeni_app', data_schema);
956
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.settle_organization_user_setup_delivery(jsonb) TO opengeni_app', data_schema);
957
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.preview_organization_user_setup(text) TO opengeni_app', data_schema);
958
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.get_organization_invitation_for_administration(uuid,text,uuid) TO opengeni_app', data_schema);
959
+ END IF;
960
+ END
961
+ $pin_and_grant$;
962
+
963
+ REVOKE ALL ON TABLE organization_user_setup_deliveries FROM PUBLIC;
964
+ REVOKE ALL ON TABLE organization_user_setup_delivery_attempts FROM PUBLIC;
965
+ REVOKE ALL ON FUNCTION opengeni_private.organization_user_setup_delivery_json(
966
+ organization_user_setup_deliveries
967
+ ) FROM PUBLIC;
968
+ REVOKE ALL ON FUNCTION claim_organization_user_setup_delivery(jsonb) FROM PUBLIC;
969
+ REVOKE ALL ON FUNCTION prepare_organization_user_setup_delivery(jsonb) FROM PUBLIC;
970
+ REVOKE ALL ON FUNCTION settle_organization_user_setup_delivery(jsonb) FROM PUBLIC;
971
+ REVOKE ALL ON FUNCTION preview_organization_user_setup(text) FROM PUBLIC;
972
+ REVOKE ALL ON FUNCTION get_organization_invitation_for_administration(uuid,text,uuid) FROM PUBLIC;
973
+ REVOKE ALL ON FUNCTION sync_organization_user_setup_delivery_revocation() FROM PUBLIC;
974
+
975
+ COMMENT ON TABLE organization_user_setup_deliveries IS
976
+ 'Digest-only durable projection for invitation setup email claims, stable provider identity, and reconciliation.';
977
+ COMMENT ON TABLE organization_user_setup_delivery_attempts IS
978
+ 'Lifecycle-only immutable-attempt journal for invitation setup delivery provider boundaries.';