@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,838 @@
1
+ -- deployment-mode: maintenance
2
+ -- Post-sign-in self-service organization onboarding and invitation-bound
3
+ -- account setup. Ordinary Better Auth signup remains an ordinary user create;
4
+ -- a separate authenticated lifecycle creates exactly one active organization
5
+ -- membership and its canonical personal workspace. Invitation setup remains a
6
+ -- separate signed-out, hashed-bearer transaction.
7
+ -- This is a one-way application protocol cutover: stop every API, control
8
+ -- worker, and turn worker before applying it, and never restart a pre-0348
9
+ -- image. Old managed-access writers can still synthesize a fallback
10
+ -- organization and Default workspace, while old clients do not speak the
11
+ -- Personal-only setup contract.
12
+
13
+ SET LOCAL lock_timeout = '5s';
14
+ SET LOCAL statement_timeout = '5min';
15
+
16
+ DO $named_signup_writer_drain_before_lock$
17
+ DECLARE
18
+ configured_roles_text text := nullif(
19
+ current_setting('opengeni.migration_application_roles', true), ''
20
+ );
21
+ configured_roles jsonb;
22
+ BEGIN
23
+ IF configured_roles_text IS NULL THEN
24
+ RAISE EXCEPTION
25
+ '0348 named signup activation requires an explicit application database role list'
26
+ USING ERRCODE = '55000';
27
+ END IF;
28
+ BEGIN
29
+ configured_roles := configured_roles_text::jsonb;
30
+ EXCEPTION WHEN OTHERS THEN
31
+ RAISE EXCEPTION
32
+ '0348 named signup activation received a malformed application database role list'
33
+ USING ERRCODE = '55000';
34
+ END;
35
+ IF jsonb_typeof(configured_roles) <> 'array'
36
+ OR jsonb_array_length(configured_roles) NOT BETWEEN 1 AND 16
37
+ OR EXISTS (
38
+ SELECT 1 FROM jsonb_array_elements(configured_roles) AS roles(value)
39
+ WHERE jsonb_typeof(value) <> 'string'
40
+ OR btrim(value #>> '{}') = ''
41
+ OR octet_length(value #>> '{}') > 63
42
+ )
43
+ OR (
44
+ SELECT count(*) FROM jsonb_array_elements_text(configured_roles)
45
+ ) <> (
46
+ SELECT count(DISTINCT value)
47
+ FROM jsonb_array_elements_text(configured_roles) AS roles(value)
48
+ )
49
+ THEN
50
+ RAISE EXCEPTION
51
+ '0348 named signup activation received an invalid application database role list'
52
+ USING ERRCODE = '55000';
53
+ END IF;
54
+ IF EXISTS (
55
+ SELECT 1 FROM pg_stat_activity activity
56
+ JOIN jsonb_array_elements_text(configured_roles) roles(role_name)
57
+ ON roles.role_name = activity.usename
58
+ WHERE activity.datname = current_database()
59
+ AND activity.pid <> pg_backend_pid()
60
+ )
61
+ THEN
62
+ RAISE EXCEPTION
63
+ '0348 named signup activation requires all configured OpenGeni application database sessions to be stopped'
64
+ USING ERRCODE = '55000';
65
+ END IF;
66
+ END
67
+ $named_signup_writer_drain_before_lock$;
68
+
69
+ LOCK TABLE auth_users IN ACCESS EXCLUSIVE MODE;
70
+ LOCK TABLE auth_identities IN ACCESS EXCLUSIVE MODE;
71
+ LOCK TABLE managed_accounts IN ACCESS EXCLUSIVE MODE;
72
+ LOCK TABLE organization_membership_invitations IN ACCESS EXCLUSIVE MODE;
73
+ LOCK TABLE organization_memberships IN ACCESS EXCLUSIVE MODE;
74
+ LOCK TABLE workspaces IN ACCESS EXCLUSIVE MODE;
75
+ LOCK TABLE workspace_memberships IN ACCESS EXCLUSIVE MODE;
76
+
77
+ DO $named_signup_writer_drain_after_lock$
78
+ DECLARE
79
+ configured_roles jsonb := current_setting(
80
+ 'opengeni.migration_application_roles', false
81
+ )::jsonb;
82
+ BEGIN
83
+ IF EXISTS (
84
+ SELECT 1 FROM pg_stat_activity activity
85
+ JOIN jsonb_array_elements_text(configured_roles) roles(role_name)
86
+ ON roles.role_name = activity.usename
87
+ WHERE activity.datname = current_database()
88
+ AND activity.pid <> pg_backend_pid()
89
+ )
90
+ THEN
91
+ RAISE EXCEPTION
92
+ '0348 named signup activation requires all configured OpenGeni application database sessions to be stopped'
93
+ USING ERRCODE = '55000';
94
+ END IF;
95
+ END
96
+ $named_signup_writer_drain_after_lock$;
97
+
98
+ CREATE TABLE self_service_organization_setup_receipts (
99
+ auth_user_id text PRIMARY KEY REFERENCES auth_users(id) ON DELETE RESTRICT,
100
+ operation_id uuid NOT NULL UNIQUE,
101
+ request_fingerprint text NOT NULL,
102
+ account_id uuid NOT NULL UNIQUE REFERENCES managed_accounts(id) ON DELETE RESTRICT,
103
+ personal_workspace_id uuid NOT NULL UNIQUE REFERENCES workspaces(id) ON DELETE RESTRICT,
104
+ organization_membership_id uuid NOT NULL UNIQUE
105
+ REFERENCES organization_memberships(id) ON DELETE RESTRICT,
106
+ organization_name text NOT NULL,
107
+ result jsonb NOT NULL,
108
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
109
+ CONSTRAINT self_service_organization_setup_fingerprint_check CHECK (
110
+ request_fingerprint ~ '^[0-9a-f]{64}$'
111
+ ),
112
+ CONSTRAINT self_service_organization_setup_name_check CHECK (
113
+ organization_name = btrim(organization_name)
114
+ AND octet_length(convert_to(organization_name, 'UTF8')) BETWEEN 1 AND 120
115
+ )
116
+ );
117
+ ALTER TABLE self_service_organization_setup_receipts ENABLE ROW LEVEL SECURITY;
118
+ ALTER TABLE self_service_organization_setup_receipts FORCE ROW LEVEL SECURITY;
119
+ CREATE POLICY organization_tenancy_lifecycle
120
+ ON self_service_organization_setup_receipts
121
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
122
+ = 'organization_membership_lifecycle')
123
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
124
+ = 'organization_membership_lifecycle');
125
+
126
+ CREATE FUNCTION complete_self_service_organization_setup(p_command jsonb)
127
+ RETURNS jsonb
128
+ LANGUAGE plpgsql
129
+ SECURITY DEFINER
130
+ SET search_path FROM CURRENT
131
+ AS $body$
132
+ DECLARE
133
+ auth_user_id_value text := p_command ->> 'authUserId';
134
+ actor_subject text := p_command ->> 'actorSubjectId';
135
+ organization_name_value text := nullif(btrim(p_command ->> 'organizationName'), '');
136
+ operation_id_value uuid := nullif(p_command ->> 'operationId', '')::uuid;
137
+ fingerprint_value text := p_command ->> 'requestFingerprint';
138
+ auth_user auth_users%ROWTYPE;
139
+ account_row managed_accounts%ROWTYPE;
140
+ existing_receipt self_service_organization_setup_receipts%ROWTYPE;
141
+ adopted_account managed_accounts%ROWTYPE;
142
+ adopt_existing_account boolean := false;
143
+ account_id_value uuid := gen_random_uuid();
144
+ workspace_id_value uuid := gen_random_uuid();
145
+ membership_id_value uuid;
146
+ existing_membership_count integer;
147
+ public_result jsonb;
148
+ BEGIN
149
+ IF auth_user_id_value IS NULL
150
+ OR octet_length(convert_to(auth_user_id_value, 'UTF8')) NOT BETWEEN 1 AND 1019
151
+ OR actor_subject IS NULL
152
+ OR actor_subject IS DISTINCT FROM opengeni_private.current_subject_id()
153
+ OR actor_subject IS DISTINCT FROM 'user:' || auth_user_id_value
154
+ OR organization_name_value IS NULL
155
+ OR octet_length(convert_to(organization_name_value, 'UTF8')) NOT BETWEEN 1 AND 120
156
+ OR operation_id_value IS NULL
157
+ OR fingerprint_value !~ '^[0-9a-f]{64}$'
158
+ THEN
159
+ RAISE EXCEPTION 'self-service organization setup input is invalid'
160
+ USING ERRCODE = '22023';
161
+ END IF;
162
+
163
+ -- Serialize all setup attempts for this exact Better Auth identity. The
164
+ -- verified-email invitation binder then takes its canonical email fence,
165
+ -- ensuring a pre-existing invitation always wins over organization creation.
166
+ PERFORM pg_catalog.pg_advisory_xact_lock(
167
+ pg_catalog.hashtextextended('self-service-organization:' || auth_user_id_value, 0)
168
+ );
169
+ SELECT * INTO auth_user FROM auth_users candidate
170
+ WHERE candidate.id = auth_user_id_value FOR UPDATE;
171
+ IF NOT FOUND
172
+ OR auth_user.email_verified IS NOT TRUE
173
+ OR lower(btrim(auth_user.email)) = ''
174
+ THEN
175
+ RAISE EXCEPTION 'verified managed user required' USING ERRCODE = '42501';
176
+ END IF;
177
+
178
+ PERFORM pg_catalog.set_config(
179
+ 'opengeni.organization_tenancy_lifecycle',
180
+ 'organization_membership_lifecycle', true
181
+ );
182
+ PERFORM bind_pending_organization_invitations_for_verified_email(
183
+ actor_subject, auth_user.email
184
+ );
185
+
186
+ SELECT * INTO existing_receipt
187
+ FROM self_service_organization_setup_receipts receipt
188
+ WHERE receipt.auth_user_id = auth_user_id_value
189
+ FOR UPDATE;
190
+ IF FOUND THEN
191
+ IF existing_receipt.operation_id IS DISTINCT FROM operation_id_value
192
+ OR existing_receipt.request_fingerprint IS DISTINCT FROM fingerprint_value
193
+ OR existing_receipt.organization_name IS DISTINCT FROM organization_name_value
194
+ THEN
195
+ RAISE EXCEPTION 'self-service organization setup was already completed'
196
+ USING ERRCODE = '23505';
197
+ END IF;
198
+ RETURN existing_receipt.result;
199
+ END IF;
200
+
201
+ SELECT count(*)::integer INTO existing_membership_count
202
+ FROM organization_memberships membership
203
+ WHERE membership.subject_id = actor_subject;
204
+ IF existing_membership_count <> 0
205
+ OR has_pending_organization_invitation_for_subject(actor_subject)
206
+ THEN
207
+ RAISE EXCEPTION 'organization setup is no longer available'
208
+ USING ERRCODE = '55000';
209
+ END IF;
210
+
211
+ -- A pre-0348 image could leave this exact Better Auth user a legacy
212
+ -- `better-auth:user` fallback organization whose organization membership was
213
+ -- never backfilled: migration 0290 only anchored subjects that already held
214
+ -- workspace access. Refusing that shape dead-ends the human permanently -
215
+ -- managed-access convergence no longer self-heals it, and this is a one-way
216
+ -- cutover - so adopt the orphaned account instead of creating a second one.
217
+ -- Adoption keeps the human's existing account identity (and any legacy
218
+ -- workspace grants they already hold inside it) and needs no migration-time
219
+ -- backfill over a FORCE-RLS table. It is only safe while that account carries
220
+ -- NO organization membership at all: one that already has memberships
221
+ -- describes a state this lifecycle must not reinterpret, and granting owner
222
+ -- there would be a privilege event rather than a repair.
223
+ SELECT * INTO adopted_account FROM managed_accounts account
224
+ WHERE account.external_source = 'better-auth:user'
225
+ AND account.external_id = auth_user_id_value;
226
+ IF FOUND THEN
227
+ IF EXISTS (
228
+ SELECT 1 FROM organization_memberships membership
229
+ WHERE membership.account_id = adopted_account.id
230
+ ) THEN
231
+ RAISE EXCEPTION 'organization setup is no longer available'
232
+ USING ERRCODE = '55000';
233
+ END IF;
234
+ account_id_value := adopted_account.id;
235
+ adopt_existing_account := true;
236
+ END IF;
237
+
238
+ -- Join the canonical organization -> workspace lock order before acquiring
239
+ -- any workspace state. Never hold managed_accounts FOR UPDATE across this
240
+ -- prefix: an adopted account can still have ordinary workspace writers, and
241
+ -- migration 0299 fences exactly that deadlock, so the adoption rename is
242
+ -- deferred until after the workspace prefix below.
243
+ PERFORM pg_catalog.pg_advisory_xact_lock(
244
+ pg_catalog.hashtextextended('organization-membership:' || account_id_value::text, 0)
245
+ );
246
+ IF NOT adopt_existing_account THEN
247
+ INSERT INTO managed_accounts (id, name, external_source, external_id)
248
+ VALUES (
249
+ account_id_value, organization_name_value,
250
+ 'better-auth:user', auth_user_id_value
251
+ ) RETURNING * INTO account_row;
252
+ END IF;
253
+ PERFORM pg_catalog.set_config('opengeni.account_id', account_id_value::text, true);
254
+ PERFORM pg_catalog.set_config('opengeni.workspace_id', workspace_id_value::text, true);
255
+
256
+ INSERT INTO workspaces (
257
+ id, account_id, name, slug, external_source, external_id
258
+ ) VALUES (
259
+ workspace_id_value, account_id_value, 'Personal workspace', NULL,
260
+ 'opengeni:organization-membership', account_id_value::text || ':' || actor_subject
261
+ );
262
+ INSERT INTO workspace_inference_controls (workspace_id, account_id)
263
+ VALUES (workspace_id_value, account_id_value);
264
+ INSERT INTO organization_memberships (
265
+ account_id, subject_id, role, status, personal_workspace_id
266
+ ) VALUES (
267
+ account_id_value, actor_subject, 'owner', 'active', workspace_id_value
268
+ ) RETURNING id INTO membership_id_value;
269
+
270
+ IF adopt_existing_account THEN
271
+ -- Only now, with every workspace row already acquired, is an organization
272
+ -- row write safe. The one-shot signup name is this human's first and only
273
+ -- chance to name the organization - they never had access to the orphaned
274
+ -- account - so this is initial provisioning, not the managed-access
275
+ -- convergence rename that the access refresh path must never perform.
276
+ UPDATE managed_accounts SET
277
+ name = organization_name_value,
278
+ updated_at = pg_catalog.clock_timestamp()
279
+ WHERE id = account_id_value
280
+ RETURNING * INTO account_row;
281
+ END IF;
282
+
283
+ public_result := pg_catalog.jsonb_build_object(
284
+ 'status', 'complete',
285
+ 'organizationId', account_id_value,
286
+ 'personalWorkspaceId', workspace_id_value,
287
+ 'organization', pg_catalog.jsonb_build_object(
288
+ 'id', account_row.id,
289
+ 'name', account_row.name,
290
+ 'createdAt', account_row.created_at,
291
+ 'updatedAt', account_row.updated_at
292
+ ),
293
+ 'workspaceId', workspace_id_value
294
+ );
295
+ INSERT INTO self_service_organization_setup_receipts (
296
+ auth_user_id, operation_id, request_fingerprint, account_id,
297
+ personal_workspace_id, organization_membership_id,
298
+ organization_name, result
299
+ ) VALUES (
300
+ auth_user_id_value, operation_id_value, fingerprint_value, account_id_value,
301
+ workspace_id_value, membership_id_value, organization_name_value, public_result
302
+ );
303
+ RETURN public_result;
304
+ END
305
+ $body$;
306
+
307
+ -- Preserve the API/SDK signature introduced by 0331 while forwarding every
308
+ -- rolling caller through the corrected one-time setup lifecycle above. The
309
+ -- legacy subject label is deliberately non-authoritative.
310
+ CREATE OR REPLACE FUNCTION create_managed_organization(
311
+ p_subject_id text,
312
+ p_subject_label text,
313
+ p_name text,
314
+ p_operation_id uuid
315
+ ) RETURNS jsonb
316
+ LANGUAGE plpgsql
317
+ SECURITY DEFINER
318
+ SET search_path FROM CURRENT
319
+ AS $body$
320
+ DECLARE
321
+ auth_user_id_value text := substring(p_subject_id from length('user:') + 1);
322
+ requested_name text := nullif(pg_catalog.btrim(p_name), '');
323
+ fingerprint_value text;
324
+ BEGIN
325
+ IF p_subject_id IS NULL
326
+ OR p_subject_id NOT LIKE 'user:%'
327
+ OR p_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
328
+ OR requested_name IS NULL
329
+ OR pg_catalog.octet_length(pg_catalog.convert_to(requested_name, 'UTF8')) NOT BETWEEN 1 AND 120
330
+ OR p_operation_id IS NULL
331
+ THEN
332
+ RAISE EXCEPTION 'managed organization creation input is invalid'
333
+ USING ERRCODE = '22023';
334
+ END IF;
335
+
336
+ fingerprint_value := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
337
+ 'opengeni:self-service-organization:v1:' || p_subject_id || ':' ||
338
+ pg_catalog.octet_length(pg_catalog.convert_to(requested_name, 'UTF8'))::text || ':' ||
339
+ requested_name,
340
+ 'UTF8'
341
+ )), 'hex');
342
+
343
+ RETURN complete_self_service_organization_setup(pg_catalog.jsonb_build_object(
344
+ 'authUserId', auth_user_id_value,
345
+ 'actorSubjectId', p_subject_id,
346
+ 'organizationName', requested_name,
347
+ 'operationId', p_operation_id,
348
+ 'requestFingerprint', fingerprint_value
349
+ ));
350
+ END
351
+ $body$;
352
+
353
+ CREATE TABLE organization_user_setup_intents (
354
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
355
+ account_id uuid NOT NULL REFERENCES managed_accounts(id) ON DELETE CASCADE,
356
+ invitation_id uuid NOT NULL,
357
+ token_digest text NOT NULL,
358
+ expires_at timestamptz NOT NULL,
359
+ status text NOT NULL DEFAULT 'pending',
360
+ completion_operation_id uuid,
361
+ completion_request_fingerprint text,
362
+ completed_auth_user_id text REFERENCES auth_users(id) ON DELETE RESTRICT,
363
+ completion_result jsonb,
364
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
365
+ updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
366
+ completed_at timestamptz,
367
+ CONSTRAINT organization_user_setup_intents_invitation_fk
368
+ FOREIGN KEY (invitation_id, account_id)
369
+ REFERENCES organization_membership_invitations(id, account_id) ON DELETE RESTRICT,
370
+ CONSTRAINT organization_user_setup_intents_invitation_uq UNIQUE (account_id, invitation_id),
371
+ CONSTRAINT organization_user_setup_intents_token_uq UNIQUE (token_digest),
372
+ CONSTRAINT organization_user_setup_intents_token_check CHECK (
373
+ token_digest ~ '^[0-9a-f]{64}$'
374
+ ),
375
+ CONSTRAINT organization_user_setup_intents_status_check CHECK (
376
+ status IN ('pending', 'completed')
377
+ ),
378
+ CONSTRAINT organization_user_setup_intents_completion_check CHECK (
379
+ (status = 'pending'
380
+ AND completion_operation_id IS NULL
381
+ AND completion_request_fingerprint IS NULL
382
+ AND completed_auth_user_id IS NULL
383
+ AND completion_result IS NULL
384
+ AND completed_at IS NULL)
385
+ OR
386
+ (status = 'completed'
387
+ AND completion_operation_id IS NOT NULL
388
+ AND completion_request_fingerprint IS NOT NULL
389
+ AND completion_request_fingerprint ~ '^[0-9a-f]{64}$'
390
+ AND completed_auth_user_id IS NOT NULL
391
+ AND completion_result IS NOT NULL
392
+ AND completed_at IS NOT NULL)
393
+ )
394
+ );
395
+
396
+ ALTER TABLE organization_user_setup_intents ENABLE ROW LEVEL SECURITY;
397
+ ALTER TABLE organization_user_setup_intents FORCE ROW LEVEL SECURITY;
398
+ CREATE POLICY organization_tenancy_lifecycle ON organization_user_setup_intents
399
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
400
+ = 'organization_membership_lifecycle')
401
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
402
+ = 'organization_membership_lifecycle');
403
+
404
+ CREATE FUNCTION ensure_organization_user_setup_intent(p_command jsonb)
405
+ RETURNS jsonb
406
+ LANGUAGE plpgsql
407
+ SECURITY DEFINER
408
+ SET search_path FROM CURRENT
409
+ AS $body$
410
+ DECLARE
411
+ account_id_value uuid := nullif(p_command ->> 'organizationId', '')::uuid;
412
+ actor_subject text := p_command ->> 'actorSubjectId';
413
+ invitation_id_value uuid := nullif(p_command ->> 'invitationId', '')::uuid;
414
+ invitation organization_membership_invitations%ROWTYPE;
415
+ actor organization_memberships%ROWTYPE;
416
+ setup organization_user_setup_intents%ROWTYPE;
417
+ digest_value text := p_command ->> 'tokenDigest';
418
+ expires_at_value timestamptz := nullif(p_command ->> 'expiresAt', '')::timestamptz;
419
+ BEGIN
420
+ IF account_id_value IS NULL
421
+ OR account_id_value IS DISTINCT FROM opengeni_private.current_account_id()
422
+ OR actor_subject IS NULL
423
+ OR actor_subject IS DISTINCT FROM opengeni_private.current_subject_id()
424
+ OR invitation_id_value IS NULL
425
+ OR digest_value !~ '^[0-9a-f]{64}$'
426
+ OR expires_at_value IS NULL
427
+ THEN
428
+ RAISE EXCEPTION 'organization user setup authority is invalid' USING ERRCODE = '42501';
429
+ END IF;
430
+
431
+ PERFORM pg_catalog.set_config(
432
+ 'opengeni.organization_tenancy_lifecycle',
433
+ 'organization_membership_lifecycle', true
434
+ );
435
+ PERFORM pg_catalog.pg_advisory_xact_lock(
436
+ pg_catalog.hashtextextended('organization-membership:' || account_id_value::text, 0)
437
+ );
438
+ PERFORM 1 FROM managed_accounts account
439
+ WHERE account.id = account_id_value FOR KEY SHARE;
440
+ IF NOT FOUND THEN
441
+ RAISE EXCEPTION 'organization not found' USING ERRCODE = 'P0002';
442
+ END IF;
443
+
444
+ SELECT * INTO actor FROM organization_memberships membership
445
+ WHERE membership.account_id = account_id_value
446
+ AND membership.subject_id = actor_subject
447
+ FOR UPDATE;
448
+ IF NOT FOUND OR actor.status <> 'active' OR actor.role NOT IN ('owner', 'admin') THEN
449
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
450
+ END IF;
451
+
452
+ SELECT * INTO invitation FROM organization_membership_invitations candidate
453
+ WHERE candidate.account_id = account_id_value
454
+ AND candidate.id = invitation_id_value
455
+ FOR UPDATE;
456
+ IF NOT FOUND
457
+ OR invitation.created_by_membership_id IS DISTINCT FROM actor.id
458
+ OR invitation.status <> 'pending'
459
+ OR invitation.expires_at <= pg_catalog.clock_timestamp()
460
+ OR expires_at_value IS DISTINCT FROM invitation.expires_at
461
+ THEN
462
+ RAISE EXCEPTION 'organization invitation is unavailable' USING ERRCODE = 'P0002';
463
+ END IF;
464
+
465
+ SELECT * INTO setup FROM organization_user_setup_intents candidate
466
+ WHERE candidate.account_id = account_id_value
467
+ AND candidate.invitation_id = invitation_id_value
468
+ FOR UPDATE;
469
+ IF FOUND THEN
470
+ IF setup.token_digest IS DISTINCT FROM digest_value
471
+ OR setup.expires_at IS DISTINCT FROM expires_at_value
472
+ THEN
473
+ RAISE EXCEPTION 'organization user setup intent changed under retry'
474
+ USING ERRCODE = '23505';
475
+ END IF;
476
+ RETURN pg_catalog.jsonb_build_object('status', setup.status);
477
+ END IF;
478
+
479
+ INSERT INTO organization_user_setup_intents (
480
+ account_id, invitation_id, token_digest, expires_at
481
+ ) VALUES (
482
+ account_id_value, invitation_id_value, digest_value, expires_at_value
483
+ ) RETURNING * INTO setup;
484
+ RETURN pg_catalog.jsonb_build_object('status', setup.status);
485
+ END
486
+ $body$;
487
+
488
+ CREATE FUNCTION preflight_organization_user_setup(p_token_digest text)
489
+ RETURNS text
490
+ LANGUAGE plpgsql
491
+ SECURITY DEFINER
492
+ SET search_path FROM CURRENT
493
+ AS $body$
494
+ DECLARE
495
+ setup organization_user_setup_intents%ROWTYPE;
496
+ invitation organization_membership_invitations%ROWTYPE;
497
+ normalized_email text;
498
+ BEGIN
499
+ -- This is deliberately a cheap, non-consuming eligibility read. The final
500
+ -- lifecycle repeats every check under the canonical email/organization
501
+ -- locks and remains the only authority that creates or consumes anything.
502
+ IF p_token_digest IS NULL OR p_token_digest !~ '^[0-9a-f]{64}$' THEN
503
+ RETURN 'unavailable';
504
+ END IF;
505
+ PERFORM pg_catalog.set_config(
506
+ 'opengeni.organization_tenancy_lifecycle',
507
+ 'organization_membership_lifecycle', true
508
+ );
509
+ SELECT * INTO setup FROM organization_user_setup_intents candidate
510
+ WHERE candidate.token_digest = p_token_digest;
511
+ IF NOT FOUND THEN
512
+ RETURN 'unavailable';
513
+ END IF;
514
+ IF setup.status = 'completed' THEN
515
+ RETURN 'completed';
516
+ END IF;
517
+ SELECT * INTO invitation FROM organization_membership_invitations candidate
518
+ WHERE candidate.account_id = setup.account_id
519
+ AND candidate.id = setup.invitation_id;
520
+ IF NOT FOUND
521
+ OR invitation.status <> 'pending'
522
+ OR invitation.expires_at <= pg_catalog.clock_timestamp()
523
+ OR setup.expires_at <= pg_catalog.clock_timestamp()
524
+ OR invitation.expires_at IS DISTINCT FROM setup.expires_at
525
+ THEN
526
+ RETURN 'unavailable';
527
+ END IF;
528
+ normalized_email := lower(btrim(invitation.target_email));
529
+ IF EXISTS (
530
+ SELECT 1 FROM auth_users auth_user
531
+ WHERE lower(btrim(auth_user.email)) = normalized_email
532
+ ) THEN
533
+ RETURN 'unavailable';
534
+ END IF;
535
+ RETURN 'pending';
536
+ END
537
+ $body$;
538
+
539
+ CREATE FUNCTION complete_organization_user_setup(p_command jsonb)
540
+ RETURNS jsonb
541
+ LANGUAGE plpgsql
542
+ SECURITY DEFINER
543
+ SET search_path FROM CURRENT
544
+ AS $body$
545
+ DECLARE
546
+ digest_value text := p_command ->> 'tokenDigest';
547
+ operation_id_value uuid := nullif(p_command ->> 'operationId', '')::uuid;
548
+ fingerprint_value text := p_command ->> 'requestFingerprint';
549
+ auth_user_id_value text := p_command ->> 'authUserId';
550
+ display_name_value text := nullif(btrim(p_command ->> 'name'), '');
551
+ password_hash_value text := p_command ->> 'passwordHash';
552
+ setup organization_user_setup_intents%ROWTYPE;
553
+ invitation organization_membership_invitations%ROWTYPE;
554
+ normalized_email text;
555
+ subject_id_value text;
556
+ locked_account_id_value uuid;
557
+ acceptance_result jsonb;
558
+ public_result jsonb := pg_catalog.jsonb_build_object('status', 'complete');
559
+ BEGIN
560
+ IF digest_value !~ '^[0-9a-f]{64}$'
561
+ OR operation_id_value IS NULL
562
+ OR fingerprint_value !~ '^[0-9a-f]{64}$'
563
+ OR auth_user_id_value IS NULL
564
+ OR octet_length(convert_to(auth_user_id_value, 'UTF8')) NOT BETWEEN 1 AND 1019
565
+ OR display_name_value IS NULL
566
+ OR octet_length(convert_to(display_name_value, 'UTF8')) NOT BETWEEN 1 AND 120
567
+ OR password_hash_value IS NULL
568
+ OR octet_length(convert_to(password_hash_value, 'UTF8')) NOT BETWEEN 32 AND 4096
569
+ THEN
570
+ RAISE EXCEPTION 'organization user setup input is invalid' USING ERRCODE = '22023';
571
+ END IF;
572
+
573
+ PERFORM pg_catalog.set_config(
574
+ 'opengeni.organization_tenancy_lifecycle',
575
+ 'organization_membership_lifecycle', true
576
+ );
577
+ SELECT * INTO setup FROM organization_user_setup_intents candidate
578
+ WHERE candidate.token_digest = digest_value;
579
+ IF NOT FOUND THEN
580
+ RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
581
+ END IF;
582
+
583
+ SELECT * INTO invitation FROM organization_membership_invitations candidate
584
+ WHERE candidate.account_id = setup.account_id
585
+ AND candidate.id = setup.invitation_id;
586
+ IF NOT FOUND THEN
587
+ RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
588
+ END IF;
589
+ normalized_email := lower(btrim(invitation.target_email));
590
+
591
+ -- Match invitation creation/binding lock order before taking any tenant row.
592
+ PERFORM pg_catalog.pg_advisory_xact_lock(
593
+ pg_catalog.hashtextextended('organization-invitation-email:' || normalized_email, 0)
594
+ );
595
+ -- Freeze the complete matching invitation set behind the email fence, then
596
+ -- acquire every relevant organization lock in UUID order before touching the
597
+ -- selected organization. This preserves the binder's canonical multi-org
598
+ -- order even when two different recipients have invitations in the same two
599
+ -- organizations but choose opposite setup links concurrently.
600
+ FOR locked_account_id_value IN
601
+ SELECT relevant.account_id
602
+ FROM (
603
+ SELECT setup.account_id
604
+ UNION
605
+ SELECT candidate.account_id
606
+ FROM organization_membership_invitations candidate
607
+ WHERE candidate.target_subject_id IS NULL
608
+ AND candidate.target_email = normalized_email
609
+ AND candidate.status = 'pending'
610
+ AND candidate.expires_at > pg_catalog.clock_timestamp()
611
+ ) relevant
612
+ ORDER BY relevant.account_id
613
+ LOOP
614
+ PERFORM pg_catalog.pg_advisory_xact_lock(
615
+ pg_catalog.hashtextextended(
616
+ 'organization-membership:' || locked_account_id_value::text, 0
617
+ )
618
+ );
619
+ PERFORM 1 FROM managed_accounts account
620
+ WHERE account.id = locked_account_id_value FOR KEY SHARE;
621
+ IF NOT FOUND THEN
622
+ RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
623
+ END IF;
624
+ END LOOP;
625
+
626
+ SELECT * INTO setup FROM organization_user_setup_intents candidate
627
+ WHERE candidate.token_digest = digest_value
628
+ FOR UPDATE;
629
+ IF setup.status = 'completed' THEN
630
+ IF setup.completion_operation_id IS DISTINCT FROM operation_id_value
631
+ OR setup.completion_request_fingerprint IS DISTINCT FROM fingerprint_value
632
+ THEN
633
+ RAISE EXCEPTION 'organization user setup operation was reused'
634
+ USING ERRCODE = '23505';
635
+ END IF;
636
+ RETURN setup.completion_result;
637
+ END IF;
638
+
639
+ SELECT * INTO invitation FROM organization_membership_invitations candidate
640
+ WHERE candidate.account_id = setup.account_id
641
+ AND candidate.id = setup.invitation_id
642
+ FOR UPDATE;
643
+ IF NOT FOUND
644
+ OR invitation.status <> 'pending'
645
+ OR invitation.expires_at <= pg_catalog.clock_timestamp()
646
+ OR setup.expires_at <= pg_catalog.clock_timestamp()
647
+ OR invitation.expires_at IS DISTINCT FROM setup.expires_at
648
+ THEN
649
+ RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
650
+ END IF;
651
+
652
+ -- Existing users retain the ordinary verified-email invitation path. A setup
653
+ -- bearer never changes an existing credential or takes over an identity.
654
+ IF EXISTS (
655
+ SELECT 1 FROM auth_users auth_user
656
+ WHERE lower(btrim(auth_user.email)) = normalized_email
657
+ ) THEN
658
+ RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
659
+ END IF;
660
+
661
+ INSERT INTO auth_users (id, name, email, email_verified)
662
+ VALUES (auth_user_id_value, display_name_value, normalized_email, true);
663
+ INSERT INTO auth_identities (
664
+ id, user_id, account_id, provider_id, password
665
+ ) VALUES (
666
+ gen_random_uuid()::text, auth_user_id_value, auth_user_id_value,
667
+ 'credential', password_hash_value
668
+ );
669
+
670
+ subject_id_value := 'user:' || auth_user_id_value;
671
+ PERFORM pg_catalog.set_config('opengeni.subject_id', subject_id_value, true);
672
+ PERFORM pg_catalog.set_config('opengeni.account_id', setup.account_id::text, true);
673
+ PERFORM pg_catalog.set_config('opengeni.workspace_id', '', true);
674
+ PERFORM bind_pending_organization_invitations_for_verified_email(
675
+ subject_id_value, normalized_email
676
+ );
677
+
678
+ SELECT * INTO invitation FROM organization_membership_invitations candidate
679
+ WHERE candidate.account_id = setup.account_id
680
+ AND candidate.id = setup.invitation_id
681
+ FOR UPDATE;
682
+ IF invitation.target_subject_id IS DISTINCT FROM subject_id_value
683
+ OR invitation.status <> 'pending'
684
+ THEN
685
+ RAISE EXCEPTION 'organization user setup is unavailable' USING ERRCODE = 'P0002';
686
+ END IF;
687
+
688
+ acceptance_result := accept_organization_invitation_v2(
689
+ pg_catalog.jsonb_build_object(
690
+ 'action', 'accept',
691
+ 'organizationId', setup.account_id,
692
+ 'actorSubjectId', subject_id_value,
693
+ 'operationId', operation_id_value,
694
+ 'invitationId', setup.invitation_id,
695
+ 'expectedRevision', invitation.revision
696
+ )
697
+ );
698
+ IF acceptance_result -> 'membership' ->> 'subjectId' IS DISTINCT FROM subject_id_value THEN
699
+ RAISE EXCEPTION 'organization user setup acceptance did not converge'
700
+ USING ERRCODE = '55000';
701
+ END IF;
702
+
703
+ UPDATE organization_user_setup_intents SET
704
+ status = 'completed',
705
+ completion_operation_id = operation_id_value,
706
+ completion_request_fingerprint = fingerprint_value,
707
+ completed_auth_user_id = auth_user_id_value,
708
+ completion_result = public_result,
709
+ completed_at = pg_catalog.clock_timestamp(),
710
+ updated_at = pg_catalog.clock_timestamp()
711
+ WHERE id = setup.id;
712
+ RETURN public_result;
713
+ END
714
+ $body$;
715
+
716
+ -- The incoming-invitation chooser must expose a human-readable organization
717
+ -- identity without turning the row-projection helper into an arbitrary account
718
+ -- name oracle. Enrich only the exact current subject's authorized page.
719
+ CREATE OR REPLACE FUNCTION list_self_organization_invitations(
720
+ p_subject_id text,
721
+ p_cursor_id uuid,
722
+ p_limit integer
723
+ )
724
+ RETURNS jsonb
725
+ LANGUAGE plpgsql
726
+ SECURITY DEFINER
727
+ SET search_path FROM CURRENT
728
+ AS $body$
729
+ DECLARE
730
+ cursor_created_at timestamptz;
731
+ result jsonb;
732
+ BEGIN
733
+ IF p_subject_id IS NULL
734
+ OR p_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
735
+ OR p_subject_id NOT LIKE 'user:%'
736
+ OR p_limit IS NULL OR p_limit < 1 OR p_limit > 100
737
+ THEN
738
+ RAISE EXCEPTION 'managed human subject authority required' USING ERRCODE = '42501';
739
+ END IF;
740
+ PERFORM pg_catalog.set_config(
741
+ 'opengeni.organization_tenancy_lifecycle',
742
+ 'organization_membership_lifecycle', true
743
+ );
744
+ IF p_cursor_id IS NOT NULL THEN
745
+ SELECT invitation.created_at INTO cursor_created_at
746
+ FROM organization_membership_invitations invitation
747
+ WHERE invitation.target_subject_id = p_subject_id
748
+ AND invitation.id = p_cursor_id;
749
+ IF NOT FOUND THEN
750
+ RAISE EXCEPTION 'organization invitation cursor not found' USING ERRCODE = 'P0002';
751
+ END IF;
752
+ END IF;
753
+ SELECT coalesce(
754
+ pg_catalog.jsonb_agg(
755
+ opengeni_private.organization_invitation_row_json(candidate.invitation)
756
+ || pg_catalog.jsonb_build_object('organizationName', candidate.organization_name)
757
+ ORDER BY candidate.created_at DESC, candidate.id DESC
758
+ ), '[]'::jsonb
759
+ ) INTO result
760
+ FROM (
761
+ SELECT invitation AS invitation, account.name AS organization_name,
762
+ invitation.created_at, invitation.id
763
+ FROM organization_membership_invitations invitation
764
+ JOIN managed_accounts account ON account.id = invitation.account_id
765
+ WHERE invitation.target_subject_id = p_subject_id
766
+ AND (
767
+ p_cursor_id IS NULL
768
+ OR (invitation.created_at, invitation.id) < (cursor_created_at, p_cursor_id)
769
+ )
770
+ ORDER BY invitation.created_at DESC, invitation.id DESC
771
+ LIMIT p_limit + 1
772
+ ) candidate;
773
+ RETURN result;
774
+ END
775
+ $body$;
776
+
777
+ DO $search_paths$
778
+ DECLARE data_schema text := pg_catalog.current_schema();
779
+ BEGIN
780
+ EXECUTE format(
781
+ 'ALTER FUNCTION %I.complete_self_service_organization_setup(jsonb) SET search_path = pg_catalog, %I, pg_temp',
782
+ data_schema, data_schema
783
+ );
784
+ EXECUTE format(
785
+ 'ALTER FUNCTION %I.ensure_organization_user_setup_intent(jsonb) SET search_path = pg_catalog, %I, pg_temp',
786
+ data_schema, data_schema
787
+ );
788
+ EXECUTE format(
789
+ 'ALTER FUNCTION %I.preflight_organization_user_setup(text) SET search_path = pg_catalog, %I, pg_temp',
790
+ data_schema, data_schema
791
+ );
792
+ EXECUTE format(
793
+ 'ALTER FUNCTION %I.complete_organization_user_setup(jsonb) SET search_path = pg_catalog, %I, pg_temp',
794
+ data_schema, data_schema
795
+ );
796
+ EXECUTE format(
797
+ 'ALTER FUNCTION %I.create_managed_organization(text,text,text,uuid) SET search_path = pg_catalog, %I, pg_temp',
798
+ data_schema, data_schema
799
+ );
800
+ EXECUTE format(
801
+ 'ALTER FUNCTION %I.list_self_organization_invitations(text,uuid,integer) SET search_path = pg_catalog, %I, pg_temp',
802
+ data_schema, data_schema
803
+ );
804
+ END
805
+ $search_paths$;
806
+
807
+ REVOKE ALL ON TABLE self_service_organization_setup_receipts FROM PUBLIC;
808
+ REVOKE ALL ON TABLE organization_user_setup_intents FROM PUBLIC;
809
+ REVOKE ALL ON FUNCTION complete_self_service_organization_setup(jsonb) FROM PUBLIC;
810
+ REVOKE ALL ON FUNCTION ensure_organization_user_setup_intent(jsonb) FROM PUBLIC;
811
+ REVOKE ALL ON FUNCTION preflight_organization_user_setup(text) FROM PUBLIC;
812
+ REVOKE ALL ON FUNCTION complete_organization_user_setup(jsonb) FROM PUBLIC;
813
+
814
+ DO $runtime_grants$
815
+ BEGIN
816
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
817
+ REVOKE ALL ON TABLE self_service_organization_setup_receipts FROM opengeni_app;
818
+ REVOKE ALL ON TABLE organization_user_setup_intents FROM opengeni_app;
819
+ GRANT EXECUTE ON FUNCTION complete_self_service_organization_setup(jsonb) TO opengeni_app;
820
+ GRANT EXECUTE ON FUNCTION ensure_organization_user_setup_intent(jsonb) TO opengeni_app;
821
+ GRANT EXECUTE ON FUNCTION preflight_organization_user_setup(text) TO opengeni_app;
822
+ GRANT EXECUTE ON FUNCTION complete_organization_user_setup(jsonb) TO opengeni_app;
823
+ END IF;
824
+ END
825
+ $runtime_grants$;
826
+
827
+ COMMENT ON TABLE self_service_organization_setup_receipts IS
828
+ 'One completed organization setup per verified Better Auth user; stores idempotency evidence and the canonical personal-workspace result.';
829
+ COMMENT ON FUNCTION complete_self_service_organization_setup(jsonb) IS
830
+ 'Atomically creates one active organization owner membership and only its canonical personal workspace after ordinary sign-in.';
831
+ COMMENT ON FUNCTION create_managed_organization(text,text,text,uuid) IS
832
+ 'Compatibility entry point for the one-time Personal-only self-service organization setup lifecycle.';
833
+ COMMENT ON TABLE organization_user_setup_intents IS
834
+ 'Invitation-bound hashed one-time account setup authority; no plaintext bearer or password is stored.';
835
+ COMMENT ON FUNCTION preflight_organization_user_setup(text) IS
836
+ 'Performs a cheap non-consuming setup-bearer eligibility read before password hashing; final completion revalidates atomically.';
837
+ COMMENT ON FUNCTION complete_organization_user_setup(jsonb) IS
838
+ 'Consumes one email-delivered setup bearer and atomically creates the verified credential and invitation membership.';