@alvera-ai/platform-sdk 0.12.1-next.f7f8a3c → 0.12.1-next.g1cfe79a

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 CHANGED
@@ -114,19 +114,50 @@ platform. Two-step auth + client construction:
114
114
  email,
115
115
  password,
116
116
  tenantSlug, // optional; omit for tenantless session
117
+ apiKey, // REQUIRED. tenant-scoped sign-in is 401'd without a
118
+ // resolvable X-API-Key; pass '' for root/tenantless
119
+ // (the field is required by the type, inert on the wire)
117
120
  })
118
121
 
119
122
  // 2. build a typed client
120
123
  const api: PlatformApi = createIsolatedPlatformApi({
121
124
  baseUrl,
122
125
  sessionToken: session.sessionToken,
126
+ apiKey, // rides as X-API-Key on every request
123
127
  })
124
128
 
129
+ **Two credentials travel together on every request** (Firebase's
130
+ "API key + ID token" pairing, not an either/or): the session token
131
+ (`Authorization: Bearer …`) is what the platform authenticates and
132
+ authorizes as — Bearer always wins identity; the **publishable API
133
+ key** (`X-API-Key: …`) identifies the client and carries the
134
+ origin policy — a `public_api` key declares `allowed_origins`, and
135
+ a browser request's `Origin` is checked against the key that
136
+ resolves. A Bearer session *inherits the origin policy of the key
137
+ that minted it*. Where the key comes from: a tenant admin mints it
138
+ in the platform's API-keys page (Settings → API Keys), or
139
+ programmatically via `api.admin.createTenantApiKey(tenantSlug,
140
+ { name, data_access_mode })` (platform-admin-gated).
141
+
125
142
  Sessions come in three scopes:
126
143
  - **root** — Alvera root admin; user signup + confirmation
144
+ (`apiKey: ''` — no tenant to scope a key to)
127
145
  - **tenantless** — authenticated user with no tenant chosen yet;
128
146
  used once to create a tenant via `api.tenants.create(...)`
147
+ (`apiKey: ''`)
129
148
  - **tenant-scoped** — the canonical Bearer for tenant operations
149
+ (**requires a real tenant API key** — the login 401s with
150
+ `X-API-Key required to sign in to a tenant` otherwise)
151
+
152
+ **Build apps tenant-scoped, and only tenant-scoped.** The root and
153
+ tenantless scopes exist for the platform's own UI and for
154
+ admin/bootstrap flows (signup, confirmation, tenant creation,
155
+ test-suite setup) — they are NOT app-building surfaces; an app that
156
+ holds a root or tenantless Bearer is misdesigned. An app
157
+ authenticates against exactly one tenant with that tenant's
158
+ publishable key. Likewise, connected apps are **single-tenant by
159
+ construction** — one connected app per tenant, never one deployment
160
+ serving many tenants (see `connected_apps.md`).
130
161
 
131
162
  Consumers holding multiple concurrent clients in the same file
132
163
  (e.g. integration tests that need root + tenantless + tenant-
@@ -189,7 +220,13 @@ business outcome.
189
220
  WORKFLOWS (composition on top)
190
221
  ─────────
191
222
  workflows.md Filter + decision + action;
192
- standard + agent-driven variants.
223
+ standard + agent-driven variants;
224
+ §8 watching a run.
225
+ messages.md The messages DATASET — what went
226
+ out, delivery status, engagement.
227
+ action_logs.md The action_logs DATASET — per-
228
+ action outcomes, batch_id joins,
229
+ audience export.
193
230
  ```
194
231
 
195
232
  Axes are mostly disjoint but a few resources cross. AI Agents
@@ -217,6 +254,13 @@ wire name (snake_case):
217
254
  api.dataActivationClients data_activation_clients.md
218
255
  api.workflows workflows.md
219
256
 
257
+ Two corpus pages document DATASETS, not namespaces — read
258
+ through `api.datasets` with SINGULAR dataset types (the plural
259
+ forms are the SQL table names used by `executeSql`):
260
+
261
+ dataset type 'message' (table messages) messages.md
262
+ dataset type 'action_log' (table action_logs) action_logs.md
263
+
220
264
  ## Cookbooks (Golden Path scenarios)
221
265
 
222
266
  `.agent/cookbook/<slug>.md` files are pure-markdown scenario
@@ -47,28 +47,51 @@ request.
47
47
  import { createSession } from '@alvera-ai/platform-sdk'
48
48
 
49
49
  // root admin (no tenant) — for confirmUser, signUp on behalf of, etc.
50
+ // apiKey is required by the type but inert for non-tenant scopes — pass ''.
50
51
  const root = await createSession({
51
- baseUrl, email: rootEmail, password: rootPassword,
52
+ baseUrl, email: rootEmail, password: rootPassword, apiKey: '',
52
53
  })
53
54
 
54
55
  // tenantless — authenticated user who has not picked a tenant yet
55
56
  const tenantless = await createSession({
56
- baseUrl, email, password,
57
+ baseUrl, email, password, apiKey: '',
57
58
  })
58
59
  // tenantless.tenant === null
59
60
 
60
- // tenant-scoped — the canonical Bearer for tenant operations
61
+ // tenant-scoped — the canonical Bearer for tenant operations.
62
+ // REQUIRES a real tenant API key (sent as X-API-Key): the server 401s
63
+ // with "X-API-Key required to sign in to a tenant" without one. The
64
+ // minted Bearer inherits the origin policy of the key that minted it.
61
65
  const tenant = await createSession({
62
- baseUrl, email, password, tenantSlug,
66
+ baseUrl, email, password, tenantSlug, apiKey: tenantApiKey,
63
67
  })
64
68
  // tenant.tenant.slug === tenantSlug
65
69
  // tenant.role.name === 'tenant_admin' | 'member' | ...
66
70
  ```
67
71
 
72
+ The tenant API key is minted by a tenant admin in the platform's
73
+ API-keys page (Settings → API Keys), or programmatically via the
74
+ platform-admin-gated side door:
75
+
76
+ ```typescript
77
+ const { data: minted } = await rootApi.admin.createTenantApiKey(tenantSlug, {
78
+ name: 'Bootstrap Key',
79
+ data_access_mode: 'unregulated',
80
+ })
81
+ // minted.api_key — the publishable key value (thread into createSession)
82
+ ```
83
+
68
84
  See `AGENTS.md` "Sessions come in three scopes" for when each is
69
85
  appropriate. Tenantless is a transient state used once: to create
70
86
  a tenant (creator path) or accept an invitation (invitee path).
71
87
 
88
+ **Scope discipline for app builders:** root and tenantless sessions
89
+ are platform-UI / admin-bootstrap surfaces (signup, confirmation,
90
+ tenant creation, test setup) — never ship them inside an app. An
91
+ app authenticates **tenant-scoped against exactly one tenant**,
92
+ with that tenant's publishable key; connected apps in particular
93
+ are single-tenant by construction (`connected_apps.md`).
94
+
72
95
  ### Explicit teardown — `revokeSession`
73
96
 
74
97
  The inverse of `createSession`. Revokes the current Bearer on
@@ -214,7 +237,7 @@ client-side"). Use the returned slug for downstream calls.
214
237
  **Gotcha**: the response carries a `session_token` field, but this
215
238
  is an *incidental* token — do not treat it as a freshly minted
216
239
  tenant-scoped Bearer. The authoritative pattern is to call
217
- `createSession({ ..., tenantSlug })` after `tenants.create`. This
240
+ `createSession({ ..., tenantSlug, apiKey })` after `tenants.create`. This
218
241
  gives a fresh Bearer with all role + tenant claims correctly
219
242
  populated.
220
243
 
@@ -223,6 +246,8 @@ populated.
223
246
  ```typescript
224
247
  const { data: tenants } = await api.tenants.list()
225
248
  // tenants.data: TenantResponse[] — every tenant the current Bearer can see
249
+ // takes an optional Flop query (page/page_size/order_by/filters —
250
+ // see type_naming.md "List response types")
226
251
  ```
227
252
 
228
253
  A root Bearer sees every tenant; a tenant-scoped Bearer typically
@@ -277,7 +302,7 @@ the invitation enum:
277
302
  | `'datalake_api'` | Programmatic datalake access | provisioned, not invited |
278
303
 
279
304
  ```typescript
280
- const session = await createSession({ baseUrl, email, password, tenantSlug })
305
+ const session = await createSession({ baseUrl, email, password, tenantSlug, apiKey: tenantApiKey })
281
306
  // session.role.name === 'tenant_admin' if accepted from a role: 'admin' invitation
282
307
  // session.role.name === 'member' if accepted from a role: 'member' invitation
283
308
  // session.role.name === 'researcher' if accepted from a role: 'researcher' invitation
@@ -327,7 +352,7 @@ const mine = invites.data.find(i => i.tenant.slug === tenantSlug)
327
352
  const { data: membership } = await tenantlessApi.invitations.accept(mine.id)
328
353
 
329
354
  // Then mint a tenant-scoped Bearer
330
- const session = await createSession({ baseUrl, email, password, tenantSlug })
355
+ const session = await createSession({ baseUrl, email, password, tenantSlug, apiKey: tenantApiKey })
331
356
  // session.role.name === 'member'
332
357
  ```
333
358
 
@@ -419,7 +444,7 @@ treat as "already exists" via `api.tenants.list` lookup.
419
444
 
420
445
  2. **The `session_token` returned by `tenants.create` is
421
446
  incidental.** Always re-mint a fresh tenant-scoped Bearer via
422
- `createSession({ ..., tenantSlug })` after creating a tenant.
447
+ `createSession({ ..., tenantSlug, apiKey })` after creating a tenant.
423
448
  This guarantees clean role+tenant claims on the Bearer.
424
449
 
425
450
  3. **Tenantless Bearer is a transient state.** Only two
@@ -0,0 +1,139 @@
1
+ # Action logs (datalake dataset)
2
+
3
+ One row = one action outcome for one subject. Where `messages`
4
+ answers "what went out", `action_logs` answers "what did each
5
+ action do, per subject, per run" — including the actions that did
6
+ NOT send (skipped, filtered, failed).
7
+
8
+ Like `messages`, this is a **dataset** — read it via
9
+ `api.datasets.createUserSearch` + `api.datasets.search` with
10
+ dataset type `'action_log'` — **singular**; the plural
11
+ `action_logs` is the SQL *table* name (`executeSql`) and 422s as
12
+ a dataset type. Base SQL alias: `ral`. Rows exist only for
13
+ **live** mode — dry runs never write here.
14
+
15
+ ## 1. When a row appears (the scheduled gap)
16
+
17
+ Not all at once. Two moments:
18
+
19
+ ```
20
+ at run time — failed / filtered / skipped outcomes
21
+ land immediately
22
+ at execution time — a scheduled action gets its row only
23
+ when the trigger clock fires and it
24
+ actually executes
25
+ ```
26
+
27
+ Between those two moments a scheduled send is INVISIBLE here: its
28
+ execution log exists (the platform accepted it), but no
29
+ action_log row and no message row yet. A UI that only reads the
30
+ datalake shows nothing and looks broken — read the execution logs
31
+ (`workflows.md` §6) and say "scheduled — nothing sent yet".
32
+
33
+ ## 2. Fields
34
+
35
+ ```
36
+ id, workflow_id, action_id, workflow_execution_log_id,
37
+ batch_id — the run this outcome belongs to; THE
38
+ campaign key (messages does not have it)
39
+ decision_key — which action fired
40
+ mdm_subject_id, subject_name, session_id
41
+ action_type ('sms'|'email'|'voice'|'data_exchange'), channel,
42
+ classification
43
+ status — see §3
44
+ external_id — provider id; joins to messages.external_id
45
+ sent_at, tool_call_payload, idempotency_key, metadata,
46
+ attachments, body, sender_tool_id
47
+ ```
48
+
49
+ ## 3. Statuses
50
+
51
+ ```
52
+ pending → queued → sent → delivered
53
+ └──→ failed
54
+
55
+ skipped — the action's runtime_filter said no; nothing sent
56
+ filtered — the parent run was filtered out; nothing sent
57
+ cancelled — manually cancelled
58
+ ```
59
+
60
+ Initial status mirrors the action execution log's outcome; a
61
+ status updater with an `action_log_config` can move
62
+ `sent → delivered/failed` here too (optional — many setups only
63
+ reconcile messages).
64
+
65
+ ## 4. How everything joins
66
+
67
+ ```
68
+ run (batch_id) ──── WorkflowRunLog "how is the batch doing"
69
+ │
70
+ ├── WorkflowExecutionLog (per subject) "what did the pipeline decide"
71
+ │ │
72
+ │ └── ActionExecutionLog "what did each action attempt"
73
+ │ │ (live + actually executed)
74
+ │ ▼
75
+ └────────── action_logs row "the outcome, queryable"
76
+ │ external_id
77
+ ▼
78
+ messages row "the communication + delivery"
79
+ ```
80
+
81
+ Left side lives in the platform DB (read via
82
+ `api.workflows.batchLogs` / `api.workflows.workflowLogs`); the
83
+ bottom two are datalake datasets. `batch_id` ties the datalake
84
+ rows back to the run; `external_id` ties an action_log to its
85
+ message.
86
+
87
+ ## 5. Reading + audience export
88
+
89
+ ```typescript
90
+ // dataset type is SINGULAR 'action_log' (the table is plural)
91
+ const { data: search } = await api.datasets.createUserSearch(
92
+ tenantSlug, datalakeSlug, 'action_log',
93
+ { search_query: `ral.batch_id = '${batchId}'` },
94
+ )
95
+ const { data: page } = await api.datasets.search(
96
+ tenantSlug, datalakeSlug, 'action_log',
97
+ { userSearchId: search.id, dataAccessMode: 'unregulated' },
98
+ )
99
+ ```
100
+
101
+ Filterable columns (Flop): `status`, `batch_id`, `action_type`,
102
+ `channel`, `classification`, `workflow_id`, `sent_at`,
103
+ `external_id`, `mdm_subject_id`, `inserted_at`.
104
+
105
+ "Who did this campaign go to" = the query above (subject columns
106
+ + status per row — exportable as-is). For bulk export the platform
107
+ also merges each finished batch into NDJSON artifacts in cold
108
+ storage:
109
+
110
+ ```
111
+ <datalake>/workflows/<wf_slug>/<batch_id>/merged.ndjson (per-subject)
112
+ <datalake>/workflows/<wf_slug>/<batch_id>/<decision_key>/merged.ndjson (per-action payloads)
113
+ ```
114
+
115
+ The batch log row (`api.workflows.batchLogs.get`) carries the
116
+ download references once the batch completes.
117
+
118
+ ## 6. Gotchas
119
+
120
+ 1. **The scheduled gap (§1).** No action_log row ≠ nothing
121
+ happened. Check the execution logs before declaring a run
122
+ empty.
123
+
124
+ 2. **`batch_id` is a string, not a UUID** — manual runs look like
125
+ `manual:<uuid>`; DAC-triggered runs carry the ingest batch id.
126
+ Compare as strings.
127
+
128
+ 3. **One subject can have many rows for the same decision.**
129
+ Re-fires via `workflows.execute` with `manual_override: true`
130
+ (the retry path) create a NEW action_log each time. Latest row
131
+ wins for "current state"; the full set is the audit trail.
132
+
133
+ 4. **`skipped`/`filtered` rows are successes of the control
134
+ system, not failures.** Only `failed` is the retry surface
135
+ (`workflows.md` §8).
136
+
137
+ 5. **`tool_call_payload` is the rendered call** — what was
138
+ actually sent to the tool, after Liquid. When a message looks
139
+ wrong, read this before blaming the template engine.
@@ -204,11 +204,12 @@ branch.
204
204
  ### Read shapes
205
205
 
206
206
  ```
207
- .list(tenantSlug, datalakeSlug) Paged: { data, meta }
208
- .get(tenantSlug, datalakeSlug, idOrSlug) One row
207
+ .list(tenantSlug, datalakeSlug, query?) Paged: { data, meta }
208
+ (full Flop query — type_naming.md)
209
+ .get(tenantSlug, datalakeSlug, id) One row — UUID only
209
210
  .metadata(tenantSlug, datalakeSlug) Markdown — catalog
210
211
  .metadataDetails(tenantSlug, datalakeSlug, Markdown — one updater
211
- idOrSlug)
212
+ id) (UUID only)
212
213
  ```
213
214
 
214
215
  ### Update
@@ -262,3 +263,35 @@ check or retry for partial-delete states.
262
263
  transient delivery delays don't fall between two polls.
263
264
  The defaults in the wire-shape example illustrate this
264
265
  pattern: 30-minute cron, 45-minute window.
266
+
267
+ ## 7. Where delivery truth comes from (consumer view)
268
+
269
+ If you only READ the platform (an operator UI, a dashboard), this
270
+ is the part that matters:
271
+
272
+ ```
273
+ dispatch writes: messages row, status 'sent',
274
+ external_id = provider's id
275
+ THIS updater writes: 'delivered' or 'failed' +
276
+ failure_reason (+ delivered_at)
277
+ nothing else writes those.
278
+ ```
279
+
280
+ - **No updater configured → no delivery truth.** Rows stay `sent`
281
+ forever; a delivery-rate number computed from them is fiction.
282
+ Updaters are created manually (this page §1) — the platform does
283
+ not auto-create them per sender tool.
284
+ - **The join is `{external_id, set_params}`,** exact-match, silent
285
+ on misses (gotcha 3). "Eventually honest": a just-sent row is
286
+ neither delivered nor failed yet — show the in-between state.
287
+ - **Retry is not here.** The updater only reports. Re-sending a
288
+ failed message is `workflows.execute` for that subject +
289
+ decision (`mode: 'live'`, `manual_override: true`) — the same
290
+ call as any manual dispatch; there is no retry endpoint. See
291
+ `workflows.md` §8.
292
+ - **Engagement is not here either.** `opened_at` /
293
+ `form_submitted_at` arrive via
294
+ `connectedApps.updateMessageTracking` (magic-link seam,
295
+ `connected_apps.md` §6) and never touch `status`.
296
+ - Field-by-field semantics of the rows being updated:
297
+ `messages.md`, `action_logs.md`.
@@ -331,7 +331,8 @@ once `enabled: true`.
331
331
  ### Read shapes
332
332
 
333
333
  ```
334
- .list(tenantSlug, datalakeSlug) Paged: { data, meta }
334
+ .list(tenantSlug, datalakeSlug, query?) Paged: { data, meta }
335
+ (full Flop query — type_naming.md)
335
336
  .get(tenantSlug, datalakeSlug, id) One row, BY ID (UUID — not slug)
336
337
  .metadata(tenantSlug, datalakeSlug) Markdown catalog
337
338
  .metadataDetails(tenantSlug, datalakeSlug, Markdown for one agent;
@@ -27,6 +27,15 @@ The namespace splits into **two roles**:
27
27
  machine-to-machine API key) when a customer hits a magic link. Uses the app's
28
28
  `slug` as the path key.
29
29
 
30
+ **Connected apps are single-tenant, full stop.** A connected app
31
+ belongs to exactly ONE tenant (and one datalake under it): its
32
+ auto-provisioned M2M key is tenant-scoped, its magic links resolve
33
+ against that tenant's data, and its publishable browser key carries
34
+ that tenant's origin allowlist. Do NOT design a connected app that
35
+ serves multiple tenants from one deployment — per tenant, register
36
+ a separate connected app (separate keys, separate routes). There is
37
+ no cross-tenant resolution path, by construction.
38
+
30
39
  Connected apps are **Datalake-DB-resident** — the parent datalake
31
40
  must be `status: 'ready'` before you `create()`.
32
41
 
@@ -219,7 +228,8 @@ and can be retried via `.syncRoutes`.
219
228
  ### Read shapes — management
220
229
 
221
230
  ```
222
- .list(tenantSlug, datalakeSlug) Paged: { data, meta }
231
+ .list(tenantSlug, datalakeSlug, query?) Paged: { data, meta }
232
+ (full Flop query — type_naming.md)
223
233
  .get(tenantSlug, datalakeSlug, id) One row, by UUID
224
234
  .metadata(tenantSlug, datalakeSlug) Markdown catalog
225
235
  .metadataDetails(tenantSlug, datalakeSlug, Markdown for one app
@@ -343,7 +353,16 @@ await api.connectedApps.updateMessageTracking(
343
353
 
344
354
  Both calls are authenticated with the connected app's
345
355
  auto-provisioned machine-to-machine API key (typically configured as
346
- `ALVERA_API_KEY` in the deployment environment).
356
+ `ALVERA_API_KEY` in the deployment environment). Both are also on
357
+ the `public_api` (publishable-key) allowlist — alongside `ingest`,
358
+ `ingest-file`, and `mdm/verify` — so a browser-side app holding
359
+ only a publishable key can call them.
360
+
361
+ **This is the engagement seam.** `updateMessageTracking` writes
362
+ `opened_at` / `form_submitted_at` onto the message row — the
363
+ open/click/conversion half of campaign analytics — and it NEVER
364
+ changes `status` (delivery truth belongs to the action status
365
+ updater; see `messages.md` §1 for who writes which field).
347
366
 
348
367
  ### Workflow-side wiring
349
368
 
@@ -36,13 +36,15 @@ having to copy-paste this prelude.
36
36
  ## 001 — root admin signs in and provisions a fresh industry-admin user
37
37
 
38
38
  Authenticate as the platform's root admin (`admin@dev.local` /
39
- `devpassword` in local dev). Then sign up a per-run industry-admin
39
+ `devpassword` in local dev) via the tenantless bootstrap login —
40
+ keyless by structural necessity (no tenant exists yet to scope a
41
+ key to), and a dev/test-only surface. Then sign up a per-run industry-admin
40
42
  user (`sarah`) under a unique email derived from `runSuffix`, and
41
43
  confirm them so they can sign in. Both the signup and the confirm
42
44
  endpoints require root scope.
43
45
 
44
46
  ```typescript
45
- ctx.rootSession = await createSession({
47
+ ctx.rootSession = await createBootstrapSession({
46
48
  baseUrl: process.env.ALVERA_BASE_URL!,
47
49
  email: process.env.ALVERA_ROOT_EMAIL!,
48
50
  password: process.env.ALVERA_ROOT_PASSWORD!,
@@ -50,12 +52,13 @@ ctx.rootSession = await createSession({
50
52
  ctx.rootApi = createIsolatedPlatformApi({
51
53
  baseUrl: process.env.ALVERA_BASE_URL!,
52
54
  sessionToken: ctx.rootSession.sessionToken,
55
+ apiKey: '',
53
56
  })
54
57
 
55
58
  ctx.sarahEmail = `cookbook-sarah-${runSuffix}@dev.local`
56
59
  ctx.sarahPassword = 'CookbookPass1!'
57
60
 
58
- const signUpResp = await ctx.rootApi.auth.signUp({
61
+ const signUpResp = await ctx.rootApi.admin.signUp({
59
62
  email: ctx.sarahEmail,
60
63
  password: ctx.sarahPassword,
61
64
  first_name: 'Cookbook',
@@ -74,7 +77,7 @@ captured into the closure-scoped `tenantSlug` slot for downstream
74
77
  steps.
75
78
 
76
79
  ```typescript
77
- ctx.sarahTenantlessSession = await createSession({
80
+ ctx.sarahTenantlessSession = await createBootstrapSession({
78
81
  baseUrl: process.env.ALVERA_BASE_URL!,
79
82
  email: ctx.sarahEmail,
80
83
  password: ctx.sarahPassword,
@@ -82,6 +85,7 @@ ctx.sarahTenantlessSession = await createSession({
82
85
  ctx.sarahTenantlessApi = createIsolatedPlatformApi({
83
86
  baseUrl: process.env.ALVERA_BASE_URL!,
84
87
  sessionToken: ctx.sarahTenantlessSession.sessionToken,
88
+ apiKey: '',
85
89
  })
86
90
 
87
91
  const tenantResp = await ctx.sarahTenantlessApi.tenants.create({
@@ -98,15 +102,28 @@ is assigned to the closure-scoped `api` slot so the scenario
98
102
  cookbook's steps inherit it via inlining.
99
103
 
100
104
  ```typescript
105
+ // A tenant-scoped login requires X-API-Key — mint a public_api key for
106
+ // the fresh tenant via the platform-admin side door (root Bearer). In the
107
+ // web console this is Settings -> API Keys; any of the tenant's keys
108
+ // satisfies the login gate, and the minted Bearer inherits this key's
109
+ // origin policy.
110
+ const { data: mintedKey } = await ctx.rootApi.admin.createTenantApiKey(tenantSlug, {
111
+ name: `Cookbook Bootstrap Key ${runSuffix}`,
112
+ data_access_mode: 'unregulated',
113
+ })
114
+ ctx.tenantApiKey = mintedKey.api_key
115
+
101
116
  const sarahTenantSession = await createSession({
102
117
  baseUrl: process.env.ALVERA_BASE_URL!,
103
118
  email: ctx.sarahEmail,
104
119
  password: ctx.sarahPassword,
105
120
  tenantSlug,
121
+ apiKey: ctx.tenantApiKey,
106
122
  })
107
123
  api = createIsolatedPlatformApi({
108
124
  baseUrl: process.env.ALVERA_BASE_URL!,
109
125
  sessionToken: sarahTenantSession.sessionToken,
126
+ apiKey: ctx.tenantApiKey,
110
127
  })
111
128
  ```
112
129
 
@@ -266,6 +283,9 @@ After this setup runs, the closure-scoped slots are populated as:
266
283
  - `ctx.sarahEmail` / `ctx.sarahPassword` — the per-run admin
267
284
  credentials (kept on `ctx` because no scenario cookbook needs
268
285
  to re-authenticate by default)
286
+ - `ctx.tenantApiKey` — the tenant's publishable API key (minted in
287
+ setup via `admin.createTenantApiKey`); thread it into any
288
+ additional tenant-scoped `createSession` a scenario performs
269
289
 
270
290
  Scenario cookbooks under `industry: accounts_receivable` start at
271
291
  their own `§001` with these slots already populated.
@@ -35,13 +35,15 @@ having to copy-paste this prelude.
35
35
  ## 001 — root admin signs in and provisions a fresh industry-admin user
36
36
 
37
37
  Authenticate as the platform's root admin (`admin@dev.local` /
38
- `devpassword` in local dev). Then sign up a per-run industry-admin
38
+ `devpassword` in local dev) via the tenantless bootstrap login —
39
+ keyless by structural necessity (no tenant exists yet to scope a
40
+ key to), and a dev/test-only surface. Then sign up a per-run industry-admin
39
41
  user (`sarah`) under a unique email derived from `runSuffix`, and
40
42
  confirm them so they can sign in. Both the signup and the confirm
41
43
  endpoints require root scope.
42
44
 
43
45
  ```typescript
44
- ctx.rootSession = await createSession({
46
+ ctx.rootSession = await createBootstrapSession({
45
47
  baseUrl: process.env.ALVERA_BASE_URL!,
46
48
  email: process.env.ALVERA_ROOT_EMAIL!,
47
49
  password: process.env.ALVERA_ROOT_PASSWORD!,
@@ -49,12 +51,13 @@ ctx.rootSession = await createSession({
49
51
  ctx.rootApi = createIsolatedPlatformApi({
50
52
  baseUrl: process.env.ALVERA_BASE_URL!,
51
53
  sessionToken: ctx.rootSession.sessionToken,
54
+ apiKey: '',
52
55
  })
53
56
 
54
57
  ctx.sarahEmail = `cookbook-sarah-${runSuffix}@dev.local`
55
58
  ctx.sarahPassword = 'CookbookPass1!'
56
59
 
57
- const signUpResp = await ctx.rootApi.auth.signUp({
60
+ const signUpResp = await ctx.rootApi.admin.signUp({
58
61
  email: ctx.sarahEmail,
59
62
  password: ctx.sarahPassword,
60
63
  first_name: 'Cookbook',
@@ -72,7 +75,7 @@ foundation tenant. The tenant's server-derived `slug` is captured
72
75
  into the closure-scoped `tenantSlug` slot for downstream steps.
73
76
 
74
77
  ```typescript
75
- ctx.sarahTenantlessSession = await createSession({
78
+ ctx.sarahTenantlessSession = await createBootstrapSession({
76
79
  baseUrl: process.env.ALVERA_BASE_URL!,
77
80
  email: ctx.sarahEmail,
78
81
  password: ctx.sarahPassword,
@@ -80,6 +83,7 @@ ctx.sarahTenantlessSession = await createSession({
80
83
  ctx.sarahTenantlessApi = createIsolatedPlatformApi({
81
84
  baseUrl: process.env.ALVERA_BASE_URL!,
82
85
  sessionToken: ctx.sarahTenantlessSession.sessionToken,
86
+ apiKey: '',
83
87
  })
84
88
 
85
89
  const tenantResp = await ctx.sarahTenantlessApi.tenants.create({
@@ -96,15 +100,28 @@ is assigned to the closure-scoped `api` slot so the scenario
96
100
  cookbook's steps inherit it via inlining.
97
101
 
98
102
  ```typescript
103
+ // A tenant-scoped login requires X-API-Key — mint a public_api key for
104
+ // the fresh tenant via the platform-admin side door (root Bearer). In the
105
+ // web console this is Settings -> API Keys; any of the tenant's keys
106
+ // satisfies the login gate, and the minted Bearer inherits this key's
107
+ // origin policy.
108
+ const { data: mintedKey } = await ctx.rootApi.admin.createTenantApiKey(tenantSlug, {
109
+ name: `Cookbook Bootstrap Key ${runSuffix}`,
110
+ data_access_mode: 'unregulated',
111
+ })
112
+ ctx.tenantApiKey = mintedKey.api_key
113
+
99
114
  const sarahTenantSession = await createSession({
100
115
  baseUrl: process.env.ALVERA_BASE_URL!,
101
116
  email: ctx.sarahEmail,
102
117
  password: ctx.sarahPassword,
103
118
  tenantSlug,
119
+ apiKey: ctx.tenantApiKey,
104
120
  })
105
121
  api = createIsolatedPlatformApi({
106
122
  baseUrl: process.env.ALVERA_BASE_URL!,
107
123
  sessionToken: sarahTenantSession.sessionToken,
124
+ apiKey: ctx.tenantApiKey,
108
125
  })
109
126
  ```
110
127
 
@@ -262,6 +279,9 @@ After this setup runs, the closure-scoped slots are populated as:
262
279
  - `ctx.sarahEmail` / `ctx.sarahPassword` — the per-run admin
263
280
  credentials (kept on `ctx` because no scenario cookbook needs
264
281
  to re-authenticate by default)
282
+ - `ctx.tenantApiKey` — the tenant's publishable API key (minted in
283
+ setup via `admin.createTenantApiKey`); thread it into any
284
+ additional tenant-scoped `createSession` a scenario performs
265
285
 
266
286
  Scenario cookbooks under `industry: foundation` start at their own
267
287
  `§001` with these slots already populated.