@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.
- package/CHANGELOG.md +325 -0
- package/README.md +63 -0
- package/dist/auth/auth.d.ts +140 -0
- package/dist/auth/hosted.d.ts +40 -0
- package/dist/auth/index.d.ts +15 -0
- package/dist/auth/operations.d.ts +123 -0
- package/dist/auth/session-client.d.ts +76 -0
- package/dist/auth-session.js +137 -0
- package/dist/auth.js +238 -26
- package/dist/client/platform-client.d.ts +29 -0
- package/dist/client.js +290 -32
- package/dist/codegen/block-registry.d.ts +4 -8
- package/dist/codegen/block-scaffold.d.ts +4 -8
- package/dist/codegen/index.d.ts +8 -62
- package/dist/codegen/preset.d.ts +60 -0
- package/dist/codegen/shared.d.ts +25 -0
- package/dist/components/EditorProvider.d.ts +36 -0
- package/dist/content/content-renderer.d.ts +47 -0
- package/dist/content/index.d.ts +12 -0
- package/dist/content/richtext.d.ts +70 -0
- package/dist/data/builtins.d.ts +296 -0
- package/dist/data/client.d.ts +94 -0
- package/dist/data/index.d.ts +54 -0
- package/dist/data/manifest.d.ts +161 -0
- package/dist/data/schema.d.ts +243 -0
- package/dist/data/selection.d.ts +330 -0
- package/dist/data/transport.d.ts +107 -0
- package/dist/data.js +309 -0
- package/dist/graphql/cdn-route.d.ts +42 -0
- package/dist/graphql/client.d.ts +30 -0
- package/dist/graphql/content-client.d.ts +66 -0
- package/dist/graphql/foir-request.d.ts +47 -0
- package/dist/hooks/useAutoResize.d.ts +22 -0
- package/dist/hooks/useEditor.d.ts +16 -0
- package/dist/index.d.ts +10 -394
- package/dist/media/BlurhashCanvas.d.ts +10 -0
- package/dist/media/ProgressiveMedia.d.ts +56 -0
- package/dist/media/index.d.ts +14 -0
- package/dist/media/media.d.ts +139 -0
- package/dist/media/useProgressiveLoad.d.ts +23 -0
- package/dist/media.js +5 -0
- package/dist/next/cookieAdapter.d.ts +26 -0
- package/dist/next/index.d.ts +15 -0
- package/dist/next/middleware.d.ts +49 -0
- package/dist/next/session.d.ts +53 -0
- package/dist/next.js +230 -26
- package/dist/protocol/constants.d.ts +6 -0
- package/dist/protocol/messages.d.ts +155 -0
- package/dist/protocol/validation.d.ts +8 -0
- package/dist/push/index.d.ts +14 -0
- package/dist/push/operations.d.ts +22 -0
- package/dist/push/web-push.d.ts +39 -0
- package/dist/push.js +230 -26
- package/dist/react/FoirProvider.d.ts +116 -0
- package/dist/react/OAuthButtons.d.ts +20 -0
- package/dist/react/SignIn.d.ts +22 -0
- package/dist/react/UserButton.d.ts +13 -0
- package/dist/react/index.d.ts +19 -0
- package/dist/react/localStorageAdapter.d.ts +10 -0
- package/dist/react.js +230 -26
- package/dist/{realtime.d.ts → realtime/index.d.ts} +35 -16
- package/dist/server/callback-client.d.ts +92 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/operation-types.d.ts +134 -0
- package/dist/server/require-scoped-token.d.ts +76 -0
- package/dist/server/server-client.d.ts +34 -0
- package/dist/server/types.d.ts +54 -0
- package/dist/server/verify-scoped-token.d.ts +100 -0
- package/dist/server/webhook-helpers.d.ts +36 -0
- package/dist/transport/foir-http.d.ts +217 -0
- package/dist/transport/retry.d.ts +61 -0
- package/dist/types/index.d.ts +124 -0
- package/package.json +23 -13
- package/dist/auth.d.ts +0 -382
- package/dist/client.d.ts +0 -83
- package/dist/content.d.ts +0 -96
- package/dist/media.d.ts +0 -234
- package/dist/next.d.ts +0 -178
- package/dist/push.d.ts +0 -109
- package/dist/react.d.ts +0 -319
- package/dist/server.d.ts +0 -534
- 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>;
|