@foir/sdk 0.9.0 → 0.11.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 (63) hide show
  1. package/CHANGELOG.md +719 -27
  2. package/README.md +121 -2
  3. package/dist/auth/auth.d.ts +72 -1
  4. package/dist/auth/hosted.d.ts +71 -4
  5. package/dist/auth/index.d.ts +3 -3
  6. package/dist/auth/operations.d.ts +84 -0
  7. package/dist/auth/ops.d.ts +18 -0
  8. package/dist/auth.js +209 -47
  9. package/dist/client.js +101 -13
  10. package/dist/codegen/block-registry.d.ts +1 -1
  11. package/dist/codegen/block-scaffold.d.ts +1 -1
  12. package/dist/codegen/index.d.ts +1 -1
  13. package/dist/codegen/item-registry.d.ts +1 -1
  14. package/dist/codegen/item-scaffold.d.ts +1 -1
  15. package/dist/codegen/preset.d.ts +3 -2
  16. package/dist/content/content-renderer.d.ts +4 -5
  17. package/dist/content/index.d.ts +1 -1
  18. package/dist/content/richtext.d.ts +1 -1
  19. package/dist/data/builtins.d.ts +81 -13
  20. package/dist/data/client.d.ts +92 -9
  21. package/dist/data/decimal.d.ts +212 -0
  22. package/dist/data/document.d.ts +14 -1
  23. package/dist/data/index.d.ts +22 -10
  24. package/dist/data/manifest.d.ts +40 -4
  25. package/dist/data/schema.d.ts +18 -11
  26. package/dist/data/selection.d.ts +15 -0
  27. package/dist/data/subscriptions.d.ts +36 -10
  28. package/dist/data/transport.d.ts +363 -24
  29. package/dist/data/ws-transport.d.ts +8 -2
  30. package/dist/data.js +591 -64
  31. package/dist/graphql/cdn-route.d.ts +1 -1
  32. package/dist/graphql/foir-request.d.ts +1 -1
  33. package/dist/index.js +0 -9
  34. package/dist/live/coalesce.d.ts +57 -0
  35. package/dist/live/drift.d.ts +58 -0
  36. package/dist/live/events.d.ts +13 -0
  37. package/dist/live/headers.d.ts +53 -0
  38. package/dist/live/index.d.ts +54 -0
  39. package/dist/live/keys.d.ts +72 -0
  40. package/dist/live/manager.d.ts +77 -0
  41. package/dist/live/react/index.d.ts +143 -0
  42. package/dist/live/reconcile.d.ts +82 -0
  43. package/dist/live/resync.d.ts +59 -0
  44. package/dist/live/store.d.ts +134 -0
  45. package/dist/live/tagIndex.d.ts +29 -0
  46. package/dist/live/tanstack.d.ts +127 -0
  47. package/dist/live/writetoken.d.ts +86 -0
  48. package/dist/live-react.js +395 -0
  49. package/dist/live.js +845 -0
  50. package/dist/next/cacheTags.d.ts +245 -0
  51. package/dist/next/index.d.ts +7 -0
  52. package/dist/next.js +260 -34
  53. package/dist/push.js +163 -36
  54. package/dist/react.js +157 -33
  55. package/dist/realtime/index.d.ts +4 -5
  56. package/dist/server/events-client.d.ts +54 -4
  57. package/dist/server/index.d.ts +4 -2
  58. package/dist/server/operation-types.d.ts +86 -58
  59. package/dist/server/read-dispatch.d.ts +53 -0
  60. package/dist/server/require-scoped-token.d.ts +20 -1
  61. package/dist/server.js +602 -10
  62. package/dist/transport/foir-http.d.ts +168 -15
  63. package/package.json +10 -2
@@ -30,16 +30,17 @@ export interface FoirEnv {
30
30
  * Returns true when `FOIR_PREVIEW=true` is set in the environment.
31
31
  *
32
32
  * Preview is selected at the environment level, not per-request: load a
33
- * preview env file that sets `FOIR_PREVIEW=true` + a `sk_…`
34
- * `PLATFORM_PREVIEW_API_KEY` with `drafts:read` instead of the default env.
33
+ * preview env file that sets `FOIR_PREVIEW=true` + the preview environment's
34
+ * own `pk_…` key holding `drafts:read` as `PLATFORM_PREVIEW_API_KEY`, instead
35
+ * of the default env.
35
36
  * The site-wide mode keeps draft semantics consistent — every loader sees
36
37
  * the same preview/published view of the world. Intended for non-production
37
38
  * surfaces only.
38
39
  */
39
40
  export declare function isFoirPreview(env: FoirEnv): boolean;
40
41
  /**
41
- * The api key a request should carry for this env: the `sk_` preview key when
42
- * `FOIR_PREVIEW=true` (it is the one with `drafts:read`), the `pk_` key
42
+ * The api key a request should carry for this env: the preview key when
43
+ * `FOIR_PREVIEW=true` (it is the one with `drafts:read`), the production key
43
44
  * otherwise. Returns `undefined` when neither is configured, so the caller can
44
45
  * raise the "add them to .env" error with its own wording.
45
46
  */
@@ -69,6 +70,42 @@ export interface FoirGraphQLErrorItem {
69
70
  path?: Array<string | number>;
70
71
  extensions?: FoirErrorExtensions;
71
72
  }
73
+ /**
74
+ * One selection the server stripped from the document before executing it.
75
+ *
76
+ * The data plane PRUNES rather than refuses: a selection that does not match
77
+ * the project's current schema is removed and the rest of the query runs, so an
78
+ * editor-driven model change cannot fail a deployed client's query mid-flight.
79
+ * The report arrives in `extensions.pruned` and deliberately NOT in `errors`,
80
+ * because clients treat any `errors` entry as a failed response.
81
+ *
82
+ * Which is why this type exists here. Pruning is the ONLY signal for the
83
+ * commonest way to get a quietly incomplete object — asking for a field the
84
+ * type does not have — and it produces no error for `strict` to catch.
85
+ */
86
+ export interface FoirPrunedSelection {
87
+ /** Dotted path to the stripped selection, e.g. `pageBySlug.logo.id`. */
88
+ path: string;
89
+ /** Why it was stripped. See `MALFORMED_PRUNE_REASONS`. */
90
+ reason: string;
91
+ /** The parent type the selection was made on, when the server named one. */
92
+ type?: string;
93
+ }
94
+ /**
95
+ * The prune reasons that mean THE DOCUMENT IS WRONG, as opposed to the schema
96
+ * having moved underneath a document that was right when it shipped.
97
+ *
98
+ * A field the type does not have, a subselection on a scalar, or a composite
99
+ * asked for without one: each is a query that could never have been served as
100
+ * written. They are the shape that silently returns a plausible object with a
101
+ * key missing, which is how an empty file id became a `setNull` and would have
102
+ * deleted a logo on the next save.
103
+ *
104
+ * The rest — dead type conditions, dead fragment spreads, unreferenced
105
+ * fragments, unused variables — are what the pruner exists to absorb, and they
106
+ * stay a warning.
107
+ */
108
+ export declare const MALFORMED_PRUNE_REASONS: ReadonlySet<string>;
72
109
  /** What the `X-RateLimit-*` headers said on the response that carried them. */
73
110
  export interface FoirRateLimitSnapshot {
74
111
  /** Requests allowed per window for this credential. */
@@ -83,11 +120,27 @@ export declare class FoirRequestError extends Error {
83
120
  readonly status?: number;
84
121
  /** GraphQL errors, when the request failed with a no-data error envelope. */
85
122
  readonly graphQLErrors?: ReadonlyArray<FoirGraphQLErrorItem>;
123
+ /**
124
+ * The `data` the server did return, when a STRICT read refused a partial
125
+ * response. Present only on that path: without it, opting into strictness
126
+ * would trade a silent wrong answer for a thrown one that discards the answer,
127
+ * and a caller that wants to log what it nearly rendered could not.
128
+ */
129
+ readonly partialData?: unknown;
130
+ /**
131
+ * The selections the server stripped, when the refusal was a pruned document
132
+ * rather than a partial error. Same purpose as `partialData`: a caller that
133
+ * wants to log what it nearly rendered, or name the offending path in a
134
+ * developer-facing message, has it without re-parsing anything.
135
+ */
136
+ readonly prunedSelections?: ReadonlyArray<FoirPrunedSelection>;
86
137
  readonly cause?: unknown;
87
138
  constructor(message: string, options?: {
88
139
  cause?: unknown;
89
140
  status?: number;
90
141
  graphQLErrors?: ReadonlyArray<FoirGraphQLErrorItem>;
142
+ partialData?: unknown;
143
+ prunedSelections?: ReadonlyArray<FoirPrunedSelection>;
91
144
  });
92
145
  /**
93
146
  * The code of the first error, if the response carried one.
@@ -158,6 +211,32 @@ export interface FoirRequestOptions {
158
211
  * (e.g. `myProfile`). Used by `@foir/sdk/auth`'s post-login profile fetch.
159
212
  */
160
213
  bearerToken?: string;
214
+ /**
215
+ * The organization this request acts in, sent as `X-Org-Id`. For a
216
+ * project-scoped key: the server resolves it to an active organization of
217
+ * the key's own project (else `NOT_FOUND`) and binds the request to it — a
218
+ * key already confined to an organization may only name that one (else
219
+ * `PERMISSION_DENIED`). See `FoirClientConfig.orgId` for the full contract.
220
+ *
221
+ * PER CALL, which is the narrower and riskier of the two spellings. The
222
+ * client-level option is the one to reach for: set it once where the
223
+ * organization is resolved and every request from that client carries it,
224
+ * so there is no call left to forget. This one is for the odd request that
225
+ * has to act somewhere else — and a request that omits it is not refused,
226
+ * it reads the project-shared tier, which is a smaller answer rather than an
227
+ * error.
228
+ *
229
+ * ⚠️ It names a SUBTREE. Organizations nest and reach runs downward, so a
230
+ * reseller's id covers every client beneath it. Send the narrowest id that
231
+ * covers what the request is about.
232
+ */
233
+ orgId?: string;
234
+ /**
235
+ * The organization this request acts in, named by the address of the page it
236
+ * is for, sent as `X-Org-Url`. See `FoirClientConfig.orgUrl` for the full
237
+ * contract. With `orgId` as well, the two must name the same organization.
238
+ */
239
+ orgUrl?: string;
161
240
  /**
162
241
  * Called with the `X-RateLimit-*` headers of every response, successful or
163
242
  * not, and with `undefined` when a response carried none.
@@ -180,7 +259,8 @@ export interface FoirRequestOptions {
180
259
  * they would end up accepting subtly different things.
181
260
  */
182
261
  export type FoirTokenProvider = () => string | null | undefined | Promise<string | null | undefined>;
183
- export interface FoirHeaderInput {
262
+ /** A request made with an api key, optionally for a signed-in customer. */
263
+ export interface FoirKeyHeaderInput {
184
264
  apiKey: string;
185
265
  /**
186
266
  * Opt into the draft *schema* channel (`X-Foir-Schema-Channel: draft`) so
@@ -192,14 +272,62 @@ export interface FoirHeaderInput {
192
272
  */
193
273
  draftChannel?: boolean;
194
274
  bearerToken?: string;
275
+ /**
276
+ * The organization the request acts in (`X-Org-Id`). Set from
277
+ * `FoirClientConfig.orgId` / `FoirRequestOptions.orgId`; a caller who
278
+ * spells the header in `extra` still wins, because `extra` is applied last.
279
+ */
280
+ orgId?: string;
281
+ /**
282
+ * The organization the request acts in, named by a page URL (`X-Org-Url`).
283
+ * Set from `FoirClientConfig.orgUrl` / `FoirRequestOptions.orgUrl`.
284
+ */
285
+ orgUrl?: string;
286
+ dispatchToken?: never;
287
+ dispatchContext?: never;
195
288
  /** Caller-supplied extras, applied last so a caller can override. */
196
289
  extra?: Readonly<Record<string, string>>;
197
290
  }
291
+ /**
292
+ * A request made AS an operation, with the dispatch token its endpoint was
293
+ * handed.
294
+ *
295
+ * The token is the whole credential. api-public takes the scoped path when
296
+ * `X-Foir-Token` is present and never reads `x-api-key` on it, and the
297
+ * organization, the customer and the schema channel all come from the token's
298
+ * own claims, so none of the key-side inputs has anything to say here.
299
+ */
300
+ export interface FoirDispatchHeaderInput {
301
+ /** Sent as `X-Foir-Token`. */
302
+ dispatchToken: string;
303
+ /** The dispatch's `X-Foir-Context`, forwarded as it arrived. */
304
+ dispatchContext?: string;
305
+ apiKey?: never;
306
+ draftChannel?: never;
307
+ bearerToken?: never;
308
+ orgId?: never;
309
+ orgUrl?: never;
310
+ /**
311
+ * Caller-supplied extras, applied last. They may not name a credential
312
+ * header: see {@link refuseCredentialHeaders}.
313
+ */
314
+ extra?: Readonly<Record<string, string>>;
315
+ }
316
+ export type FoirHeaderInput = FoirKeyHeaderInput | FoirDispatchHeaderInput;
317
+ /**
318
+ * Refuse extra headers that would add a second credential to a request made as
319
+ * an operation. Throws naming the header; returns nothing when there is none.
320
+ */
321
+ export declare function refuseCredentialHeaders(extra: Readonly<Record<string, string>> | undefined): void;
198
322
  /**
199
323
  * The exact header set every Foir data-plane request carries. Single
200
324
  * definition on purpose: `/graphql` and `/cdn/graphql` authenticate identically,
201
325
  * and a header that exists on one surface but not the other is a per-surface
202
326
  * authorization difference nobody would notice until it mattered.
327
+ *
328
+ * Two credentials, and a request carries exactly one: an api key (with its
329
+ * optional customer bearer, organization and channel), or an operation's
330
+ * dispatch token. A dispatch request never writes `x-api-key`.
203
331
  */
204
332
  export declare function buildFoirHeaders(input: FoirHeaderInput): Record<string, string>;
205
333
  /**
@@ -218,17 +346,42 @@ export declare function buildFoirHeaders(input: FoirHeaderInput): Record<string,
218
346
  * accepted. `data/transport.ts` re-exports it under its published name.
219
347
  */
220
348
  export declare function foirApiEndpoint(apiUrl: string, path: `/${string}`): string;
349
+ /**
350
+ * Foir DISCARDED a credential you sent, and served the request without it.
351
+ *
352
+ * A customer JWT is optional: if it fails to verify, the read is served as an
353
+ * ordinary anonymous one rather than refused, because plenty of reads
354
+ * legitimately have nobody signed in. That is the right behaviour and it has a
355
+ * sharp edge — a lapsed token and a genuinely anonymous request produce the
356
+ * same 200, so the failure surfaces as WRONG CONTENT rather than as an error.
357
+ * This block is how you find out.
358
+ *
359
+ * Present only when a token was actually sent and rejected. Its presence is
360
+ * always a signal.
361
+ */
362
+ export interface FoirAuthDowngrade {
363
+ /** Always `"rejected"`. */
364
+ customerToken: string;
365
+ /**
366
+ * `expired` — re-exchange and retry. `invalid` — not a customer token for
367
+ * this environment. `wrong_project` — the token and the api-key name
368
+ * different projects.
369
+ */
370
+ reason: 'expired' | 'invalid' | 'wrong_project';
371
+ /** The authority the request actually ran under. */
372
+ servedAs: string;
373
+ }
221
374
  export interface FoirResponseEnvelope<T> {
222
375
  data?: T;
223
376
  errors?: Array<FoirGraphQLErrorItem>;
377
+ extensions?: {
378
+ auth?: FoirAuthDowngrade;
379
+ pruned?: {
380
+ selections?: Array<FoirPrunedSelection>;
381
+ };
382
+ [key: string]: unknown;
383
+ };
224
384
  }
225
- /**
226
- * Unwrap a Foir `{ data, errors }` envelope.
227
- *
228
- * GraphQL can return partial data alongside errors; when data is present the
229
- * errors are logged and the data returned (callers handle nulls), and we only
230
- * throw when there is no data at all. Both Foir data-plane routes — `/graphql`
231
- * and `/cdn/graphql` — hand their document to the same executor, so the two
232
- * return this envelope and get the same treatment.
233
- */
234
- export declare function readFoirEnvelope<T>(json: FoirResponseEnvelope<T>): T;
385
+ export declare function readFoirEnvelope<T>(json: FoirResponseEnvelope<T>, options?: {
386
+ strict?: boolean;
387
+ }): T;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foir/sdk",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Official Foir SDK — editor iframe client + server-side platform helpers",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -25,6 +25,14 @@
25
25
  "types": "./dist/data/index.d.ts",
26
26
  "import": "./dist/data.js"
27
27
  },
28
+ "./live": {
29
+ "types": "./dist/live/index.d.ts",
30
+ "import": "./dist/live.js"
31
+ },
32
+ "./live/react": {
33
+ "types": "./dist/live/react/index.d.ts",
34
+ "import": "./dist/live-react.js"
35
+ },
28
36
  "./media": {
29
37
  "types": "./dist/media/index.d.ts",
30
38
  "import": "./dist/media.js"
@@ -157,7 +165,7 @@
157
165
  "node": ">=18.0.0"
158
166
  },
159
167
  "scripts": {
160
- "build": "tsup && tsc -p tsconfig.build.json && node scripts/assert-export-types.mjs && node scripts/assert-public-exports.mjs",
168
+ "build": "node scripts/clean-dist.mjs && tsup && tsc -p tsconfig.build.json && node scripts/assert-export-types.mjs && node scripts/assert-public-exports.mjs",
161
169
  "snapshot:exports": "node scripts/assert-public-exports.mjs --update",
162
170
  "dev": "node scripts/dev-watch.mjs",
163
171
  "check-types": "tsc --noEmit",