@alvera-ai/platform-sdk 0.17.0 → 0.18.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 (83) hide show
  1. package/.agent/AGENTS.md +82 -144
  2. package/.agent/account_management.md +2 -2
  3. package/.agent/action_logs.md +4 -4
  4. package/.agent/ai_agents.md +28 -21
  5. package/.agent/ai_sandbox.md +49 -39
  6. package/.agent/connected_apps.md +3 -3
  7. package/.agent/cookbook/_fixtures/README.md +1 -1
  8. package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
  9. package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
  10. package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
  11. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
  12. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
  13. package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
  14. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
  15. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
  16. package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
  17. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
  18. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
  19. package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
  20. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
  21. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
  22. package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
  23. package/.agent/cookbook/organic-marketing.md +2801 -0
  24. package/.agent/cookbook/payments-compliance.md +2180 -0
  25. package/.agent/cookbook/primary-care.md +2175 -0
  26. package/.agent/cookbook/subscription-saas.md +2403 -0
  27. package/.agent/data_activation_clients.md +65 -52
  28. package/.agent/datalakes.md +338 -171
  29. package/.agent/errors.md +3 -3
  30. package/.agent/generic_tables.md +151 -62
  31. package/.agent/interoperability_contracts.md +57 -22
  32. package/.agent/mdm.md +136 -153
  33. package/.agent/messages.md +36 -34
  34. package/.agent/mock-services.md +1 -1
  35. package/.agent/mutations.md +2 -2
  36. package/.agent/templates.md +14 -13
  37. package/.agent/tools.md +63 -21
  38. package/.agent/type_naming.md +13 -13
  39. package/.agent/workflows.md +99 -53
  40. package/README.md +2 -2
  41. package/dist/bin/platform-sdk.mjs +33 -47
  42. package/dist/bin/platform-sdk.mjs.map +1 -1
  43. package/dist/index.d.mts +565 -379
  44. package/dist/index.d.mts.map +1 -1
  45. package/dist/index.mjs +494 -59
  46. package/dist/index.mjs.map +1 -1
  47. package/package.json +4 -3
  48. package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
  49. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
  50. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
  51. package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
  52. package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
  53. package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
  54. package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
  55. package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
  56. package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
  57. package/.agent/cookbook/_setup/foundation.md +0 -359
  58. package/.agent/cookbook/_setup/healthcare.md +0 -361
  59. package/.agent/cookbook/_setup/payments.md +0 -365
  60. package/.agent/cookbook/_setup/subscription.md +0 -364
  61. package/.agent/cookbook/action-status-updaters.md +0 -278
  62. package/.agent/cookbook/ai-agent-invoke.md +0 -279
  63. package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
  64. package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
  65. package/.agent/cookbook/bulk-ingest.md +0 -302
  66. package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
  67. package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
  68. package/.agent/cookbook/generic-tables.md +0 -244
  69. package/.agent/cookbook/invite-team.md +0 -200
  70. package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
  71. package/.agent/cookbook/marketing-campaign-send.md +0 -1044
  72. package/.agent/cookbook/paginated-restapi-poller.md +0 -383
  73. package/.agent/cookbook/rest-fetch.md +0 -273
  74. package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
  75. package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
  76. package/.agent/cookbook/system-templates.md +0 -165
  77. package/.agent/cookbook/talk-to-data.md +0 -178
  78. package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
  79. package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
  80. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
  81. /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
  82. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
  83. /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
@@ -1,244 +0,0 @@
1
- ---
2
- title: "Capability: stand up a generic table the platform doesn't model"
3
- summary: A capability walk for generic tables. Create a generic table with typed, privacy-tagged columns (`api.genericTables.create`), wait for it to deploy, use the default ingestion client the platform auto-provisions for it, ingest a row (`api.dataActivationClients.ingest`), and read it back with read-only SQL (`api.datalakes.executeSql`). For data the built-in datasets don't cover.
4
- industry: subscription
5
- slug: generic-tables
6
- vitest_source:
7
- - integration-tests/tests/subscription/generic-tables.test.ts
8
- - integration-tests/tests/subscription/bootstrap.test.ts
9
- status: green
10
- ---
11
-
12
- # Capability
13
-
14
- **What you get:** a first-class dataset for data the platform's built-in
15
- datasets (customer, message, …) don't model — defined by you, deployed by the
16
- platform, and queryable like any other dataset.
17
-
18
- The lifecycle is:
19
-
20
- 1. `genericTables.create(...)` — declare the table: a title and typed columns,
21
- each with a `privacy_requirement` (`none` / `tokenize` / `redact_only`).
22
- 2. The platform deploys it asynchronously (`status: 'new'` → `'deployed'`) and
23
- **auto-provisions a default Data Activation Client** bound to an identity
24
- contract — no tool, contract, or DAC to wire by hand.
25
- 3. `dataActivationClients.ingest(...)` a row through that default client.
26
- 4. `datalakes.executeSql(...)` reads it back by its unique column with read-only SQL.
27
-
28
- This is **global** to every datalake — only the table's domain meaning differs.
29
- See `generic_tables.md` for the column reference.
30
-
31
- # Walkthrough
32
-
33
- The `_setup/subscription.md` bootstrap left `api`, `tenantSlug`, and
34
- `datalakeSlug` populated.
35
-
36
- ## 001 — list existing generic tables (sanity)
37
-
38
- `genericTables.list` returns the paginated `{ data, meta }` envelope. A fresh
39
- datalake has none; the call just confirms the surface is reachable.
40
-
41
- ```typescript
42
- const { data: existing } = await api.genericTables.list(tenantSlug, datalakeSlug)
43
- if (!Array.isArray(existing.data)) {
44
- throw new Error('expected a paginated generic-tables list with a data array')
45
- }
46
- ```
47
-
48
- ## 002 — create the generic table
49
-
50
- Declare the table with a `title` and `columns`. Each column has a `type`, an
51
- `is_unique` flag, and — load-bearing — a `privacy_requirement` that decides how
52
- the platform stores and exposes the value (`tokenize` for PII, `redact_only`
53
- for free text, `none` for safe fields). The server **derives the table `name`**
54
- (slugified + `alvera_custom_` prefix) and returns a `checksum`; you don't set
55
- the name. The `checksum(...)` call recomputes that fingerprint from a body
56
- without persisting — handy for drift checks.
57
-
58
- ```typescript
59
- const tableBody = {
60
- title: `Customer Inquiries ${runSuffix}`,
61
- description: 'Inbound customer billing / dunning inquiries',
62
- columns: [
63
- { name: 'submission_id', title: 'Submission ID', type: 'string', description: 'Unique inquiry identifier', is_unique: true, privacy_requirement: 'none' },
64
- { name: 'customer_name', title: 'Customer Name', type: 'string', description: 'Inquiring customer name', is_unique: false, privacy_requirement: 'tokenize' },
65
- { name: 'email', title: 'Email', type: 'string', description: 'Contact email', is_unique: false, privacy_requirement: 'tokenize' },
66
- { name: 'message', title: 'Message', type: 'string', description: 'Inquiry message body', is_unique: false, privacy_requirement: 'redact_only' },
67
- { name: 'source_channel', title: 'Source Channel', type: 'string', description: 'Origin channel (web/portal/email)', is_unique: false, privacy_requirement: 'none' },
68
- ],
69
- }
70
-
71
- const { data: table } = await api.genericTables.create(tenantSlug, datalakeSlug, tableBody)
72
- genericTableId = table.id!
73
- ctx.tableName = table.name! // server-derived, e.g. alvera_custom_customer_inquiries_<suffix>
74
-
75
- const { data: ck } = await api.genericTables.checksum(tenantSlug, datalakeSlug, tableBody)
76
- if (ck.checksum !== table.checksum) {
77
- throw new Error('checksum of the create body should match the created table')
78
- }
79
- ```
80
-
81
- ## 003 — wait for the table to deploy
82
-
83
- Creation returns immediately with `status: 'new'`; the platform then runs the
84
- schema migration in the background. Poll `genericTables.get` until `status` is
85
- `'deployed'`. Only then is it safe to ingest.
86
-
87
- ```typescript
88
- const deadline = Date.now() + 60_000
89
- let status: string | undefined
90
- while (Date.now() < deadline) {
91
- const { data: row } = await api.genericTables.get(tenantSlug, datalakeSlug, genericTableId)
92
- status = row.status
93
- if (status === 'deployed') break
94
- await new Promise((r) => setTimeout(r, 1_000))
95
- }
96
- if (status !== 'deployed') {
97
- throw new Error(`generic table did not reach :deployed within 60s (last: ${status})`)
98
- }
99
- ```
100
-
101
- ## 004 — find the auto-provisioned default ingestion client
102
-
103
- Deploying the table also creates a default Data Activation Client bound to an
104
- auto-generated identity contract. You don't create it — you find it. It shows up
105
- in the DAC list with the table's derived name in its own name. Poll briefly, as
106
- it appears a moment after `:deployed`.
107
-
108
- ```typescript
109
- const deadline = Date.now() + 30_000
110
- let defaultDacSlug: string | undefined
111
- while (Date.now() < deadline && !defaultDacSlug) {
112
- const { data: dacs } = await api.dataActivationClients.list(tenantSlug, datalakeSlug)
113
- const match = (dacs.data ?? []).find((d) => (d.name ?? '').includes(ctx.tableName))
114
- defaultDacSlug = match?.slug ?? undefined
115
- if (!defaultDacSlug) await new Promise((r) => setTimeout(r, 1_000))
116
- }
117
- if (!defaultDacSlug) {
118
- throw new Error('default DAC for the generic table was not provisioned within 30s')
119
- }
120
- ctx.defaultDacSlug = defaultDacSlug
121
- ```
122
-
123
- ## 005 — ingest a row
124
-
125
- Submit one row as inline JSON through the default client. The keys are your
126
- column `name`s. `submission_id` is the unique column, so give it a per-run value.
127
-
128
- ```typescript
129
- ctx.submissionId = `CDS-${runSuffix}`
130
- const { data: ingest } = await api.dataActivationClients.ingest(
131
- tenantSlug, datalakeSlug, ctx.defaultDacSlug,
132
- {
133
- data: {
134
- submission_id: ctx.submissionId,
135
- customer_name: 'Ada Lovelace',
136
- email: 'ada@example.test',
137
- message: 'My invoice total looks wrong this month.',
138
- source_channel: 'portal',
139
- },
140
- },
141
- )
142
- if (!ingest.batch_id) {
143
- throw new Error('ingest did not return a batch_id')
144
- }
145
- ```
146
-
147
- ## 006 — read the row back with read-only SQL
148
-
149
- `executeSql` runs a read-only statement against the datalake and returns a page
150
- as `{ data, meta }` — `data` is an array-of-arrays aligned positionally to
151
- `meta.columns`. `mode: 'unregulated'` runs it against the unregulated schema, so
152
- you reference the table by its server-derived `name` (the `alvera_custom_…`
153
- value from §002 — `mode` selects the schema, so no `regulated_`/`unregulated_`
154
- prefix). Ingestion is async, so poll until the row lands.
155
-
156
- ```typescript
157
- const deadline = Date.now() + 45_000
158
- let row: Record<string, unknown> | undefined
159
- while (Date.now() < deadline && !row) {
160
- const { data: result } = await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
161
- sql: `SELECT submission_id, customer_name, source_channel
162
- FROM ${ctx.tableName}
163
- WHERE submission_id = '${ctx.submissionId}'`,
164
- mode: 'unregulated',
165
- })
166
- // JSON envelope (a `string` only for `{ format: 'csv' }`); zip the positional
167
- // row back into an object keyed by meta.columns.
168
- if (typeof result !== 'string' && result.data.length > 0) {
169
- row = Object.fromEntries(result.meta.columns.map((c, i) => [c, result.data[0][i]]))
170
- }
171
- if (!row) await new Promise((r) => setTimeout(r, 1_000))
172
- }
173
- if (!row || row.submission_id !== ctx.submissionId) {
174
- throw new Error('ingested row not found in the generic table within 45s')
175
- }
176
- ```
177
-
178
- ## 007 — write the integration test
179
-
180
- End the build with a test you keep: re-read the table and prove the two
181
- facts every consumer of it depends on — the deploy completed (with the
182
- server-derived physical name), and the ingest path accepts a row. The
183
- probe row is `test-`-prefixed so it is unmistakably synthetic wherever it
184
- surfaces. This block runs live under `make validate-cookbook`.
185
-
186
- ```typescript
187
- // Re-GET — deployed, with the server-derived alvera_custom_ name.
188
- const { data: tableRow } = await api.genericTables.get(tenantSlug, datalakeSlug, genericTableId)
189
- if (tableRow.status !== 'deployed') {
190
- throw new Error(`generic table regressed from deployed: ${tableRow.status}`)
191
- }
192
- if (tableRow.name !== ctx.tableName) {
193
- throw new Error(`physical name drifted on read-back: ${tableRow.name}`)
194
- }
195
- // Behavioural probe — one synthetic row through the auto-provisioned
196
- // default client; ingest is async (202), so assert the batch
197
- // acknowledgement, never synchronous row completion.
198
- const { data: probeAck } = await api.dataActivationClients.ingest(
199
- tenantSlug, datalakeSlug, ctx.defaultDacSlug,
200
- {
201
- data: {
202
- submission_id: `test-CDS-probe-${runSuffix}`,
203
- customer_name: 'test-Ada Lovelace',
204
- email: 'test-ada@example.test',
205
- message: 'test: integration-test probe row.',
206
- source_channel: 'portal',
207
- },
208
- },
209
- )
210
- if (typeof probeAck.batch_id !== 'string' || probeAck.batch_id.length === 0) {
211
- throw new Error('default-client ingest did not enqueue a batch')
212
- }
213
- ```
214
-
215
- If the probe fails in production, escalate with the failing response —
216
- don't re-create the table or hand-edit the physical schema.
217
-
218
- # Gotchas
219
-
220
- - **`privacy_requirement` is load-bearing.** It drives the regulated/unregulated
221
- split — `tokenize` columns are masked in the unregulated view, `redact_only`
222
- free text is scrubbed, `none` passes through. Dropping it silently changes who
223
- can see what.
224
- - **The table name is server-derived, never set by you.** The platform slugifies
225
- the title and prefixes `alvera_custom_`. Read `name` off the create response and
226
- reference it directly in `executeSql`; `mode` selects the schema, so you don't
227
- add a `regulated_`/`unregulated_` prefix yourself.
228
- - **You don't create the ingestion client.** Deploying the table auto-provisions
229
- a default DAC + identity contract. Find it by name; don't build one.
230
- - **Two async waits.** Wait for `:deployed` before ingesting, then poll
231
- `executeSql` until the row materializes — ingestion is async like every other
232
- ingest path.
233
- - **`mode` picks the schema, and `tokenize` columns come back masked.** In
234
- `mode: 'unregulated'` the read runs against the tokenized view, so filter and
235
- assert on a `none` column (here `submission_id`); a `tokenize` column like
236
- `customer_name` returns its token, not the raw value.
237
-
238
- # See also
239
-
240
- - `generic_tables.md` — column types, `privacy_requirement`, lifecycle reference
241
- - `data_activation_clients.md` — the `.ingest` runtime verb + dataset search
242
- - `_setup/subscription.md` — the bootstrap this walk starts from
243
- - `integration-tests/tests/subscription/generic-tables.test.ts` — the
244
- green test these calls are lifted from
@@ -1,200 +0,0 @@
1
- ---
2
- title: "Capability: invite a teammate into your tenant"
3
- summary: A capability walk for multi-user onboarding. An admin invites a colleague by email (`api.invitations.create`), the colleague signs up and is confirmed, signs in without a tenant to find the pending invite (`invitations.list`), accepts it (`invitations.accept`), and finally signs in tenant-scoped as a member. Shows the three session scopes — root, tenantless, tenant-scoped — in one flow.
4
- industry: subscription
5
- slug: invite-team
6
- vitest_source:
7
- - integration-tests/tests/subscription/invite-team.test.ts
8
- - integration-tests/tests/subscription/bootstrap.test.ts
9
- status: green
10
- ---
11
-
12
- # Capability
13
-
14
- **What you get:** a second person on your tenant, added entirely through the
15
- SDK — no console, no out-of-band steps.
16
-
17
- The flow touches all three session scopes (see `AGENTS.md` § SDK auth):
18
-
19
- - **tenant-scoped** (the admin) — `api.invitations.create(tenantSlug, { email, role })`
20
- - **root** — signs the new user up and confirms them
21
- (`api.admin.signUp` + `api.admin.confirmUser`)
22
- - **tenantless** (the invitee, before they belong anywhere) — lists pending
23
- invites and accepts one (`api.invitations.list` / `api.invitations.accept`)
24
-
25
- After accepting, the invitee re-authenticates **tenant-scoped** and is a member.
26
- This capability is **global** — it works the same on every datalake's tenant.
27
-
28
- # Walkthrough
29
-
30
- The `_setup/subscription.md` bootstrap already provisioned the tenant and
31
- left `api` as the admin's tenant-scoped client, with `tenantSlug` and
32
- `ctx.sarahEmail` / `ctx.sarahPassword` populated. Root credentials come from the
33
- `ALVERA_ROOT_EMAIL` / `ALVERA_ROOT_PASSWORD` env vars the validator already
34
- exports.
35
-
36
- ## 001 — confirm the admin can invite
37
-
38
- Before inviting, verify the current session is a tenant-scoped admin of the
39
- expected tenant. `api.sessions.verify()` echoes the session's `tenant.slug` and
40
- `role.name`. Only an admin can create invitations.
41
-
42
- ```typescript
43
- const { data: who } = await api.sessions.verify()
44
- if (who.tenant?.slug !== tenantSlug) {
45
- throw new Error(`expected an admin session on ${tenantSlug}, got ${who.tenant?.slug}`)
46
- }
47
- if (!/admin/i.test(who.role?.name ?? '')) {
48
- throw new Error(`expected an admin role, got ${who.role?.name}`)
49
- }
50
- ```
51
-
52
- ## 002 — invite the teammate by email
53
-
54
- The admin invites a colleague with their email and a role (`member`). The
55
- invite is keyed on `(tenant, email)`, so re-inviting the same address returns a
56
- 422 with "already invited" — handle that as success so the step is idempotent on
57
- re-runs. The teammate's per-run email is derived from `runSuffix` so parallel
58
- runs don't collide.
59
-
60
- ```typescript
61
- ctx.emmaEmail = `cookbook-emma-${runSuffix}@dev.local`
62
- ctx.emmaPassword = 'CookbookPass1!'
63
-
64
- try {
65
- const { data: invite } = await api.invitations.create(tenantSlug, {
66
- email: ctx.emmaEmail,
67
- role: 'member',
68
- })
69
- if (invite.email !== ctx.emmaEmail || invite.role !== 'member') {
70
- throw new Error(`unexpected invite: ${JSON.stringify(invite)}`)
71
- }
72
- } catch (err) {
73
- const detail = JSON.stringify(err)
74
- if (!detail.includes('already invited')) throw err
75
- // already invited on a re-run — fine
76
- }
77
- ```
78
-
79
- ## 003 — root signs the teammate up and confirms them
80
-
81
- The invitee needs an account before they can accept. Signup + confirmation are
82
- root-scoped actions, so build a root client from the `ALVERA_ROOT_*` credentials
83
- and use it. `confirmUser` activates the account so the invitee can sign in.
84
-
85
- ```typescript
86
- const rootSession = await createBootstrapSession({
87
- baseUrl: process.env.ALVERA_BASE_URL!,
88
- email: process.env.ALVERA_ROOT_EMAIL!,
89
- password: process.env.ALVERA_ROOT_PASSWORD!,
90
- })
91
- const rootApi = createIsolatedPlatformApi({
92
- baseUrl: process.env.ALVERA_BASE_URL!,
93
- sessionToken: rootSession.sessionToken,
94
- apiKey: '',
95
- })
96
-
97
- const { data: emmaUser } = await rootApi.admin.signUp({
98
- email: ctx.emmaEmail,
99
- password: ctx.emmaPassword,
100
- first_name: 'Emma',
101
- last_name: 'Wilson',
102
- })
103
- await rootApi.admin.confirmUser(emmaUser.id!)
104
- ```
105
-
106
- ## 004 — teammate signs in WITHOUT a tenant and finds the invite
107
-
108
- The invitee has an account but belongs to no tenant yet, so they sign in
109
- **tenantless** — `createBootstrapSession` (`createSession` is tenant login
110
- only since the universal-key contract). The resulting session has
111
- `tenant: null`. A tenantless client can list the invitations waiting for that
112
- user. Find the one for this tenant by matching `tenant.slug`.
113
-
114
- ```typescript
115
- const emmaTenantless = await createBootstrapSession({
116
- baseUrl: process.env.ALVERA_BASE_URL!,
117
- email: ctx.emmaEmail,
118
- password: ctx.emmaPassword,
119
- })
120
- if (emmaTenantless.tenant !== null) {
121
- throw new Error('expected a tenantless session (tenant should be null)')
122
- }
123
- const emmaTenantlessApi = createIsolatedPlatformApi({
124
- baseUrl: process.env.ALVERA_BASE_URL!,
125
- sessionToken: emmaTenantless.sessionToken,
126
- apiKey: '',
127
- })
128
- ctx.emmaTenantlessToken = emmaTenantless.sessionToken
129
-
130
- const { data: invites } = await emmaTenantlessApi.invitations.list()
131
- const ours = (invites.data ?? []).find((i) => i.tenant?.slug === tenantSlug)
132
- if (!ours?.id) {
133
- throw new Error(`no pending invite for ${tenantSlug} found for the new user`)
134
- }
135
- ctx.invitationId = ours.id
136
- ```
137
-
138
- ## 005 — teammate accepts the invitation
139
-
140
- Accepting consumes the invite and creates the membership. The response echoes
141
- the new `role` and the `tenant.slug` the user just joined.
142
-
143
- ```typescript
144
- const emmaTenantlessApi = createIsolatedPlatformApi({
145
- baseUrl: process.env.ALVERA_BASE_URL!,
146
- sessionToken: ctx.emmaTenantlessToken,
147
- apiKey: '',
148
- })
149
-
150
- const { data: membership } = await emmaTenantlessApi.invitations.accept(ctx.invitationId)
151
- if (membership.tenant?.slug !== tenantSlug || membership.role !== 'member') {
152
- throw new Error(`unexpected membership: ${JSON.stringify(membership)}`)
153
- }
154
- ```
155
-
156
- ## 006 — teammate signs in tenant-scoped as a member
157
-
158
- Now that the membership exists, the invitee re-authenticates **with** the
159
- tenant slug — which requires the tenant's API key (`ctx.tenantApiKey`, minted
160
- during setup): tenant-scoped sign-in is 401'd without a resolvable
161
- `X-API-Key`. The new session carries `tenant.slug` and a `member` role — they
162
- are in.
163
-
164
- ```typescript
165
- const emmaScoped = await createSession({
166
- baseUrl: process.env.ALVERA_BASE_URL!,
167
- email: ctx.emmaEmail,
168
- password: ctx.emmaPassword,
169
- tenantSlug,
170
- apiKey: ctx.tenantApiKey,
171
- })
172
- if (emmaScoped.tenant?.slug !== tenantSlug) {
173
- throw new Error(`expected a tenant-scoped session on ${tenantSlug}`)
174
- }
175
- if (!/member/i.test(emmaScoped.role?.name ?? '')) {
176
- throw new Error(`expected a member role, got ${emmaScoped.role?.name}`)
177
- }
178
- ```
179
-
180
- # Gotchas
181
-
182
- - **Invitations are created tenant-scoped, accepted tenantless.** The admin
183
- calls `invitations.create(tenantSlug, …)` on their tenant-scoped client; the
184
- invitee calls `invitations.list()` / `accept(id)` on a **tenantless** client
185
- (they have no tenant yet). Mixing the scopes up is the #1 mistake here.
186
- - **Re-inviting the same email 422s.** The `(tenant, email)` pair is unique —
187
- catch "already invited" and treat it as success for idempotent re-runs.
188
- - **The invitee re-authenticates twice.** Once tenantless (to find + accept the
189
- invite), then again tenant-scoped (to act as a member). Accepting does not
190
- upgrade the existing tenantless session in place — you mint a new one.
191
- - **Signup + confirm are root-scoped.** A tenant admin can invite, but only root
192
- can create and confirm the underlying user account.
193
-
194
- # See also
195
-
196
- - `AGENTS.md` § SDK auth + client construction — root / tenantless / tenant-scoped
197
- - `account_management.md` — users, roles, invitations reference
198
- - `_setup/subscription.md` — the bootstrap this walk starts from
199
- - `integration-tests/tests/subscription/invite-team.test.ts` — the green
200
- test these calls are lifted from