@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.
- package/.agent/AGENTS.md +82 -144
- package/.agent/account_management.md +2 -2
- package/.agent/action_logs.md +4 -4
- package/.agent/ai_agents.md +28 -21
- package/.agent/ai_sandbox.md +49 -39
- package/.agent/connected_apps.md +3 -3
- package/.agent/cookbook/_fixtures/README.md +1 -1
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
- package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
- package/.agent/cookbook/organic-marketing.md +2801 -0
- package/.agent/cookbook/payments-compliance.md +2180 -0
- package/.agent/cookbook/primary-care.md +2175 -0
- package/.agent/cookbook/subscription-saas.md +2403 -0
- package/.agent/data_activation_clients.md +65 -52
- package/.agent/datalakes.md +338 -171
- package/.agent/errors.md +3 -3
- package/.agent/generic_tables.md +151 -62
- package/.agent/interoperability_contracts.md +57 -22
- package/.agent/mdm.md +136 -153
- package/.agent/messages.md +36 -34
- package/.agent/mock-services.md +1 -1
- package/.agent/mutations.md +2 -2
- package/.agent/templates.md +14 -13
- package/.agent/tools.md +63 -21
- package/.agent/type_naming.md +13 -13
- package/.agent/workflows.md +99 -53
- package/README.md +2 -2
- package/dist/bin/platform-sdk.mjs +33 -47
- package/dist/bin/platform-sdk.mjs.map +1 -1
- package/dist/index.d.mts +565 -379
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +494 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
- package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
- package/.agent/cookbook/_setup/foundation.md +0 -359
- package/.agent/cookbook/_setup/healthcare.md +0 -361
- package/.agent/cookbook/_setup/payments.md +0 -365
- package/.agent/cookbook/_setup/subscription.md +0 -364
- package/.agent/cookbook/action-status-updaters.md +0 -278
- package/.agent/cookbook/ai-agent-invoke.md +0 -279
- package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
- package/.agent/cookbook/bulk-ingest.md +0 -302
- package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
- package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
- package/.agent/cookbook/generic-tables.md +0 -244
- package/.agent/cookbook/invite-team.md +0 -200
- package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
- package/.agent/cookbook/marketing-campaign-send.md +0 -1044
- package/.agent/cookbook/paginated-restapi-poller.md +0 -383
- package/.agent/cookbook/rest-fetch.md +0 -273
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
- package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
- package/.agent/cookbook/system-templates.md +0 -165
- package/.agent/cookbook/talk-to-data.md +0 -178
- package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
- package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
- /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
|