@foir/sdk 0.4.0 → 0.7.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 (82) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +63 -0
  3. package/dist/auth/auth.d.ts +140 -0
  4. package/dist/auth/hosted.d.ts +40 -0
  5. package/dist/auth/index.d.ts +15 -0
  6. package/dist/auth/operations.d.ts +123 -0
  7. package/dist/auth/session-client.d.ts +76 -0
  8. package/dist/auth-session.js +137 -0
  9. package/dist/auth.js +238 -26
  10. package/dist/client/platform-client.d.ts +29 -0
  11. package/dist/client.js +290 -32
  12. package/dist/codegen/block-registry.d.ts +4 -8
  13. package/dist/codegen/block-scaffold.d.ts +4 -8
  14. package/dist/codegen/index.d.ts +8 -62
  15. package/dist/codegen/preset.d.ts +60 -0
  16. package/dist/codegen/shared.d.ts +25 -0
  17. package/dist/components/EditorProvider.d.ts +36 -0
  18. package/dist/content/content-renderer.d.ts +47 -0
  19. package/dist/content/index.d.ts +12 -0
  20. package/dist/content/richtext.d.ts +70 -0
  21. package/dist/data/builtins.d.ts +296 -0
  22. package/dist/data/client.d.ts +94 -0
  23. package/dist/data/index.d.ts +54 -0
  24. package/dist/data/manifest.d.ts +161 -0
  25. package/dist/data/schema.d.ts +243 -0
  26. package/dist/data/selection.d.ts +330 -0
  27. package/dist/data/transport.d.ts +107 -0
  28. package/dist/data.js +309 -0
  29. package/dist/graphql/cdn-route.d.ts +42 -0
  30. package/dist/graphql/client.d.ts +30 -0
  31. package/dist/graphql/content-client.d.ts +66 -0
  32. package/dist/graphql/foir-request.d.ts +47 -0
  33. package/dist/hooks/useAutoResize.d.ts +22 -0
  34. package/dist/hooks/useEditor.d.ts +16 -0
  35. package/dist/index.d.ts +10 -394
  36. package/dist/media/BlurhashCanvas.d.ts +10 -0
  37. package/dist/media/ProgressiveMedia.d.ts +56 -0
  38. package/dist/media/index.d.ts +14 -0
  39. package/dist/media/media.d.ts +139 -0
  40. package/dist/media/useProgressiveLoad.d.ts +23 -0
  41. package/dist/media.js +5 -0
  42. package/dist/next/cookieAdapter.d.ts +26 -0
  43. package/dist/next/index.d.ts +15 -0
  44. package/dist/next/middleware.d.ts +49 -0
  45. package/dist/next/session.d.ts +53 -0
  46. package/dist/next.js +230 -26
  47. package/dist/protocol/constants.d.ts +6 -0
  48. package/dist/protocol/messages.d.ts +155 -0
  49. package/dist/protocol/validation.d.ts +8 -0
  50. package/dist/push/index.d.ts +14 -0
  51. package/dist/push/operations.d.ts +22 -0
  52. package/dist/push/web-push.d.ts +39 -0
  53. package/dist/push.js +230 -26
  54. package/dist/react/FoirProvider.d.ts +116 -0
  55. package/dist/react/OAuthButtons.d.ts +20 -0
  56. package/dist/react/SignIn.d.ts +22 -0
  57. package/dist/react/UserButton.d.ts +13 -0
  58. package/dist/react/index.d.ts +19 -0
  59. package/dist/react/localStorageAdapter.d.ts +10 -0
  60. package/dist/react.js +230 -26
  61. package/dist/{realtime.d.ts → realtime/index.d.ts} +35 -16
  62. package/dist/server/callback-client.d.ts +92 -0
  63. package/dist/server/index.d.ts +8 -0
  64. package/dist/server/operation-types.d.ts +134 -0
  65. package/dist/server/require-scoped-token.d.ts +76 -0
  66. package/dist/server/server-client.d.ts +34 -0
  67. package/dist/server/types.d.ts +54 -0
  68. package/dist/server/verify-scoped-token.d.ts +100 -0
  69. package/dist/server/webhook-helpers.d.ts +36 -0
  70. package/dist/transport/foir-http.d.ts +217 -0
  71. package/dist/transport/retry.d.ts +61 -0
  72. package/dist/types/index.d.ts +124 -0
  73. package/package.json +23 -13
  74. package/dist/auth.d.ts +0 -382
  75. package/dist/client.d.ts +0 -83
  76. package/dist/content.d.ts +0 -96
  77. package/dist/media.d.ts +0 -234
  78. package/dist/next.d.ts +0 -178
  79. package/dist/push.d.ts +0 -109
  80. package/dist/react.d.ts +0 -319
  81. package/dist/server.d.ts +0 -534
  82. package/dist/shared-DkWlEd2Z.d.ts +0 -18
@@ -0,0 +1,76 @@
1
+ /**
2
+ * requireScopedToken — the one-line ingress guard for BFF routes the
3
+ * platform dispatches operations to (EID-498).
4
+ *
5
+ * Operation endpoints must be publicly reachable (the platform's dispatcher
6
+ * refuses private networks), so "the BFF is never public-facing" is achieved
7
+ * by AUTHENTICATED ingress, not network privacy: every route the platform
8
+ * calls verifies the platform-minted `X-Foir-Token` before doing anything. A
9
+ * route that skips verification is an open endpoint anyone can call
10
+ * directly, bypassing the operation's scopes, quotas, and preconditions.
11
+ *
12
+ * ```ts
13
+ * // Hono / Workers / Hydrogen — any fetch-API request:
14
+ * const { claims, subject, context } = await requireScopedToken(request, {
15
+ * expectedProjectId: env.FOIR_PROJECT_ID,
16
+ * });
17
+ * ```
18
+ *
19
+ * Throws {@link ScopedTokenError} with `status` 401 (missing/invalid token)
20
+ * or 403 (valid token for the wrong tenant/project) — map it to a response
21
+ * in one place:
22
+ *
23
+ * ```ts
24
+ * catch (err) {
25
+ * if (err instanceof ScopedTokenError) {
26
+ * return new Response(err.message, { status: err.status });
27
+ * }
28
+ * throw err;
29
+ * }
30
+ * ```
31
+ */
32
+ import type { ScopedTokenClaims, ScopedTokenSubject, VerifyScopedTokenOptions } from './verify-scoped-token';
33
+ /**
34
+ * Verification failure with the HTTP status the route should answer with:
35
+ * 401 for a missing or invalid token, 403 for a valid token bound to the
36
+ * wrong tenant or project.
37
+ */
38
+ export declare class ScopedTokenError extends Error {
39
+ readonly status: 401 | 403;
40
+ constructor(message: string, status: 401 | 403);
41
+ }
42
+ /** The minimal request shape needed — satisfied by any fetch-API Request. */
43
+ export interface HeaderCarrier {
44
+ headers: {
45
+ get(name: string): string | null;
46
+ };
47
+ }
48
+ export interface RequireScopedTokenOptions extends VerifyScopedTokenOptions {
49
+ /**
50
+ * JWKS endpoint to verify against. Defaults to the production platform
51
+ * (`https://api.foir.dev/.well-known/jwks.json`); point it at your dev
52
+ * host's `/.well-known/jwks.json` for non-production deployments.
53
+ */
54
+ jwksUrl?: string;
55
+ /**
56
+ * When set, a verified token whose subject names a different project is
57
+ * rejected with 403. Set this from your deploy's own config — it pins the
58
+ * route to its project even if a token minted for another project leaks.
59
+ */
60
+ expectedProjectId?: string;
61
+ /** Tenant analogue of {@link expectedProjectId}. */
62
+ expectedTenantId?: string;
63
+ }
64
+ export interface ScopedTokenIngress {
65
+ claims: ScopedTokenClaims;
66
+ subject: ScopedTokenSubject;
67
+ /** Parsed `X-Foir-Context` correlation metadata (empty when absent). */
68
+ context: Record<string, string>;
69
+ }
70
+ /**
71
+ * Verify the platform-minted scoped token on an inbound dispatch request.
72
+ * Returns the decoded claims, parsed subject, and dispatch context; throws
73
+ * {@link ScopedTokenError} otherwise. See the module doc for the catch
74
+ * pattern.
75
+ */
76
+ export declare function requireScopedToken(request: HeaderCarrier, options?: RequireScopedTokenOptions): Promise<ScopedTokenIngress>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Server-side client for runtime communication with the Foir platform.
3
+ *
4
+ * Used by config API backends (e.g., Hono servers) to query the platform
5
+ * GraphQL API at runtime. Provisioning (models, operations, schedules) is
6
+ * handled by `foir push`. Record writes from upstream sources go through
7
+ * the scoped-token `upsertRecord` GraphQL mutation served by the
8
+ * integrations gateway, not through this client.
9
+ *
10
+ * Auth: All requests use an API key (x-api-key header).
11
+ */
12
+ import type { ServerClient, ServerClientOptions } from './types';
13
+ /** Error thrown when a platform API request fails. */
14
+ export declare class ServerClientError extends Error {
15
+ status?: number | undefined;
16
+ response?: unknown | undefined;
17
+ constructor(message: string, status?: number | undefined, response?: unknown | undefined);
18
+ }
19
+ /**
20
+ * Create a ServerClient for runtime platform communication.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { createServerClient } from '@foir/sdk/server';
25
+ *
26
+ * const client = createServerClient({
27
+ * baseUrl: 'https://api.example.com',
28
+ * apiKey: 'sk_project_xxx',
29
+ * });
30
+ *
31
+ * const { data } = await client.query('query { models { items { key } } }');
32
+ * ```
33
+ */
34
+ export declare function createServerClient(options: ServerClientOptions): ServerClient;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Server-side types for Foir Editor SDK.
3
+ *
4
+ * Used by Hono API backends that communicate with the platform.
5
+ */
6
+ /** Options for creating a ServerClient. */
7
+ export interface ServerClientOptions {
8
+ /** Base URL for the platform API (e.g., 'https://api.example.com'). */
9
+ baseUrl: string;
10
+ /** Project-scoped API key (sk_* format). */
11
+ apiKey: string;
12
+ /**
13
+ * Optional config key. Retained for backwards compatibility with older
14
+ * extension scaffolds that pass it; the current client does not use it.
15
+ */
16
+ configKey?: string;
17
+ }
18
+ /**
19
+ * Where an editor iframe should appear in the admin record editor.
20
+ */
21
+ export interface EditorPlacement {
22
+ /** 'main-editor' replaces / augments the content tab; 'sidebar' renders in the sidebar. */
23
+ type: 'main-editor' | 'sidebar';
24
+ /** Full URL of the editor iframe. */
25
+ url: string;
26
+ /** Allowed postMessage origin (defaults to url origin). */
27
+ allowedOrigin?: string;
28
+ /** Iframe height: 'auto' (resize via postMessage), 'fill' (100%), or fixed px. */
29
+ height?: 'auto' | 'fill' | number;
30
+ /** Tab label for main-editor placements (defaults to config name). */
31
+ tabName?: string;
32
+ /** When true, hides the standard Content tab (main-editor only). */
33
+ hideContentTab?: boolean;
34
+ /** Which model keys this placement applies to; omit for all models. */
35
+ modelKeys?: string[];
36
+ }
37
+ /** GraphQL response shape. */
38
+ export interface GraphQLResponse<T = Record<string, unknown>> {
39
+ data: T | null;
40
+ errors?: Array<{
41
+ message: string;
42
+ locations?: Array<{
43
+ line: number;
44
+ column: number;
45
+ }>;
46
+ path?: Array<string | number>;
47
+ extensions?: Record<string, unknown>;
48
+ }>;
49
+ }
50
+ /** The ServerClient interface — runtime communication with the platform. */
51
+ export interface ServerClient {
52
+ /** Execute a GraphQL query against the platform public API. */
53
+ query<T = Record<string, unknown>>(query: string, variables?: Record<string, unknown>): Promise<GraphQLResponse<T>>;
54
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Verify a Foir scoped-token JWT (Ed25519, EdDSA) using Web Crypto.
3
+ *
4
+ * Operation handlers receive this token in the `X-Foir-Token` header (the
5
+ * dispatch token authenticating the inbound request) or embedded in the
6
+ * dispatch envelope's `callback.token` field (the callback token used as
7
+ * Bearer for mid-execution GraphQL calls back to api-public). Replaces the
8
+ * legacy HMAC + shared-secret verification for operation dispatches — the
9
+ * handler no longer needs `FOIR_SIGNING_SECRET` for the inbound path, only
10
+ * the URL of the platform's JWKS endpoint
11
+ * (`https://api.foir.dev/.well-known/jwks.json`).
12
+ *
13
+ * Lifted verbatim from the inline implementations in
14
+ * belstaff-translation-portal/src/lib/verify-scoped-token.ts and
15
+ * hydrogen-storefront/app/lib/foir/verify-scoped-token.ts so consumers can
16
+ * drop their copies in favor of the shared export. JWKS keys are cached
17
+ * per-isolate via `globalThis`; TTL matches the endpoint's
18
+ * Cache-Control max-age (5 minutes).
19
+ */
20
+ export interface ScopedTokenClaims {
21
+ iss: string;
22
+ /**
23
+ * Pipe-delimited `<tenantId>|<projectId>|<appName>` triple. Use
24
+ * {@link parseSubject} to split into a typed object.
25
+ */
26
+ sub: string;
27
+ cap: Array<string>;
28
+ jti: string;
29
+ exp: number;
30
+ nbf: number;
31
+ iat: number;
32
+ ctx?: Record<string, string>;
33
+ /**
34
+ * Records-read model allow-list. Present when the operation declared one
35
+ * or more `records:read:<model>` capabilities. Pair with the
36
+ * `records:read` marker in {@link ScopedTokenClaims.cap} to authorize a
37
+ * record read for a specific model.
38
+ */
39
+ rrm?: Array<string>;
40
+ /**
41
+ * Records-write model allow-list. Present when the operation declared
42
+ * one or more `records:write:<model>` capabilities. Pair with the
43
+ * `records:write` marker in {@link ScopedTokenClaims.cap} to authorize
44
+ * a record write for a specific model.
45
+ */
46
+ rwm?: Array<string>;
47
+ }
48
+ export interface ScopedTokenSubject {
49
+ tenantId: string;
50
+ projectId: string;
51
+ appName: string;
52
+ }
53
+ /**
54
+ * Parse the pipe-delimited `sub` claim emitted by the platform's
55
+ * scoped-token minter into its three components. Throws on a missing or
56
+ * empty segment — the platform never emits a partially-populated subject,
57
+ * so a malformed sub indicates a forged or corrupt token.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const { tenantId, projectId, appName } = parseSubject(claims.sub);
62
+ * ```
63
+ */
64
+ export declare function parseSubject(sub: string): ScopedTokenSubject;
65
+ /**
66
+ * Extract the `records:read:<model>` allow-list from a verified claim
67
+ * set. Returns an empty array when the token grants no per-model read
68
+ * capabilities. Convenience over reading {@link ScopedTokenClaims.rrm}
69
+ * directly, mirroring the platform's
70
+ * `scopedtoken.Claims.RecordsReadModels` field.
71
+ */
72
+ export declare function recordsReadModels(claims: ScopedTokenClaims): Array<string>;
73
+ /**
74
+ * Extract the `records:write:<model>` allow-list from a verified claim
75
+ * set. Returns an empty array when the token grants no per-model write
76
+ * capabilities. Mirror of {@link recordsReadModels} for the write side.
77
+ */
78
+ export declare function recordsWriteModels(claims: ScopedTokenClaims): Array<string>;
79
+ export interface VerifyScopedTokenOptions {
80
+ /**
81
+ * Expected `iss` claim. Defaults to `'foir-platform'`, the issuer the
82
+ * platform's scoped-token minter uses. Override only when verifying
83
+ * tokens from a non-default issuer (test harnesses, alternate deploys).
84
+ */
85
+ expectedIssuer?: string;
86
+ }
87
+ /**
88
+ * Verify a scoped-token JWT against the platform's JWKS.
89
+ *
90
+ * Throws on malformed header, unknown kid, bad signature, missing/expired
91
+ * exp, future nbf (with 30s skew tolerance), or mismatched iss. Returns
92
+ * the decoded claim set on success.
93
+ */
94
+ export declare function verifyScopedToken(token: string, jwksUrl: string, options?: VerifyScopedTokenOptions): Promise<ScopedTokenClaims>;
95
+ /**
96
+ * Parse the semicolon-delimited `X-Foir-Context` header the platform
97
+ * attaches to dispatched operation requests. Shape:
98
+ * `project=P;app=A;operation=O;triggered_by=T;execution_id=E;causation_chain=a,b,c`.
99
+ */
100
+ export declare function parseContextHeader(raw: string | null): Record<string, string>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Webhook verification helpers for extension API backends.
3
+ *
4
+ * Verifies HMAC-SHA256 signatures on incoming webhook requests
5
+ * from the EIDE platform.
6
+ *
7
+ * The platform sends two security headers:
8
+ * - `x-foir-signature`: HMAC-SHA256 hex signature of the request body
9
+ * - `x-foir-token`: Short-lived JWT (5min) containing extension context + API key
10
+ *
11
+ * Extensions should verify the signature and extract the API key from the token.
12
+ */
13
+ /**
14
+ * Verify a webhook signature from the platform.
15
+ *
16
+ * The platform signs webhook payloads with HMAC-SHA256 using the
17
+ * extension's webhook secret. The signature is sent in the
18
+ * `x-foir-signature` header as a hex-encoded string.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { verifyWebhookSignature } from '@foir/sdk/server';
23
+ *
24
+ * app.post('/webhooks/entity-published', async (c) => {
25
+ * const body = await c.req.text();
26
+ * const signature = c.req.header('x-foir-signature') ?? '';
27
+ *
28
+ * const valid = await verifyWebhookSignature(body, signature, WEBHOOK_SECRET);
29
+ * if (!valid) return c.text('Invalid signature', 401);
30
+ *
31
+ * const payload = JSON.parse(body);
32
+ * // process webhook...
33
+ * });
34
+ * ```
35
+ */
36
+ export declare function verifyWebhookSignature(payload: string, signature: string, secret: string): Promise<boolean>;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Foir HTTP transport core — the `graphql`-free half of `graphql/foir-request.ts`.
3
+ *
4
+ * Everything here is shared by BOTH public data surfaces:
5
+ *
6
+ * - `@foir/sdk/client` — `foirRequest`, which prints a `TypedDocumentNode` and
7
+ * POSTs it to `/graphql`. Needs `graphql` for `print`.
8
+ * - `@foir/sdk/data` — `createFoirClient`, which POSTs a JSON op to
9
+ * `/v1/query` and must not pull `graphql` into a storefront bundle at all.
10
+ *
11
+ * The split exists so the second one is possible. The Foir-specific transport
12
+ * concerns — which api key is in play, the preview schema channel header, the
13
+ * optional customer bearer, and the `FoirRequestError` shape — are identical on
14
+ * both, and duplicating them is exactly how the two surfaces would drift. So
15
+ * they live here once and `foir-request.ts` re-exports them unchanged; the only
16
+ * thing that stayed behind is the part that needs `print`.
17
+ *
18
+ * `fetch` only, no Node built-ins, and no module-level cache — the reasoning at
19
+ * the top of `graphql/foir-request.ts` applies verbatim: a shared cache on a
20
+ * stateless multi-tenant edge worker is a cross-tenant hazard.
21
+ */
22
+ export interface FoirEnv {
23
+ PLATFORM_API_URL?: string;
24
+ PLATFORM_API_KEY?: string;
25
+ PLATFORM_PREVIEW_API_KEY?: string;
26
+ FOIR_PREVIEW?: string;
27
+ }
28
+ /**
29
+ * Returns true when `FOIR_PREVIEW=true` is set in the environment.
30
+ *
31
+ * Preview is selected at the environment level, not per-request: load a
32
+ * preview env file that sets `FOIR_PREVIEW=true` + a `sk_…`
33
+ * `PLATFORM_PREVIEW_API_KEY` with `drafts:read` instead of the default env.
34
+ * The site-wide mode keeps draft semantics consistent — every loader sees
35
+ * the same preview/published view of the world. Intended for non-production
36
+ * surfaces only.
37
+ */
38
+ export declare function isFoirPreview(env: FoirEnv): boolean;
39
+ /**
40
+ * The api key a request should carry for this env: the `sk_` preview key when
41
+ * `FOIR_PREVIEW=true` (it is the one with `drafts:read`), the `pk_` key
42
+ * otherwise. Returns `undefined` when neither is configured, so the caller can
43
+ * raise the "add them to .env" error with its own wording.
44
+ */
45
+ export declare function resolveFoirApiKey(env: FoirEnv): string | undefined;
46
+ /**
47
+ * The machine-readable codes Foir puts in `extensions.code`. Stable contract —
48
+ * the platform's own note on them says never rename — so branching on these is
49
+ * the supported way to tell one failure from another.
50
+ *
51
+ * Note what is NOT here: there is no code for a missing record. A read that
52
+ * finds nothing resolves the field to `null` with no `errors` entry at all,
53
+ * because an absent record is an answer rather than a failure. `NOT_FOUND` only
54
+ * appears when a mutation names something that does not exist.
55
+ */
56
+ export type FoirErrorCode = 'INVALID_ARGUMENT' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'FAILED_PRECONDITION' | 'PERMISSION_DENIED' | 'UNAUTHENTICATED' | 'RATE_LIMITED' | 'TIMEOUT' | 'UNAVAILABLE' | 'CANCELED' | 'INTERNAL';
57
+ export interface FoirErrorExtensions {
58
+ /** Stable machine-readable code. Branch on this, never on `message`. */
59
+ code?: FoirErrorCode | string;
60
+ /** Correlation id. Quote it in a support request. */
61
+ requestId?: string;
62
+ /** On `RATE_LIMITED`: whole seconds to wait. Never less than 1. */
63
+ retryAfter?: number;
64
+ [key: string]: unknown;
65
+ }
66
+ export interface FoirGraphQLErrorItem {
67
+ message: string;
68
+ path?: Array<string | number>;
69
+ extensions?: FoirErrorExtensions;
70
+ }
71
+ /** What the `X-RateLimit-*` headers said on the response that carried them. */
72
+ export interface FoirRateLimitSnapshot {
73
+ /** Requests allowed per window for this credential. */
74
+ limit: number;
75
+ /** Requests left in the current window. */
76
+ remaining: number;
77
+ /** Unix seconds at which the window resets. */
78
+ resetAt: number;
79
+ }
80
+ export declare class FoirRequestError extends Error {
81
+ /** HTTP status code, when the request failed at the HTTP layer. */
82
+ readonly status?: number;
83
+ /** GraphQL errors, when the request failed with a no-data error envelope. */
84
+ readonly graphQLErrors?: ReadonlyArray<FoirGraphQLErrorItem>;
85
+ readonly cause?: unknown;
86
+ constructor(message: string, options?: {
87
+ cause?: unknown;
88
+ status?: number;
89
+ graphQLErrors?: ReadonlyArray<FoirGraphQLErrorItem>;
90
+ });
91
+ /**
92
+ * The code of the first error, if the response carried one.
93
+ *
94
+ * A getter rather than a stored field so it stays correct for an error
95
+ * constructed anywhere, including by a caller in a test.
96
+ */
97
+ get code(): FoirErrorCode | string | undefined;
98
+ /** The correlation id, if the response carried one. */
99
+ get requestId(): string | undefined;
100
+ /**
101
+ * Whether retrying this request could succeed.
102
+ *
103
+ * The 429 is the one that has to be in here and is easy to leave out: it sits
104
+ * among the 4xx statuses that are otherwise permanent, so a policy written as
105
+ * "retry 5xx, never retry 4xx" excludes exactly the failure that clears on its
106
+ * own. A storefront wrote that policy, its key saturated, and every read
107
+ * failed on the first attempt for an hour.
108
+ */
109
+ get retryable(): boolean;
110
+ /**
111
+ * How long to wait before retrying, in milliseconds, when the server said.
112
+ * Undefined when it did not, which means the caller picks its own backoff.
113
+ */
114
+ get retryAfterMs(): number | undefined;
115
+ }
116
+ /** True when the failure is Foir refusing on rate. */
117
+ export declare function isFoirRateLimited(err: unknown): err is FoirRequestError;
118
+ /**
119
+ * True when the credential was rejected. Distinguished from
120
+ * {@link isFoirRateLimited} because the two want opposite responses: a bad key
121
+ * is never going to start working, and retrying it just spends allowance.
122
+ */
123
+ export declare function isFoirUnauthenticated(err: unknown): err is FoirRequestError;
124
+ /** True when the credential is valid but not permitted to do this. */
125
+ export declare function isFoirForbidden(err: unknown): err is FoirRequestError;
126
+ /**
127
+ * True when a MUTATION named something that does not exist.
128
+ *
129
+ * Reads do not produce this. A read for a record that is not there resolves the
130
+ * field to `null` and returns no error at all, so `data.page === null` is the
131
+ * check for a missing page — not a catch.
132
+ */
133
+ export declare function isFoirNotFound(err: unknown): err is FoirRequestError;
134
+ /**
135
+ * Read the `X-RateLimit-*` headers off a response, when it carried them.
136
+ *
137
+ * The presence check is not defensive padding. `Number(null)` is 0, not NaN, so
138
+ * reading a missing header through `Number` alone yields a perfectly plausible
139
+ * snapshot saying zero requests remain — on every response from anything that
140
+ * does not set these headers, including a cache or a proxy in front of Foir. An
141
+ * app wired to warn on a low remaining count would then warn constantly, which
142
+ * is how a rate-limit alarm becomes noise people mute.
143
+ */
144
+ export declare function readFoirRateLimit(headers: Headers): FoirRateLimitSnapshot | undefined;
145
+ /**
146
+ * Seconds the server asked the caller to wait, from a 429's `Retry-After`
147
+ * header. Undefined when absent or unparseable.
148
+ *
149
+ * Read alongside `extensions.retryAfter`, which carries the same number in the
150
+ * body; the header is the one available when the body is unreadable.
151
+ */
152
+ export declare function readFoirRetryAfterMs(headers: Headers): number | undefined;
153
+ export interface FoirRequestOptions {
154
+ /**
155
+ * Customer bearer token. api-public still needs the `x-api-key` to resolve
156
+ * the project; the bearer adds the customer identity for self-scoped reads
157
+ * (e.g. `myProfile`). Used by `@foir/sdk/auth`'s post-login profile fetch.
158
+ */
159
+ bearerToken?: string;
160
+ /**
161
+ * Called with the `X-RateLimit-*` headers of every response, successful or
162
+ * not, and with `undefined` when a response carried none.
163
+ *
164
+ * Exists because those headers have always been on the wire and nothing has
165
+ * ever read them. A key can be at 200 of 10,000 remaining for twenty minutes
166
+ * before it starts refusing, and the only place that number appears is a
167
+ * header the SDK was throwing away. Log it, and you find out before your site
168
+ * does.
169
+ */
170
+ onRateLimit?: (snapshot: FoirRateLimitSnapshot | undefined) => void;
171
+ }
172
+ /**
173
+ * Supplies the current customer bearer. Sync or async so consumers can read a
174
+ * cookie session (Hydrogen/Remix), a KV store, an in-memory value, etc. Returns
175
+ * the token when signed in, or `null`/`undefined` when signed out.
176
+ *
177
+ * Lives here rather than beside `createFoirContentClient` because both data
178
+ * surfaces bind a session the same way, and a second copy of this alias is how
179
+ * they would end up accepting subtly different things.
180
+ */
181
+ export type FoirTokenProvider = () => string | null | undefined | Promise<string | null | undefined>;
182
+ export interface FoirHeaderInput {
183
+ apiKey: string;
184
+ /**
185
+ * Opt into the draft *schema* channel (`X-Foir-Schema-Channel: draft`) so
186
+ * freshly pushed model changes (new fields, new models) are visible at
187
+ * runtime — otherwise selections on draft-only fields are silently stripped
188
+ * and surface as confusing "fragment never used" errors. The header has no
189
+ * effect under public keys (the server ignores it), so it is safe to send
190
+ * whenever the caller is in preview.
191
+ */
192
+ draftChannel?: boolean;
193
+ bearerToken?: string;
194
+ /** Caller-supplied extras, applied last so a caller can override. */
195
+ extra?: Readonly<Record<string, string>>;
196
+ }
197
+ /**
198
+ * The exact header set every Foir data-plane request carries. Single
199
+ * definition on purpose: `/graphql` and `/v1/query` authenticate identically,
200
+ * and a header that exists on one surface but not the other is a per-surface
201
+ * authorization difference nobody would notice until it mattered.
202
+ */
203
+ export declare function buildFoirHeaders(input: FoirHeaderInput): Record<string, string>;
204
+ export interface FoirResponseEnvelope<T> {
205
+ data?: T;
206
+ errors?: Array<FoirGraphQLErrorItem>;
207
+ }
208
+ /**
209
+ * Unwrap a Foir `{ data, errors }` envelope.
210
+ *
211
+ * GraphQL can return partial data alongside errors; when data is present the
212
+ * errors are logged and the data returned (callers handle nulls), and we only
213
+ * throw when there is no data at all. `/v1/query` compiles to a GraphQL
214
+ * document and hands it to the same executor, so it returns the same envelope
215
+ * and gets the same treatment.
216
+ */
217
+ export declare function readFoirEnvelope<T>(json: FoirResponseEnvelope<T>): T;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Retry for the Foir data plane.
3
+ *
4
+ * It lives in the SDK rather than in a docs snippet because the snippet was
5
+ * getting written wrong. The rule everyone reaches for — retry 5xx, never retry
6
+ * 4xx — is right about every 4xx except the one that matters: a 429 means the
7
+ * key has spent its hourly allowance, the request is fine, and it will succeed
8
+ * once the window moves. A storefront implemented that rule faithfully, covering
9
+ * 502/503/504 only, and when its key saturated every read failed on the first
10
+ * attempt for a full hour. The reads it failed were rendering pages, and the
11
+ * pages came out empty.
12
+ *
13
+ * So the policy is: retry on no-response, on 5xx, and on 429 — and when the
14
+ * server said how long to wait, wait that long instead of guessing.
15
+ */
16
+ import { FoirRequestError } from './foir-http';
17
+ export interface FoirRetryPolicy {
18
+ /**
19
+ * Total attempts including the first. 1 disables retrying without needing a
20
+ * separate flag, which is what makes "off" expressible in the same shape.
21
+ */
22
+ attempts?: number;
23
+ /** Base delay for exponential backoff, in ms. Default 200. */
24
+ baseDelayMs?: number;
25
+ /**
26
+ * Ceiling on any single wait, in ms. Default 20_000.
27
+ *
28
+ * A rate-limit window is an hour, so a `Retry-After` on a saturated key can
29
+ * be most of one — far longer than a page render can wait. Capping it means a
30
+ * retry is attempted rather than the caller blocking for 40 minutes, and if
31
+ * that attempt fails too the error surfaces, which is the honest outcome. A
32
+ * caller that genuinely can wait sets a larger cap.
33
+ */
34
+ maxDelayMs?: number;
35
+ /** Called before each wait. For logging; must not throw. */
36
+ onRetry?: (info: {
37
+ attempt: number;
38
+ delayMs: number;
39
+ error: FoirRequestError;
40
+ }) => void;
41
+ /** Injectable sleep, so tests do not spend real seconds. */
42
+ sleep?: (ms: number) => Promise<void>;
43
+ }
44
+ /**
45
+ * How long to wait before attempt N+1, or null when this error should not be
46
+ * retried at all.
47
+ *
48
+ * Exported so a caller running its own loop gets the same answer as the built-in
49
+ * one; two implementations of "is this worth retrying" is how the 429 gets left
50
+ * out of one of them.
51
+ */
52
+ export declare function foirRetryDelayMs(error: unknown, attempt: number, policy?: FoirRetryPolicy): number | null;
53
+ /**
54
+ * Run `fn`, retrying transient Foir failures.
55
+ *
56
+ * Deliberately takes a thunk rather than wrapping a specific request shape, so
57
+ * the same policy covers `/graphql` and `/v1/query` and anything either grows
58
+ * later. Non-Foir errors propagate untouched on the first throw — this is not a
59
+ * general-purpose retry helper and should not become one.
60
+ */
61
+ export declare function withFoirRetry<T>(fn: () => Promise<T>, policy?: FoirRetryPolicy): Promise<T>;