@foir/sdk 0.7.0 → 0.9.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 (61) hide show
  1. package/CHANGELOG.md +857 -4
  2. package/README.md +46 -15
  3. package/dist/auth/auth.d.ts +104 -1
  4. package/dist/auth/hosted.d.ts +22 -0
  5. package/dist/auth/index.d.ts +13 -7
  6. package/dist/auth/operations.d.ts +85 -64
  7. package/dist/auth/ops.d.ts +99 -0
  8. package/dist/auth.js +786 -106
  9. package/dist/chunk-IGNLWT3H.js +35 -0
  10. package/dist/chunk-OHYVKTGB.js +45 -0
  11. package/dist/chunk-TAL5H6VA.js +39 -0
  12. package/dist/client.js +25 -31
  13. package/dist/codegen/block-registry.d.ts +13 -19
  14. package/dist/codegen/block-registry.js +3 -31
  15. package/dist/codegen/block-scaffold.d.ts +12 -21
  16. package/dist/codegen/block-scaffold.js +3 -41
  17. package/dist/codegen/index.d.ts +9 -4
  18. package/dist/codegen/index.js +7 -11
  19. package/dist/codegen/item-registry.d.ts +28 -0
  20. package/dist/codegen/item-registry.js +7 -0
  21. package/dist/codegen/item-scaffold.d.ts +29 -0
  22. package/dist/codegen/item-scaffold.js +7 -0
  23. package/dist/codegen/preset.d.ts +14 -7
  24. package/dist/codegen/shared.d.ts +20 -9
  25. package/dist/content/content-renderer.d.ts +90 -24
  26. package/dist/content/index.d.ts +13 -7
  27. package/dist/content/richtext.d.ts +66 -18
  28. package/dist/content.js +227 -80
  29. package/dist/data/builtins.d.ts +369 -92
  30. package/dist/data/client.d.ts +97 -25
  31. package/dist/data/document.d.ts +251 -0
  32. package/dist/data/index.d.ts +38 -19
  33. package/dist/data/manifest.d.ts +290 -8
  34. package/dist/data/schema.d.ts +78 -8
  35. package/dist/data/selection.d.ts +231 -37
  36. package/dist/data/subjects.d.ts +221 -0
  37. package/dist/data/subscription-document.d.ts +75 -0
  38. package/dist/data/subscriptions.d.ts +181 -0
  39. package/dist/data/transport.d.ts +255 -37
  40. package/dist/data/ws-transport.d.ts +197 -0
  41. package/dist/data.js +1419 -45
  42. package/dist/graphql/cdn-route.d.ts +12 -28
  43. package/dist/media/index.d.ts +6 -2
  44. package/dist/media/media.d.ts +2 -2
  45. package/dist/media/screen-capture.d.ts +81 -0
  46. package/dist/media.js +97 -0
  47. package/dist/next.js +663 -182
  48. package/dist/push.js +700 -90
  49. package/dist/react/FoirFeedback.d.ts +62 -0
  50. package/dist/react/feedback-notes.d.ts +101 -0
  51. package/dist/react/index.d.ts +7 -1
  52. package/dist/react.js +1272 -194
  53. package/dist/realtime.js +1 -1
  54. package/dist/server/events-client.d.ts +279 -0
  55. package/dist/server/index.d.ts +4 -0
  56. package/dist/server.js +298 -0
  57. package/dist/transport/foir-http.d.ts +23 -6
  58. package/dist/transport/persisted-query.d.ts +68 -0
  59. package/dist/transport/retry.d.ts +1 -1
  60. package/package.json +13 -1
  61. package/dist/chunk-ADO5FJNM.js +0 -30
package/CHANGELOG.md CHANGED
@@ -106,6 +106,860 @@ Changes on `dev` that have not been published to npm.
106
106
 
107
107
  ---
108
108
 
109
+ ## [0.9.0] — 2026-08-20
110
+
111
+ Everything additive, so a MINOR either way under the pre-1.0 note.
112
+
113
+ ### Added
114
+
115
+ - **`hostedLogoutUrl(config, clientId, returnTo?)` on `./auth`** — the mirror of
116
+ `hostedLoginUrl`, and the only thing that actually signs a customer out on the
117
+ hosted-cookie flow.
118
+
119
+ Login on that flow is symmetric across browser and server; logout was not.
120
+ The apex `foir_customer_session` cookie is the durable credential, and
121
+ disposing of a bearer, clearing a local session, or calling the
122
+ `customerLogout` mutation all leave it in place — the next request
123
+ re-establishes the customer from it. The only surface that ended a session was
124
+ `POST /session/logout`, cookie-authenticated and CSRF-header-gated, which only
125
+ a browser sending `credentials: 'include'` can reach.
126
+
127
+ This is a URL builder: no transport, no CORS surface, no CSRF header, no
128
+ JavaScript required. Server-side logout becomes
129
+ `return redirect(hostedLogoutUrl(config, clientId, returnTo))`. `returnTo`
130
+ must be absolute and on an origin registered for `clientId`.
131
+
132
+ Requires the platform's `end-session` endpoint (TTS-1534). `HostedLoginConfig`
133
+ gains an optional `endSessionPath`, defaulting to `/session/end-session`.
134
+
135
+ - **`Prose`, `Passage`, `ProseSpan` and `RecordAncestor` on `./data`.** The
136
+ shapes a prose field and the record hierarchy resolve to, shipped here
137
+ rather than generated per project because they are identical everywhere —
138
+ the same reason `Image` and `Reference` live here.
139
+
140
+ `Prose<E>` is parameterised on its entries element: `Prose<Passage>` for a
141
+ text-only field, `Prose<Cta | Passage>` for one that admits embeds. `foir
142
+ types` fills the parameter in from the field's own member set.
143
+
144
+ - **`FoirManifestStructural`, and `structurals` on `FoirManifestModel`.** The
145
+ manifest's bucket for a platform-owned object with no model behind it — a
146
+ prose wrapper, an `ancestors` list. Absent on a server that predates it,
147
+ where those fields landed in `unclassifiedFields` and dropped out of the
148
+ selection entirely; widen on absent.
149
+
150
+ ---
151
+
152
+ ## [0.8.0] — 2026-08-20
153
+
154
+ **BREAKING** — see `foir.raw()` under "Changed". A MINOR, per the pre-1.0
155
+ note above.
156
+
157
+ > Filed after the fact. 0.8.0 was published with a version bump and no
158
+ > changelog entry, so everything below sat under "Unreleased" while it was
159
+ > already on npm. Recorded here rather than left there, because a consumer
160
+ > upgrading from 0.7.0 has no other account of what changed.
161
+
162
+ ### Removed
163
+
164
+ - **BREAKING — `FoirBlockMeta`, `MediaBlock`, `TextBlock` and `FlexibleBlock`
165
+ are gone from `./data`.** They have no replacement because the things they
166
+ described are not in the schema:
167
+
168
+ - `FoirBlockMeta` declared `_id`/`_key`/`_label`/`_type` on every member. That
169
+ was the wrapper's envelope, removed at TTS-1391 — an item IS its value. No
170
+ member carries any of the four, so every one was a field the types promised
171
+ and the API never returned.
172
+ - `MediaBlock` — `media` stopped being a primitive at TTS-1391. It was never
173
+ in the proto FieldType enum, and it is the one primitive name a project had
174
+ also used as a model key.
175
+ - `TextBlock` — removed at TTS-1406. GraphQL requires a union member to be an
176
+ object type and a text item's value is a bare scalar, which is the whole
177
+ reason a wrapper existed. An inline model with a text field is how a text
178
+ slot is authored.
179
+ - `FlexibleBlock` — there is no fallback member. An item whose `_type` names
180
+ nothing is nulled out of its list and reported, which is already what a
181
+ stored null reads as.
182
+
183
+ **Migration:** delete the import. Code narrowing on `__typename ===
184
+ 'MediaBlock' | 'TextBlock' | 'FlexibleBlock'` was matching nothing, so the
185
+ branch was already dead. Code reading `_id`/`_key`/`_label`/`_type` off a
186
+ member was reading `undefined` through a type that said `string | null`.
187
+
188
+ Nothing generates them either: `foir types` emitted `FoirBlockMeta` and the
189
+ three tokens, and no longer does — see the CLI entry below.
190
+
191
+ ### Changed
192
+
193
+ - **BREAKING (`foir types`) — a flexible union member is the model's own type,
194
+ and the per-model wrapper is gone.** `foir types` used to emit a second
195
+ interface for every inline model — `ActHero` AND `ActHeroBlock` — where the
196
+ wrapper was `FoirBlockMeta` plus the model's non-`_`-prefixed fields. On the
197
+ Marketing fixture that was 20 of 47 interfaces describing a shape the platform
198
+ stopped serving at TTS-1391.
199
+
200
+ A union branch is now `(<Model> & { __typename: '<Model>' })`: the model
201
+ itself, intersected with the discriminant the union position guarantees. The
202
+ intersection is not a wrapper — it adds no field the server does not send. It
203
+ is there because the op compiler selects `__typename` on a union's own
204
+ selection set and nowhere else, so the model interface cannot declare it
205
+ outright, and because `@foir/sdk/data`'s narrowing surface is written as
206
+ `U extends { __typename: infer T }` — an optional key does not satisfy that,
207
+ and `pick: { on: … }` would silently stop working.
208
+
209
+ Where a model is reached BOTH as a member and as a model, the two answers now
210
+ live on one declaration keyed on `C`: `PageContent<D, C>` degrades `sections`
211
+ at `C extends true` and expands it otherwise, which is exactly what
212
+ `PageContentBlock` and `PageContent` said separately before.
213
+
214
+ **Migration:** re-run `foir types`. Narrow on `ActHero` rather than
215
+ `ActHeroBlock`; `_type`, `_key` and `_label` are gone with the envelope, and
216
+ `__typename` is the discriminant. Aliased response keys are unchanged — they
217
+ are a wire contract published in the manifest, so `imageBlockHeight` stays
218
+ `imageBlockHeight` until the platform republishes it.
219
+
220
+ - **BREAKING (`foir types`) — a manifest carrying a retired primitive is now
221
+ refused by name.** `text`, `media` and a `flexible` fallback member stopped
222
+ being union members at TTS-1391/1406. A manifest listing one predates that, so
223
+ the generator says so and names re-publishing as the fix rather than emitting
224
+ a branch for a type the server never sends.
225
+
226
+ - **BREAKING — the primitive members are the value types, under the schema's own
227
+ names.** `ImageValue` and `ImageBlock` are one type called `Image`, and the
228
+ same for the rest:
229
+
230
+ | was | now |
231
+ | --- | --- |
232
+ | `ImageValue` · `ImageBlock` | `Image` |
233
+ | `VideoValue` · `VideoBlock` | `Video` |
234
+ | `RichtextValue` · `RichtextBlock` | `Richtext` |
235
+ | `GeoPointValue` | `GeoPoint` |
236
+ | `ReferenceValue` · `ReferenceBlock` | `Reference` |
237
+ | `FileValue` · `FileBlock` | `FileValue` |
238
+
239
+ Every old name is kept as a deprecated alias of the new one, so imports keep
240
+ resolving. What changes underneath them:
241
+
242
+ - **`__typename` is now on each type**, optional, carrying the schema's name —
243
+ `'Image'`, not `'ImageBlock'`. The old literals matched nothing in a current
244
+ project, so an exhaustive `switch` over a flexible field fell through every
245
+ case.
246
+ - **`Image.url` and `Video.url` are nullable.** They were `string`. The
247
+ platform relaxed them to `String` at TTS-1391 — non-null on the value object
248
+ and nullable on the wrapper is a §5.3.2 response-shape conflict once the two
249
+ are one type — so `img.url.startsWith(…)` could throw on a value the types
250
+ said was present.
251
+ - **`Reference` loses `_id` and `_type`, and gains `_targetModelKey`,
252
+ `_targetId`, `_targetNaturalKey` and `record`.** `_id` was the ITEM's id and
253
+ `_type` read `"reference"` on every reference ever stored; neither is in the
254
+ schema. The target coordinates are what actually make a reference
255
+ followable (TTS-1349/1371), and both identifier shapes are live — on the dev
256
+ database 101 of 193 stored references carry an id and 92 carry a natural key
257
+ and no id.
258
+
259
+ **Migration:** narrow on the new `__typename` literals; treat `url` as
260
+ nullable; replace `ref._id` with `ref._targetId ?? ref._targetNaturalKey` and
261
+ `ref._type` with `ref.__typename`.
262
+
263
+ **Why this was wrong for so long:** `builtins.ts` is the one part of the typed
264
+ data surface that is not generated per project, and nothing compared it to a
265
+ real schema. `src/data/__tests__/builtins.test-d.ts` now pins every member
266
+ against graphql-codegen output taken from a live project.
267
+
268
+ - **BREAKING (behaviour) — the codegen plugins now default to the `Flexible`
269
+ union, not `Block`.** `DEFAULT_UNIONS` in `@foir/sdk/codegen` was `['Block']`;
270
+ it is `['Flexible']`.
271
+
272
+ The platform stopped emitting a union named `Block` at TTS-1391 — a flexible
273
+ field's members are the value types and inline models under their own names,
274
+ and the project-wide union an unconstrained field resolves to is `Flexible`.
275
+ So `foirCodegen()` on defaults could not have worked against any current
276
+ project: `collectItemMembers` refuses rather than emitting an empty registry,
277
+ so it threw `[foir codegen] no union members found. Looked for union(s):
278
+ Block`. This is listed as breaking because it changes a default, but the
279
+ default it replaces had no working configuration.
280
+
281
+ It stayed invisible because the plugin tests built their own fixture schema
282
+ declaring `union Block = RichtextBlock | ImageBlock | TextBlock` — the
283
+ pre-TTS-1391 shape. The fixture is now the post-1391 shape, and
284
+ `DEFAULT_UNIONS` is asserted against it directly.
285
+
286
+ **Migration:** none if you pass `blockUnions`/`itemUnions` explicitly. If you
287
+ relied on the default and codegen was failing, it now resolves. If your
288
+ project genuinely has a union named `Block`, pass `itemUnions: ['Block']`.
289
+
290
+ - **`createContentRenderer` passes `item` to every component, and accepts
291
+ `items` as well as `blocks`.** `props.block` is still passed on every render
292
+ and `blocks=` is still accepted, so scaffolded components and existing call
293
+ sites are unaffected. Passing both `items` and `blocks` is a type error rather
294
+ than a silent precedence rule.
295
+
296
+ ### Added
297
+
298
+ - **`@foir/sdk/codegen/item-registry` and `@foir/sdk/codegen/item-scaffold`** —
299
+ the two graphql-codegen plugins under names that do not claim `block` is a
300
+ platform concept. `@foir/sdk/codegen/block-registry` and `/block-scaffold`
301
+ re-export them unchanged and keep working.
302
+
303
+ - **`ItemLike`, `ItemComponent`, `ItemComponentProps`, `ItemRegistry`** on
304
+ `./content`, and **`ItemPluginConfig`**, **`ItemRegistryConfig`**,
305
+ **`ItemScaffoldConfig`** on the codegen subpaths. Each is the canonical name
306
+ for the `Block*` type of the same shape.
307
+
308
+ - **`foirCodegen({ itemUnions })` / `foirScaffold({ itemUnions })`** — the
309
+ canonical spelling of `blockUnions`, which is still honoured when `itemUnions`
310
+ is unset.
311
+
312
+ ### Deprecated
313
+
314
+ - **Every `Block*` spelling in the SDK.** `BlockLike`, `BlockComponent`,
315
+ `BlockComponentProps`, `BlockRegistry`, `BlockPluginConfig`,
316
+ `BlockRegistryConfig`, `BlockScaffoldConfig`, `collectBlockMembers`, the
317
+ `blockUnions` option, the `blocks` prop on `createContentRenderer`, the
318
+ `props.block` field, and the `./codegen/block-registry` and
319
+ `./codegen/block-scaffold` subpaths.
320
+
321
+ `block` was never a platform concept — it was one project's NAME for a
322
+ flexible field, and a modeller may equally call one `items`, `sections` or
323
+ `modules`. The platform never infers anything from a field's name and the SDK
324
+ should not teach otherwise (TTS-1391).
325
+
326
+ Nothing is removed in this release; every old name still resolves and still
327
+ behaves identically. Removal is a later, separately announced break.
328
+
329
+ - **BREAKING (generated types) — a union the compiler stopped in front of one
330
+ level BELOW a block union member is now typed as the degraded union, where it
331
+ used to be typed as the whole one.** Same direction as the composite entry
332
+ below: the type promised more than arrives, so code reading through it
333
+ compiled and got `undefined`.
334
+
335
+ The entry below fixed the composites cut at a union member's OWN fields, which
336
+ are emitted per position and could simply be degraded there. A cut can also
337
+ land one level deeper. `ProductDetailsAccordionBlock.items` is a composite the
338
+ server did NOT cut, so it expands and is typed with `Detail`'s own interface —
339
+ and `Detail.body` IS cut inside that subtree, because reaching it means
340
+ `Block` is already on the compiler's path.
341
+
342
+ `Detail` is one interface referenced from a cut position and an uncut one
343
+ (`product_details_accordion.items` at the model position), so it could not
344
+ simply be degraded: that would have been wrong at the other reference. The
345
+ answer now travels with the REFERENCE, as a second parameter threaded the way
346
+ `<D>` already is:
347
+
348
+ ```ts
349
+ export interface Detail<D extends FoirDepthLevel = 1, C extends boolean = false> {
350
+ label: string | null;
351
+ body: Array<(C extends true ? … : DetailBody<D>) | null> | null;
352
+ }
353
+
354
+ // inside the union member — cut
355
+ export interface ProductDetailsAccordionBlock<D extends FoirDepthLevel = 1> … {
356
+ items: Array<Detail<D, true> | null> | null;
357
+ }
358
+ // at the model position — whole
359
+ export interface ProductDetailsAccordion<D extends FoirDepthLevel = 1, C extends boolean = false> {
360
+ items: Array<Detail<D, C> | null> | null;
361
+ }
362
+ ```
363
+
364
+ **Nothing a caller writes ever passes `C`.** It defaults to `false`, so every
365
+ existing reference resolves exactly as it did; the position it came from is
366
+ what sets it.
367
+
368
+ On the live dev project **one position narrows**, and the full recursive
369
+ census is what says so: ten `truncated` positions anywhere in `models[*]
370
+ .selection` or `selectionUnions`, of which six were already correct, three
371
+ were fixed by the entry below, and this is the tenth. Re-run after the change,
372
+ the count of over-promising positions is **zero of ten**.
373
+
374
+ | position | was | is |
375
+ | --- | --- | --- |
376
+ | `ProductDetailsAccordionBlock.items[].body` | `Array<DetailBody \| null> \| null` | `Array<DetailBodyDegraded \| null> \| null` |
377
+
378
+ **`depth` DOES help here**, which is the difference from the composite case.
379
+ The position carries a union name, so `printFields` expands it when the budget
380
+ allows and the type follows: at `depth: 2` the same read resolves to the whole
381
+ `DetailBody` union and narrows on `__typename` with no cast. Verified against
382
+ deployed dev — at the level where the member arrives expanded, all ten live
383
+ `body` values are `{ "__typename": "RichtextBlock" }` and nothing else; one
384
+ level further, every one of them is a whole `RichtextBlock` carrying its
385
+ `html` and `json`.
386
+
387
+ **The wire is untouched.** Types only: 192 documents compared across five
388
+ manifests (the four committed fixtures and a live capture) at every level
389
+ 1–4, all byte-identical, and every emitted `foirSelections` /
390
+ `foirNamespaceSelections` / `foirSelectionUnions` const unchanged byte for
391
+ byte. **Regenerate with `foir types` to get the narrowing.**
392
+
393
+ A manifest with no selection trees is unaffected, as before: no `truncated`
394
+ means no cut, no interface gains the parameter, and the generated file is what
395
+ it was.
396
+
397
+ - **BREAKING (generated types) — a composite the compiler stopped in front of
398
+ inside a block union member is now typed `FoirDegraded`, where it used to be
399
+ typed whole.** The DEFAULT result type narrows at those positions, and code
400
+ reading through one stops compiling. That is the point: it compiles today
401
+ against a value that never arrives.
402
+
403
+ The op compiler never expands a type already on the current path, and the path
404
+ includes the union the composite was reached through. `foir types` was deciding
405
+ truncation from the composite GRAPH alone, which cannot see that edge — so a
406
+ composite reached from inside a union member got the target's full interface
407
+ for a position the server sends `{ "__typename": "PageContent" }` at. A caller
408
+ wrote `block.pageContent.sections[0].anchor`, it type-checked, and it was
409
+ `undefined` at runtime with no null check to have skipped.
410
+
411
+ On the live dev project (50 models, one 56-member `Block` union) **four
412
+ positions narrow**, and they are the whole population — the manifest's own
413
+ `truncated` flag is now what decides, so this is not an estimate:
414
+
415
+ | position | was | is |
416
+ | --- | --- | --- |
417
+ | `ProductBlock.pageContent` | `PageContent` | `FoirDegraded<'PageContent'>` |
418
+ | `PageBlock.pageContent` | `PageContent` | `FoirDegraded<'PageContent'>` |
419
+ | `CollectionBlock.pageContent` | `PageContent` | `FoirDegraded<'PageContent'>` |
420
+ | `PageContentBlock.sections` | `Array<Section \| null> \| null` | `Array<FoirDegraded<'Section'> \| null> \| null` |
421
+
422
+ **What a caller does about it.** The values were never arriving, so there is no
423
+ code to preserve — only a read to move:
424
+
425
+ ```ts
426
+ // was: compiled, `sections` undefined at runtime
427
+ const anchor = block.pageContent.sections[0].anchor;
428
+
429
+ // now: `__typename` is all that arrives, and the type says so
430
+ if (block.pageContent?.__typename === 'PageContent') { /* … */ }
431
+ ```
432
+
433
+ To actually get the content, address the record it belongs to in a second call
434
+ (`foir.page.findUnique({ … })`), or reach it through the position where it is
435
+ NOT cut — the same composite read from the model rather than from inside a
436
+ block still arrives whole.
437
+
438
+ **`depth` does not help here, deliberately.** A UNION stopping point is buyable
439
+ a level at a time (see `depth` under "Added"); a COMPOSITE one has no union to
440
+ expand, so `printFields` prints `{ __typename }` for it at every level. Typing
441
+ it behind `<D>` would promise a level that never arrives, which is the defect
442
+ being fixed rather than a fix for it. Measured on the live project: the number
443
+ of expanded union positions rises 1 → 2 → 3 → 4 with `depth`, and the four
444
+ composite cuts are merely replicated by that expansion, never opened.
445
+
446
+ **The wire is untouched.** This is a types-only change: the document printed
447
+ for a call passing no `depth` is byte-identical — verified across 119 default
448
+ documents (73 ops of the live manifest, 39 of the `orderby` fixture, 7 of the
449
+ `jacket` one), and the emitted `foirSelections` runtime const is unchanged
450
+ byte for byte. **Regenerate with `foir types` to get the narrowing**; an
451
+ existing generated file keeps its old, over-promising types.
452
+
453
+ A manifest with no selection trees — a server predating `selectionUnions` —
454
+ keeps widening at these positions, unchanged. Absence of the flag must not read
455
+ as "cut", or an old server would silently degrade every composite in every
456
+ block, which is a worse lie than the one being fixed. `compositeCycleMembers`
457
+ is still what cuts a composite CYCLE there, from the graph alone.
458
+
459
+ - **`@foir/sdk/data` prints a GraphQL document and executes it over `/graphql`**,
460
+ the same endpoint every other consumer of the API uses — one execution surface,
461
+ one executor, one set of scope checks, one response cache. The public API is
462
+ unchanged: `foir.page.findMany({ where, include, omit })` is the same call it
463
+ always was. What changed is what goes on the wire. The client loads that op's
464
+ selection tree (published by `GET /v1/manifest` as `models.<key>.selection`),
465
+ applies `omit`/`pick`/`include`, prints a document, hashes it and sends the
466
+ hash.
467
+
468
+ Still **no `graphql` dependency** — the tree arrives pre-resolved, so printing
469
+ it is string building. The import-graph assertion that pins this is unchanged
470
+ and still passes.
471
+
472
+ What this buys, immediately: a block-bearing model reads complete-by-default.
473
+ `page`, `product` and `collection` need no caller-supplied `omit` to be read at
474
+ all. A document is the only form that can alias two union members which
475
+ disagree on a field's response shape, and the manifest publishes those aliases,
476
+ so those models return 200 on a bare `findMany()`.
477
+
478
+ Two new config fields carry the runtime half of `foir types`:
479
+
480
+ ```ts
481
+ createFoirClient<FoirSchema>({
482
+ apiUrl, apiKey,
483
+ selections: foirSelections,
484
+ selectionUnions: foirSelectionUnions,
485
+ });
486
+ ```
487
+
488
+ Both are optional. Without them the client fetches `GET /v1/manifest` once at
489
+ first use and reads the same trees off it — a FALLBACK, so a zero-codegen start
490
+ still works, at the cost of a round trip in front of the first query on every
491
+ cold edge invocation.
492
+
493
+ Reads take the shared, cacheable lane where they can:
494
+ `GET /cdn/graphql?key=pk_…` with the persisted-query hash in the URL when the
495
+ call is a query, on a `pk_` key, with no customer bearer, on the published
496
+ channel — and `POST /graphql` otherwise. The server's gate refuses on five
497
+ conditions and the client decides all five before the request: the fifth,
498
+ "the credential is in the URL", it satisfies by construction. The server is
499
+ still the authority — it refuses an ineligible read by name, and the refusal
500
+ is retried on `/graphql`.
501
+
502
+ - **BREAKING — `foir.raw()` takes a GraphQL document.** A document string and an
503
+ optional variables object, where it used to take a JSON op
504
+ (`{ model, op, args }`):
505
+
506
+ ```ts
507
+ // before
508
+ await foir.raw({ model: 'page', op: 'findMany', args: { first: 5 } });
509
+ // after
510
+ await foir.raw('query ($n: Int) { pages(first: $n) { totalCount } }', { n: 5 });
511
+ ```
512
+
513
+ It returns the whole `data` object rather than a single root field, because a
514
+ hand-written document may legitimately have several. As a document it reaches
515
+ everything the credential can reach — including the two dozen root fields no
516
+ model and no namespace claims (`designTokens`, `record`, `locales`, the
517
+ customer-auth mutations), which is the point of an escape hatch and is more
518
+ than the old shape could address.
519
+
520
+ - **Every op prints, and there is one wire format rather than two.** The
521
+ descriptor now publishes a `payload` per op — the model it IS, the model it
522
+ CARRIES, its own resolved fields, or an explicit `leaf` — so every shape the
523
+ client can name is a shape it can print. That covers the 18 the client could
524
+ previously only name: every `foir.ns.*` op, plus `myAccess` and the other model
525
+ ops whose result is not the model. They execute over `/graphql` like everything
526
+ else, and nothing is kept beside it that a call could quietly land on.
527
+
528
+ This also removes the last place the client knew something the descriptor did
529
+ not tell it. A connection was wrapped in `edges { cursor node { … } }` from a
530
+ table of op names compiled into the client; it is now printed from the wrapper
531
+ the manifest published, so a server that changes that shape does not need a
532
+ client release to match.
533
+
534
+ **An op the manifest describes no payload for is refused, by name.** It is not
535
+ synthesised and there is no second protocol to fall back to.
536
+
537
+ - **Arguments are sent as GraphQL variables, not printed as literals.** An op's
538
+ published `argTypes` becomes a real variable definition —
539
+ `query PageFindMany($orderBy: [PageOrderByInput])` — and the value travels in
540
+ `variables`, where the executor coerces it against the declared input type.
541
+
542
+ This is the fix for enum arguments. A printer working from argument names
543
+ alone has to infer each value's literal form from the JavaScript value, which
544
+ is exact for strings, numbers, booleans and objects and ambiguous for enums:
545
+ `orderBy: { createdAt: DESC }` needs `DESC` unquoted and the client holds the
546
+ string `'DESC'`. On one real project that was `orderBy` on every `findMany`
547
+ plus `where` on any enum field. Those calls used to be refused by the server,
548
+ by name; they work now.
549
+
550
+ Second-order, and worth having on its own: the document text no longer varies
551
+ with the argument VALUE, so the persisted-query registry holds one entry per
552
+ op instead of one per argument tuple. Argument names are emitted sorted for
553
+ the same reason.
554
+
555
+ **An argument the manifest publishes no type for is refused.** Falling back to
556
+ inferring a literal would reinstate the ambiguity under a condition no caller
557
+ can see.
558
+
559
+ - **`@foir/sdk/auth` prints its documents too.** The flows used the same JSON
560
+ lane the data client did; they now print from `namespaces.auth.ops.<op>` on
561
+ the caller's own descriptor and execute over `/graphql`. Same functions, same
562
+ arguments, same return values. The descriptor is fetched once per credential
563
+ and memoised, so a `currentUser()` on every render does not refetch it.
564
+
565
+ - **Schema drift now means "regenerate", not "be aware".** The trees the client
566
+ prints from are the ones the generated version published, so a field added
567
+ since is not merely untyped — it is not requested. The warning says so.
568
+
569
+ ### Removed
570
+
571
+ - **`FoirRawOp` from `@foir/sdk/data`** — the JSON op shape (`{ model, op, args }`)
572
+ `foir.raw()` used to take. There is no replacement type because a call is a
573
+ GraphQL document now: `foir.raw()` takes a document string and variables. See
574
+ `foir.raw()` under "Changed" above for the migration.
575
+ - **`foirOpResultKind` and `FoirResultKind` from `@foir/sdk/data`** — a table
576
+ mapping op NAMES to how their result wrapped the model, which is exactly the
577
+ schema knowledge the client is not allowed to hold. The manifest publishes
578
+ each op's result shape, so the table has nothing left to answer. Neither
579
+ symbol has appeared in a published release.
580
+ - **`customerResendVerificationEmail` from `@foir/sdk/auth`** — and the
581
+ `AUTH_OPS.customerResendVerificationEmail` entry with it. **It never sent an
582
+ email.** The resolver behind it checked that the request carried a customer id,
583
+ returned `true`, and called no RPC; there is no customer verification-code
584
+ column and no customer verification email in the action catalogue for it to
585
+ have used. A caller got a success response every time and nothing was
586
+ delivered, which is worse than an error, because there is no signal to act on.
587
+
588
+ **Use the OTP flow for mailbox proof:** `customerRequestOTP(env, email)` emails
589
+ a code, and `customerLoginOTP(env, { email, otp })` redeems it — activating a
590
+ pending account on redemption and returning a session, so possession of the
591
+ mailbox is proven by the same call that signs the customer in. That is a live,
592
+ delivered path today; `customerVerifyEmail` remains for redeeming a token that
593
+ arrived by some other route.
594
+
595
+ Like the two entries above, this symbol has not appeared in a published
596
+ release — the whole `@foir/sdk/auth` surface it belongs to is unreleased — so
597
+ no installed version loses a function and there is nothing to migrate off.
598
+
599
+ ### Added
600
+
601
+ - **`<FoirFeedback>` — a feedback widget anchored to the record, not the page.**
602
+ On `./react`, beside `<SignIn>` and `<UserButton>`.
603
+
604
+ ```tsx
605
+ const subjects = createFoirSubjectRegistry({ label: (r) => r.title as string });
606
+ const foir = createFoirClient({ apiUrl, apiKey, generated, subjects });
607
+
608
+ <FoirFeedback client={foir} subjects={subjects} />
609
+ ```
610
+
611
+ That is the whole wiring. There is no route table and no per-page
612
+ configuration: the subject is the last record the client addressed by name, so
613
+ a comment attaches to the record and survives a redesign, a URL change and a
614
+ locale variant. Nothing in the component reads `window.location`.
615
+
616
+ A page whose data all came out of lists has no subject, and the widget says so
617
+ rather than picking one of the twelve or falling back to the address bar. Pass
618
+ `subject` to name one yourself, or `subject={null}` to state there is nothing
619
+ here — which beats whatever the registry still holds from the previous page.
620
+
621
+ The visitor sees a human label or nothing at all: the model key and the record
622
+ id are what the note is filed against and never appear on screen.
623
+
624
+ Opening the panel reads `noteConfig`, which the API charges `notes:write` and
625
+ refuses to a caller with no customer session, so one probe settles whether the
626
+ visitor is signed in, whether their role may write notes, and whether
627
+ screenshot capture may be offered. A missing scope reads like a bad API key and
628
+ is not one, so it is reported as what it is: the signed-in customer's ROLE is
629
+ short `notes:write`, which a new project's default customer role does not
630
+ carry.
631
+
632
+ Screenshot capture is offered only when the project allows it and the browser
633
+ can do it, and is opt-in per submission — the browser's own picker is the
634
+ consent gate. A still is uploaded through the ordinary
635
+ `createFileUpload` → PUT → `confirmFileUpload` flow, so the mime gate, the
636
+ plan's per-file limit and the storage allowance all apply; an upload that fails
637
+ fails the whole submission rather than quietly sending a note without the
638
+ picture.
639
+
640
+ Props: `client`, `subjects`, `subject`, `label`, `triggerLabel`, `title`,
641
+ `placeholder`, `defaultOpen`, `onSignIn`, `onSubmitted`, `onError`,
642
+ `className`. Passing `className` turns the built-in inline styles off entirely;
643
+ the `data-foir-feedback-*` attributes are the styling hooks.
644
+
645
+ New exports on `./react`: `FoirFeedback`, `FoirFeedbackProps`,
646
+ `FoirFeedbackClient`.
647
+
648
+ - **Screen capture on `./media`.** `isFoirScreenCaptureSupported()` says whether
649
+ the browser can do it at all; `captureFoirScreen()` captures one still of a
650
+ surface the reader picks, downscaled to 1600px and encoded as JPEG at 0.7. It
651
+ answers `null` when the picker is dismissed, because that is a decision rather
652
+ than a failure, and it stops every track before returning on every path. The
653
+ blob and the data URL are one encode read two ways, so the size the upload
654
+ declares is the size the bytes are.
655
+
656
+ New exports on `./media`: `captureFoirScreen`, `isFoirScreenCaptureSupported`,
657
+ `FoirScreenCapture`, `FoirScreenCaptureOptions`.
658
+
659
+ - **Note attachments and note config on `./data`.** `FoirCustomerNote` gains
660
+ `attachments`, `FoirCreateMyNoteArgs` gains an optional `attachmentFileIds`,
661
+ and `FoirNoteConfig` is the shape `foir.ns.notes.noteConfig()` answers with.
662
+
663
+ New exports on `./data`: `FoirCustomerNoteAttachment`, `FoirNoteConfig`.
664
+
665
+ - **A subject registry — the records a client fetched, so an annotation can
666
+ anchor to the record instead of to a URL.** `createFoirSubjectRegistry()`
667
+ builds one; passing it as `subjects` on the client config records what each
668
+ call fetched.
669
+
670
+ ```ts
671
+ const subjects = createFoirSubjectRegistry({ label: (r) => r.title as string });
672
+ const foir = createFoirClient({ apiUrl, apiKey, generated, subjects });
673
+
674
+ await foir.article.findUnique({ naturalKey: 'about-us' });
675
+ subjects.subject(); // { modelKey: 'article', recordId: 'kx7p…', label: 'About Us' }
676
+ ```
677
+
678
+ `subject()` answers with the last record the caller addressed **by name**; a
679
+ record that only appeared inside a list is recorded but never promoted, so a
680
+ page whose data all came out of lists answers `undefined` rather than picking
681
+ one. That distinction is read off the op's published payload — a `record`
682
+ payload IS one record — not inferred from result counts or field names. The
683
+ rest of the surface is `fetched()`, `setSubject()`, `clear()`, `subscribe()`
684
+ and `add()`, the last for seeding a record read on the server or through
685
+ `foir.raw()`, neither of which the data path can see.
686
+
687
+ **Off unless you build one, and there is no flag that turns it on.** A list of
688
+ the records a page fetched names content the reader may never have seen
689
+ rendered, so it exists only when the host constructs it and the host holds the
690
+ only reference. Build one per client, never at module scope — a shared registry
691
+ on a multi-tenant edge isolate is shared across tenants.
692
+
693
+ A failing `label` function or `subscribe` listener raises rather than being
694
+ swallowed, in a message that says the request itself succeeded: a subject that
695
+ silently fails to record leaves the PREVIOUS one standing, and a widget then
696
+ files feedback against the wrong page.
697
+
698
+ `FoirDocumentPlan` also now carries an optional `records` — where the records a
699
+ document selects sit in the response, and which model each is — derived from
700
+ the same payload walk that decides where `pick`/`omit` land. Optional so a
701
+ hand-built plan need not invent positions.
702
+
703
+ New exports on `./data`: `createFoirSubjectRegistry`, `recordFoirSubjects`,
704
+ `FoirSubject`, `FoirSubjectEntry`, `FoirSubjectOptions`, `FoirSubjectOrigin`,
705
+ `FoirSubjectRegistry`, `FoirRecordPath`.
706
+
707
+ - **Types for the customer notes surface.** `foir.ns.notes.myNotes()` and
708
+ `foir.ns.notes.createMyNote()` already worked — `notes` is a namespace the
709
+ manifest publishes like any other — but their shapes had to be written out by
710
+ hand at each call site. They are platform-owned and identical in every project,
711
+ so they now ship: `FoirCustomerNote`, `FoirMyNotesArgs`, `FoirMyNotesResult`,
712
+ `FoirCreateMyNoteArgs`, `FoirNoteSubjectType`.
713
+
714
+ ```ts
715
+ const mine = (await foir.ns.notes.myNotes({ first: 20 })) as FoirMyNotesResult;
716
+ ```
717
+
718
+ `FoirNoteSubjectType` is the closed set the platform's write path enforces
719
+ (`record`, `file`, `model`, `operation`, `schedule`, `customer`) and is never a
720
+ model key — a note on a record is `'record'` plus the key on
721
+ `subjectModelKey`. Like every mirrored platform table in `builtins.ts`, a kind
722
+ added upstream widens this union in a minor release.
723
+
724
+ - **`depth` on a model or namespace call — a per-request budget for nested block
725
+ content.** A block union's members can themselves carry block unions, and the
726
+ server refuses to expand a union already on the current path, so the
727
+ complete-by-default document stops one level in. Those stopping points now
728
+ publish the union they stopped in front of, and `depth` spends a level on one:
729
+
730
+ ```ts
731
+ // Default: a stopping point arrives as `{ __typename }`, exactly as before.
732
+ await foir.product.findMany({ first: 5 });
733
+
734
+ // One further union expansion — the nested blocks' own fields arrive.
735
+ await foir.product.findMany({ first: 5, depth: 2 });
736
+ ```
737
+
738
+ It is a **budget**, not a setting: the field count is exponential per level.
739
+ On one real project the same op measures 1,203 fields at the default, 4,308 at
740
+ `depth: 2` and 13,623 at `depth: 3`, against a server ceiling of 10,000 — so
741
+ the third level is reachable there only alongside `pick`/`omit`. A call whose
742
+ document would cross the ceiling is refused **client-side**, with the
743
+ arithmetic in the message and the ceiling named, rather than being sent for the
744
+ server to answer with a complexity number the caller cannot act on.
745
+
746
+ Additive in the strict sense: **the document printed for a call that does not
747
+ pass `depth` is byte-identical to the one printed before this change** —
748
+ verified across all 73 ops of a live manifest, and asserted in
749
+ `document.test.ts`. `FoirDocumentPlan` also now carries an optional `fields`,
750
+ the count the server's complexity ceiling is denominated in — optional so a
751
+ hand-built plan need not invent a number, and always set on one this SDK
752
+ printed.
753
+
754
+ **The result type follows the level.** A stopping point the budget reaches is
755
+ typed as the expanded union rather than `FoirDegraded`, so the value is read
756
+ with a `__typename` narrowing and no cast:
757
+
758
+ ```ts
759
+ const page = await foir.product.findMany({ first: 5, depth: 2 });
760
+ const block = page.edges[0].node.pageContent?.sections?.[0]?.blocks?.[0];
761
+ if (block?.__typename === 'SectionBlock') {
762
+ const nested = block.blocks?.[0]; // the expanded union, not `{ __typename }`
763
+ if (nested?.__typename === 'ProductMainBlock') nested.enableShopPay; // reads
764
+ }
765
+ ```
766
+
767
+ `foir types` gives the composites between the record and a stopping point a
768
+ `<D extends FoirDepthLevel = 1>` parameter and names the model's shape at each
769
+ level under a new optional `FoirModelDefinition.atDepth`; the client picks one
770
+ with a single lookup. **Regenerate to get it** — an existing generated file
771
+ keeps working and keeps typing stopping points as `FoirDegraded` at every
772
+ level, which is what it did before.
773
+
774
+ The default is untouched, in the same strict sense the document is: `D`
775
+ defaults to `1`, so **the result type of a call that does not pass `depth` is
776
+ the type it was before this change**. A `depth` that is not a literal at the
777
+ call site (a variable typed `number`), or a level past the four `atDepth`
778
+ names, falls back to that same default shape — the values still arrive, the
779
+ type just declines to describe them, which is the direction that cannot
780
+ mislead.
781
+
782
+ - **`FoirDepthLevel` and `FoirDepthBelow` on `@foir/sdk/data`** — the two type
783
+ exports the generated file needs to declare its `<D>` parameter (see `depth`
784
+ above). Platform-owned rather than emitted per project because `FoirDepthBelow`
785
+ mirrors the printer's `depth - 1` at an expanded stopping point, and one
786
+ definition beside it is how the two stay agreeing. Named exports, so the
787
+ export-surface snapshot moves 372 → 374.
788
+
789
+ - **`generated` on `createFoirClient` and `createFoirClientFromEnv`, with the
790
+ `FoirGeneratedModule` type** — the generated module passed whole, which is now
791
+ the documented way to build a client:
792
+
793
+ ```ts
794
+ import { createFoirClient } from '@foir/sdk/data';
795
+ import * as generated from './foir.generated';
796
+
797
+ const foir = createFoirClient({ apiUrl, apiKey, generated });
798
+ ```
799
+
800
+ One import, no per-export knowledge at the call site, and `FoirSchema` inferred
801
+ from the module rather than restated as `createFoirClient<FoirSchema>`. An
802
+ artifact added to `foir types` later then costs a call site nothing.
803
+
804
+ **Additive.** `schemaVersion`, `selections`, `namespaceSelections` and
805
+ `selectionUnions` are unchanged, still accepted, and still win where both are
806
+ given — a client written against them compiles and behaves exactly as before.
807
+
808
+ What the module form removes is a failure that had no symptom. Passing four of
809
+ the five exports produced a client that silently fetched `GET /v1/manifest` on
810
+ every cold start and answered identically, so a `foirSelections` emitted in a
811
+ shape the printer could not read went unnoticed. A `generated` module is now
812
+ checked where it is passed: a missing export is a compile error naming it and a
813
+ refusal at construction, and so is a `foirSelections` entry that is a bare
814
+ selection tree rather than a selection model.
815
+
816
+ Needs a file from a current `foir types`: the inference reads `foirSchemaType`,
817
+ a type carrier the generator now emits, because a namespace import carries
818
+ values and `FoirSchema` is an interface. An older generated file, or one from a
819
+ server that publishes no selection trees, does not type-check as a
820
+ `FoirGeneratedModule` — re-run `foir types`, or keep using the individual
821
+ fields, which is what that file's own header now tells you to do.
822
+ - **`announceManifestFallback` on `createFoirClient`** — set `false` to silence
823
+ the one-line `console.info` a client prints when it is built with neither
824
+ `generated` nor `selections` and will therefore read its selection trees from
825
+ `GET /v1/manifest`.
826
+
827
+ The fallback itself is unchanged and stays supported: it is what makes a
828
+ zero-codegen start work. What is new is that it announces itself, because a
829
+ fallback nobody can see is indistinguishable from the wiring mistake above.
830
+ - **`planFoirNamespaceCall` on `@foir/sdk/data`**, with `FoirNamespaceCall`,
831
+ `FoirNamespaceSelections` and `FoirSelectionNamespace`. A namespace op is a
832
+ `FoirManifestOp` carrying a payload exactly as a model op is, so the function
833
+ that prints `foir.page.findMany()` prints `foir.ns.search.searchRecords()` —
834
+ one printer rather than two that agree by inspection.
835
+ - **`namespaceSelections` on `createFoirClient`** — the namespace half of what
836
+ `foir types` emits, beside `selections`. Optional, like `selections`: without
837
+ it the client reads the same descriptor from `GET /v1/manifest` at first use.
838
+ - **`FoirManifestOpPayload` on `@foir/sdk/data`** — an op's published result
839
+ shape, in four kinds (`record`, `object`, `union`, `leaf`). `leaf` is a stated
840
+ kind rather than an absent field list because a leaf takes NO selection set
841
+ and printing `{}` after one is a parse error.
842
+ - **Selection-tree types on `@foir/sdk/data`** — `FoirManifestSelection`,
843
+ `FoirManifestSelectionField`, `FoirManifestSelectionMember` and
844
+ `FoirManifestSelectionUnion`, the resolved complete-by-default selections
845
+ `GET /v1/manifest` now publishes alongside the field buckets.
846
+
847
+ They are what `foir types` emits as a runtime value, so a client can print a
848
+ GraphQL document without a schema and without recomputing a selection in a
849
+ second language. Every field on them is optional on `FoirManifest` and
850
+ `FoirManifestModel`, because a server that predates trees omits them and a
851
+ consumer must not assume the shape.
852
+ - **`foirSelections` / `foirNamespaceSelections` / `foirSelectionUnions` on
853
+ `@foir/sdk/data`** — the runtime contract `foir types` emits against, and the
854
+ printer that reads it: `planFoirModelCall`, `planFoirNamespaceCall`,
855
+ `foirRoute`, and the types `FoirSelections`, `FoirNamespaceSelections`,
856
+ `FoirSelectionUnions`, `FoirSelectionModel`, `FoirSelectionNamespace`,
857
+ `FoirSelectionSource`, `FoirCall`, `FoirNamespaceCall`, `FoirDocumentPlan`,
858
+ `FoirRouteDecision`, `FoirRouteInput`, `FoirLane`, `FoirLaneReason`. Plus the
859
+ manifest's own per-op and selection types — `FoirManifestOpPayload`,
860
+ `FoirManifestSelection`, `FoirManifestSelectionField`,
861
+ `FoirManifestSelectionUnion`, `FoirManifestSelectionMember`.
862
+
863
+ - **The event-log client on `@foir/sdk/server`** (TTS-983) — `createEventsClient`,
864
+ cursor-resumable consumption of a project's durable event history over
865
+ `GET /v1/events`. `list()` returns one page; `stream()` pages forward to the
866
+ current end of the log and yields events, each carrying the `seq` that is the
867
+ durable cursor.
868
+
869
+ Also `FoirEventCursorExpiredError` and `isFoirEventCursorExpired` for the
870
+ retention refusal, the constants `FOIR_EVENTS_PATH`, `FOIR_EVENTS_SCOPE` and
871
+ `FOIR_EVENTS_RETENTION_DAYS`, and the types `FoirEvent`, `FoirEventActor`,
872
+ `FoirEventPage`, `FoirEventRetention`, `FoirEventsClient`,
873
+ `FoirEventsClientConfig`, `FoirEventsListParams`, `FoirEventsStreamParams`.
874
+
875
+ **On `@foir/sdk/server` and no other subpath, deliberately.** The event log is
876
+ an OPERATOR surface (DECIDED 2026-08-04, TTS-981): reachable by an `sk_` key or
877
+ an admin session, never by a customer token, and `events:list.project` is not
878
+ mintable on a `pk_` key at all. `events.view` is plain tenant+project
879
+ containment while `record.view` carries owner/grantee/borrow terms on top, so a
880
+ containment-only read over the log describes rows whose records the same caller
881
+ could not read — and each row's `actor` names which *other* customer acted.
882
+ `createEventsClient` therefore throws on a `pk_` key and on an `Authorization`
883
+ header rather than letting either reach the wire, and an import-graph test
884
+ fails if the module becomes reachable from a subpath that ships to a browser.
885
+
886
+ Requires foir-io/platform#757 to be deployed. Against an api-public without it,
887
+ `GET /v1/events` 404s.
888
+
889
+ - **The package error taxonomy on `@foir/sdk/server`** — `FoirRequestError`,
890
+ `isFoirRateLimited`, `isFoirUnauthenticated`, `isFoirForbidden`,
891
+ `isFoirNotFound`, and the `FoirErrorCode` / `FoirErrorExtensions` /
892
+ `FoirGraphQLErrorItem` types, re-exported from `@foir/sdk/client` so an
893
+ events-client consumer classifies failures without importing a second subpath.
894
+ Same classes, not copies.
895
+
896
+ - **The whole auth surface on `@foir/sdk/auth`.** Password management, email
897
+ verification and account lifecycle were reachable NOWHERE in the package — a
898
+ storefront doing "forgot password" had to drop to hand-written GraphQL, which
899
+ is the entire cost of GraphQL paid for one screen. Ten root fields closed:
900
+
901
+ `getAuthConfig`, `getDefaultAuthProvider`, `currentUser`, `myProviderToken`,
902
+ `customerLogout`, `customerRequestPasswordReset`, `customerResetPassword`,
903
+ `customerUpdatePassword`, `customerVerifyEmail`, `deleteMyAccount`.
904
+
905
+ Plus the session-layer pairings for the flows that need one:
906
+ `resetPasswordAndSignIn` (redeem the emailed token and persist the session it
907
+ mints — the peer of `loginWithPassword` for the branch where the customer
908
+ cannot log in), `signOut` (revoke server-side, then clear locally, and clear
909
+ locally even if the revoke fails), and `authedOp` (`authedRequest`'s twin for
910
+ auth ops: refresh once, retry once, on a 401-shaped failure).
911
+
912
+ And the vocabulary: `AUTH_OPS`, `AuthOpName`, `AuthOpOptions`, with the result
913
+ types `AuthConfig`, `AuthConfigProvider`, `DefaultAuthProvider`, `CurrentUser`,
914
+ `ProviderToken`, `DeleteAccountResult`.
915
+
916
+ Requires an api-public that publishes the full `auth` namespace in
917
+ `GET /v1/manifest`. Against an older one every auth call is refused with
918
+ `operation "…" is not available in namespace "auth" for this credential`, so
919
+ **the platform change deploys first**.
920
+
921
+ ### Removed
922
+
923
+ - **The nine auth operation documents on `@foir/sdk/auth`** —
924
+ `CustomerLoginDocument`, `CustomerRegisterDocument`,
925
+ `CustomerRefreshTokenDocument`, `CustomerRequestOTPDocument`,
926
+ `CustomerLoginOTPDocument`, `CustomerLoginWithProviderDocument`,
927
+ `CustomerProviderCallbackDocument`, `CustomerProviderVerifyOTPDocument`,
928
+ `AuthProvidersDocument`.
929
+
930
+ **Breaking, so the next release is a MINOR** per the pre-1.0 note above.
931
+
932
+ *Why.* They were a second wire protocol for one substrate inside one package:
933
+ hand-written GraphQL posted to `/graphql` while every other typed call in the
934
+ SDK went through the op transport. That split is what produced the gap above —
935
+ the hand-written set covered nine root fields and nobody noticed the other
936
+ eleven were unreachable. The flows now address the server's generated `auth`
937
+ namespace, so `@foir/sdk/auth` carries no transport of its own.
938
+
939
+ *Migration.* Nothing that called a FUNCTION changes: `customerLogin(env, …)`,
940
+ `getAuthProviders(env)`, `loginWithPassword(adapter, env, …)`, `refreshSession`,
941
+ `requireAuth`, `authedRequest` and every React hook in `@foir/sdk/react` keep
942
+ their exact signatures. Only code importing a `*Document` const is affected,
943
+ and only if it was executing the document itself through its own GraphQL
944
+ client. Two options:
945
+
946
+ - call the SDK function instead (`customerLogin(env, vars)` rather than running
947
+ `CustomerLoginDocument` through Apollo/urql), which is what the exports were
948
+ for; or
949
+ - keep your own client and write the document in your own codebase — these are
950
+ fixed-shape platform operations, so the text does not vary by project.
951
+
952
+ `authedRequest` is unchanged and still takes a `TypedDocumentNode`: it exists
953
+ for the project's OWN operations, whose shape the SDK cannot know.
954
+
955
+ - **`graphql` from `@foir/sdk/auth`'s path.** The removed documents were
956
+ `parse()`d at module load, which no bundler could tree-shake, and that is the
957
+ reason `@foir/sdk/auth/session` had to exist as a separate graphql-free door.
958
+ The subpath stays — it is a published surface and consumers import it — but it
959
+ is no longer the only graphql-free way into this package.
960
+
961
+ ---
962
+
109
963
  ## [0.7.0] — 2026-08-08
110
964
 
111
965
  Everything additive, so a MINOR per the pre-1.0 note.
@@ -166,10 +1020,9 @@ actually happened.
166
1020
  register / OTP / provider flows too; use this one for a backend-less SPA whose
167
1021
  only credential is the Fóir-hosted session cookie.
168
1022
 
169
- - **New subpath `@foir/sdk/data`** — the typed data client over
170
- `POST /v1/query` (TTS-874). `createFoirClient<FoirSchema>({ apiUrl, apiKey })`
171
- gives `foir.<model>.<op>(…)`, `foir.ns.<namespace>.<op>(…)` and an untyped
172
- `foir.raw(op)`. The client's surface is shaped by the credential: an op that
1023
+ - **New subpath `@foir/sdk/data`** — the typed data client (TTS-874).
1024
+ `createFoirClient<FoirSchema>({ apiUrl, apiKey })` gives `foir.<model>.<op>(…)`,
1025
+ `foir.ns.<namespace>.<op>(…)` and an untyped `foir.raw(op)`. The client's surface is shaped by the credential: an op that
173
1026
  is not in that key's manifest is not on the type, so `foir.page.create(…)`
174
1027
  under a `pk_` key is a compile error rather than a 400.
175
1028