@foir/sdk 0.5.0 → 0.8.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 +1126 -0
- package/README.md +94 -0
- package/dist/auth/auth.d.ts +243 -0
- package/dist/auth/hosted.d.ts +40 -0
- package/dist/auth/index.d.ts +21 -0
- package/dist/auth/operations.d.ts +144 -0
- package/dist/auth/ops.d.ts +99 -0
- package/dist/auth/session-client.d.ts +76 -0
- package/dist/auth-session.js +137 -0
- package/dist/auth.js +973 -92
- package/dist/chunk-IGNLWT3H.js +35 -0
- package/dist/chunk-OHYVKTGB.js +45 -0
- package/dist/chunk-TAL5H6VA.js +39 -0
- package/dist/client/platform-client.d.ts +29 -0
- package/dist/client.js +275 -37
- package/dist/codegen/block-registry.d.ts +13 -23
- package/dist/codegen/block-registry.js +3 -31
- package/dist/codegen/block-scaffold.d.ts +12 -25
- package/dist/codegen/block-scaffold.js +3 -41
- package/dist/codegen/index.d.ts +12 -61
- package/dist/codegen/index.js +7 -11
- package/dist/codegen/item-registry.d.ts +28 -0
- package/dist/codegen/item-registry.js +7 -0
- package/dist/codegen/item-scaffold.d.ts +29 -0
- package/dist/codegen/item-scaffold.js +7 -0
- package/dist/codegen/preset.d.ts +67 -0
- package/dist/codegen/shared.d.ts +36 -0
- package/dist/components/EditorProvider.d.ts +36 -0
- package/dist/content/content-renderer.d.ts +113 -0
- package/dist/content/index.d.ts +18 -0
- package/dist/content/richtext.d.ts +118 -0
- package/dist/content.js +227 -80
- package/dist/data/builtins.d.ts +506 -0
- package/dist/data/client.d.ts +166 -0
- package/dist/data/document.d.ts +251 -0
- package/dist/data/index.d.ts +73 -0
- package/dist/data/manifest.d.ts +424 -0
- package/dist/data/schema.d.ts +313 -0
- package/dist/data/selection.d.ts +524 -0
- package/dist/data/subjects.d.ts +221 -0
- package/dist/data/subscription-document.d.ts +75 -0
- package/dist/data/subscriptions.d.ts +181 -0
- package/dist/data/transport.d.ts +325 -0
- package/dist/data/ws-transport.d.ts +197 -0
- package/dist/data.js +1683 -0
- package/dist/graphql/cdn-route.d.ts +26 -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 +18 -0
- package/dist/media/media.d.ts +139 -0
- package/dist/media/screen-capture.d.ts +81 -0
- package/dist/media/useProgressiveLoad.d.ts +23 -0
- package/dist/media.js +102 -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 +763 -78
- 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 +892 -78
- package/dist/react/FoirFeedback.d.ts +62 -0
- 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/feedback-notes.d.ts +101 -0
- package/dist/react/index.d.ts +25 -0
- package/dist/react/localStorageAdapter.d.ts +10 -0
- package/dist/react.js +1373 -91
- package/dist/{realtime.d.ts → realtime/index.d.ts} +35 -16
- package/dist/realtime.js +1 -1
- package/dist/server/callback-client.d.ts +92 -0
- package/dist/server/events-client.d.ts +279 -0
- package/dist/server/index.d.ts +12 -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/server.js +298 -0
- package/dist/transport/foir-http.d.ts +234 -0
- package/dist/transport/persisted-query.d.ts +68 -0
- package/dist/transport/retry.d.ts +61 -0
- package/dist/types/index.d.ts +124 -0
- package/package.json +35 -13
- package/dist/auth.d.ts +0 -382
- package/dist/chunk-ADO5FJNM.js +0 -30
- package/dist/client.d.ts +0 -135
- 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>;
|
package/dist/server.js
CHANGED
|
@@ -344,11 +344,309 @@ async function hmacSha256Hex(secret, body) {
|
|
|
344
344
|
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
|
|
345
345
|
return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
346
346
|
}
|
|
347
|
+
|
|
348
|
+
// src/transport/foir-http.ts
|
|
349
|
+
var FoirRequestError = class extends Error {
|
|
350
|
+
/** HTTP status code, when the request failed at the HTTP layer. */
|
|
351
|
+
status;
|
|
352
|
+
/** GraphQL errors, when the request failed with a no-data error envelope. */
|
|
353
|
+
graphQLErrors;
|
|
354
|
+
cause;
|
|
355
|
+
constructor(message, options) {
|
|
356
|
+
super(message);
|
|
357
|
+
this.name = "FoirRequestError";
|
|
358
|
+
this.cause = options?.cause;
|
|
359
|
+
this.status = options?.status;
|
|
360
|
+
this.graphQLErrors = options?.graphQLErrors;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The code of the first error, if the response carried one.
|
|
364
|
+
*
|
|
365
|
+
* A getter rather than a stored field so it stays correct for an error
|
|
366
|
+
* constructed anywhere, including by a caller in a test.
|
|
367
|
+
*/
|
|
368
|
+
get code() {
|
|
369
|
+
return this.graphQLErrors?.[0]?.extensions?.code;
|
|
370
|
+
}
|
|
371
|
+
/** The correlation id, if the response carried one. */
|
|
372
|
+
get requestId() {
|
|
373
|
+
return this.graphQLErrors?.[0]?.extensions?.requestId;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Whether retrying this request could succeed.
|
|
377
|
+
*
|
|
378
|
+
* The 429 is the one that has to be in here and is easy to leave out: it sits
|
|
379
|
+
* among the 4xx statuses that are otherwise permanent, so a policy written as
|
|
380
|
+
* "retry 5xx, never retry 4xx" excludes exactly the failure that clears on its
|
|
381
|
+
* own. A storefront wrote that policy, its key saturated, and every read
|
|
382
|
+
* failed on the first attempt for an hour.
|
|
383
|
+
*/
|
|
384
|
+
get retryable() {
|
|
385
|
+
if (this.status === void 0) return true;
|
|
386
|
+
if (this.status === 429 || this.code === "RATE_LIMITED") return true;
|
|
387
|
+
if (this.status >= 500) return true;
|
|
388
|
+
return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* How long to wait before retrying, in milliseconds, when the server said.
|
|
392
|
+
* Undefined when it did not, which means the caller picks its own backoff.
|
|
393
|
+
*/
|
|
394
|
+
get retryAfterMs() {
|
|
395
|
+
const seconds = this.graphQLErrors?.[0]?.extensions?.retryAfter;
|
|
396
|
+
return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
function isFoirRateLimited(err) {
|
|
400
|
+
return err instanceof FoirRequestError && (err.status === 429 || err.code === "RATE_LIMITED");
|
|
401
|
+
}
|
|
402
|
+
function isFoirUnauthenticated(err) {
|
|
403
|
+
return err instanceof FoirRequestError && (err.status === 401 || err.code === "UNAUTHENTICATED");
|
|
404
|
+
}
|
|
405
|
+
function isFoirForbidden(err) {
|
|
406
|
+
return err instanceof FoirRequestError && (err.status === 403 || err.code === "PERMISSION_DENIED");
|
|
407
|
+
}
|
|
408
|
+
function isFoirNotFound(err) {
|
|
409
|
+
return err instanceof FoirRequestError && err.code === "NOT_FOUND";
|
|
410
|
+
}
|
|
411
|
+
function readFoirRetryAfterMs(headers) {
|
|
412
|
+
const seconds = Number(headers.get("Retry-After"));
|
|
413
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
414
|
+
}
|
|
415
|
+
function buildFoirHeaders(input) {
|
|
416
|
+
const headers = {
|
|
417
|
+
"Content-Type": "application/json",
|
|
418
|
+
"x-api-key": input.apiKey
|
|
419
|
+
};
|
|
420
|
+
if (input.draftChannel) {
|
|
421
|
+
headers["X-Foir-Schema-Channel"] = "draft";
|
|
422
|
+
}
|
|
423
|
+
if (input.bearerToken) {
|
|
424
|
+
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
425
|
+
}
|
|
426
|
+
if (input.extra) {
|
|
427
|
+
for (const [key, value] of Object.entries(input.extra)) {
|
|
428
|
+
headers[key] = value;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return headers;
|
|
432
|
+
}
|
|
433
|
+
function foirApiEndpoint(apiUrl, path) {
|
|
434
|
+
const base = apiUrl.trim().replace(/\/+$/, "").replace(/\/graphql$/i, "");
|
|
435
|
+
if (!base) {
|
|
436
|
+
throw new FoirRequestError("[foir] apiUrl is empty \u2014 pass the Foir API origin");
|
|
437
|
+
}
|
|
438
|
+
return base + path;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/server/events-client.ts
|
|
442
|
+
var FOIR_EVENTS_PATH = "/v1/events";
|
|
443
|
+
var FOIR_EVENTS_SCOPE = "events:list.project";
|
|
444
|
+
var FOIR_EVENTS_RETENTION_DAYS = 90;
|
|
445
|
+
var FoirEventCursorExpiredError = class extends FoirRequestError {
|
|
446
|
+
/**
|
|
447
|
+
* The cursor to resume from, accepting the gap — parsed out of the server's
|
|
448
|
+
* message, which names it.
|
|
449
|
+
*
|
|
450
|
+
* `undefined` only if the message ever stops naming one, in which case
|
|
451
|
+
* restarting from 0 and de-duplicating on `id` is the safe fallback. It is
|
|
452
|
+
* optional for that reason and not because the server omits it today; both
|
|
453
|
+
* refusal messages carry it, and both are pinned as fixtures in the tests.
|
|
454
|
+
*/
|
|
455
|
+
resumeFrom;
|
|
456
|
+
/** The cursor that was rejected — whatever was passed as `after`. */
|
|
457
|
+
requestedCursor;
|
|
458
|
+
constructor(message, options) {
|
|
459
|
+
super(message, { status: options.status });
|
|
460
|
+
this.name = "FoirEventCursorExpiredError";
|
|
461
|
+
this.resumeFrom = options.resumeFrom;
|
|
462
|
+
this.requestedCursor = options.requestedCursor;
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
function isFoirEventCursorExpired(err) {
|
|
466
|
+
return err instanceof FoirRequestError && err.status === 410;
|
|
467
|
+
}
|
|
468
|
+
var RESUME_FROM = /resume from\s+(\d+)/i;
|
|
469
|
+
function parseResumeFrom(message) {
|
|
470
|
+
const m = RESUME_FROM.exec(message);
|
|
471
|
+
if (!m) return void 0;
|
|
472
|
+
const n = Number(m[1]);
|
|
473
|
+
return Number.isSafeInteger(n) ? n : void 0;
|
|
474
|
+
}
|
|
475
|
+
function assertOperatorKey(apiKey) {
|
|
476
|
+
const key = apiKey?.trim() ?? "";
|
|
477
|
+
if (!key) {
|
|
478
|
+
throw new FoirRequestError(
|
|
479
|
+
`[foir] createEventsClient: apiKey is empty \u2014 pass an sk_ secret key holding ${FOIR_EVENTS_SCOPE}.`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
if (key.startsWith("pk_")) {
|
|
483
|
+
throw new FoirRequestError(
|
|
484
|
+
`[foir] createEventsClient: the event log is an OPERATOR surface and a public (pk_) key can never hold ${FOIR_EVENTS_SCOPE} \u2014 it is not mintable on a public key. Use an sk_ secret key from a server-side runtime; a pk_ key is embeddable in browser bundles, and the log names which customer acted on records the bundle's reader cannot themselves read.`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
if (!key.startsWith("sk_")) {
|
|
488
|
+
throw new FoirRequestError(
|
|
489
|
+
`[foir] createEventsClient: apiKey must be an sk_ secret key (sk_ or sk_t_), got a key starting "${key.slice(0, 4)}". The event log is reachable by an sk_ api key or an admin session only.`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return key;
|
|
493
|
+
}
|
|
494
|
+
function assertNoBearer(headers) {
|
|
495
|
+
if (!headers) return;
|
|
496
|
+
for (const name of Object.keys(headers)) {
|
|
497
|
+
if (name.toLowerCase() === "authorization") {
|
|
498
|
+
throw new FoirRequestError(
|
|
499
|
+
`[foir] createEventsClient: an Authorization header is not accepted. A customer bearer makes the customer the resolved authority on api-public, and no customer authority can contain ${FOIR_EVENTS_SCOPE}. The sk_ key alone is the credential for this surface.`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function readSeq(value, field) {
|
|
505
|
+
const n = typeof value === "number" ? value : Number(value ?? 0);
|
|
506
|
+
if (!Number.isSafeInteger(n)) {
|
|
507
|
+
throw new FoirRequestError(
|
|
508
|
+
`[foir] events: ${field} is not a safe integer (${String(value)}). A sequence number past 2^53 cannot be represented exactly in JSON, and an inexact cursor skips or repeats events.`
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
return n;
|
|
512
|
+
}
|
|
513
|
+
function toEvent(raw) {
|
|
514
|
+
return {
|
|
515
|
+
id: raw.id ?? "",
|
|
516
|
+
seq: readSeq(raw.seq, "event.seq"),
|
|
517
|
+
event: raw.event ?? "",
|
|
518
|
+
payload: raw.payload ?? {},
|
|
519
|
+
// `null` is preserved rather than defaulted to `{}`: "nobody was attributed"
|
|
520
|
+
// and "attributed to nothing in particular" are different facts, and the
|
|
521
|
+
// platform goes out of its way to keep them apart.
|
|
522
|
+
actor: raw.actor ?? null,
|
|
523
|
+
causationChain: raw.causation_chain ?? [],
|
|
524
|
+
createdAt: raw.created_at ?? ""
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
function toPage(raw) {
|
|
528
|
+
const retention = raw.retention ?? {};
|
|
529
|
+
return {
|
|
530
|
+
events: (raw.events ?? []).map(toEvent),
|
|
531
|
+
nextCursor: readSeq(raw.next_cursor, "next_cursor"),
|
|
532
|
+
hasMore: raw.has_more === true,
|
|
533
|
+
retention: {
|
|
534
|
+
windowDays: Number(retention.window_days ?? 0),
|
|
535
|
+
oldestRetainedAt: retention.oldest_retained_at ?? "",
|
|
536
|
+
oldestAvailableSeq: readSeq(retention.oldest_available_seq, "retention.oldest_available_seq")
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
var STATUS_CODES = {
|
|
541
|
+
400: "INVALID_ARGUMENT",
|
|
542
|
+
401: "UNAUTHENTICATED",
|
|
543
|
+
403: "PERMISSION_DENIED",
|
|
544
|
+
404: "NOT_FOUND",
|
|
545
|
+
410: "FAILED_PRECONDITION",
|
|
546
|
+
429: "RATE_LIMITED",
|
|
547
|
+
503: "UNAVAILABLE",
|
|
548
|
+
504: "TIMEOUT"
|
|
549
|
+
};
|
|
550
|
+
async function readErrorMessage(res) {
|
|
551
|
+
try {
|
|
552
|
+
const json = await res.json();
|
|
553
|
+
if (typeof json?.error === "string" && json.error) return json.error;
|
|
554
|
+
const first = json?.errors?.[0]?.message;
|
|
555
|
+
return typeof first === "string" && first ? first : void 0;
|
|
556
|
+
} catch {
|
|
557
|
+
return void 0;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function createEventsClient(config) {
|
|
561
|
+
const apiKey = assertOperatorKey(config.apiKey);
|
|
562
|
+
assertNoBearer(config.headers);
|
|
563
|
+
const url = foirApiEndpoint(config.apiUrl, FOIR_EVENTS_PATH);
|
|
564
|
+
async function list(params = {}) {
|
|
565
|
+
const doFetch = config.fetch ?? globalThis.fetch;
|
|
566
|
+
const query = new URLSearchParams();
|
|
567
|
+
if (params.after !== void 0) query.set("after", String(readSeq(params.after, "after")));
|
|
568
|
+
if (params.limit !== void 0) query.set("limit", String(params.limit));
|
|
569
|
+
for (const name of params.events ?? []) {
|
|
570
|
+
if (name) query.append("event", name);
|
|
571
|
+
}
|
|
572
|
+
const qs = query.toString();
|
|
573
|
+
let res;
|
|
574
|
+
try {
|
|
575
|
+
res = await doFetch(qs ? `${url}?${qs}` : url, {
|
|
576
|
+
method: "GET",
|
|
577
|
+
headers: buildFoirHeaders({ apiKey, extra: config.headers }),
|
|
578
|
+
...params.signal ? { signal: params.signal } : {},
|
|
579
|
+
...config.requestInit
|
|
580
|
+
});
|
|
581
|
+
} catch (err) {
|
|
582
|
+
throw new FoirRequestError("[foir] events: network error", { cause: err });
|
|
583
|
+
}
|
|
584
|
+
if (!res.ok) {
|
|
585
|
+
const message = await readErrorMessage(res);
|
|
586
|
+
const detail = message ? `: ${message}` : "";
|
|
587
|
+
if (res.status === 410) {
|
|
588
|
+
throw new FoirEventCursorExpiredError(`[foir] events: cursor expired${detail}`, {
|
|
589
|
+
status: 410,
|
|
590
|
+
resumeFrom: message ? parseResumeFrom(message) : void 0,
|
|
591
|
+
requestedCursor: params.after
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
const extensions = {};
|
|
595
|
+
const code = STATUS_CODES[res.status] ?? (res.status >= 500 ? "INTERNAL" : void 0);
|
|
596
|
+
if (code) extensions.code = code;
|
|
597
|
+
const retryAfterMs = readFoirRetryAfterMs(res.headers);
|
|
598
|
+
if (retryAfterMs !== void 0) extensions.retryAfter = retryAfterMs / 1e3;
|
|
599
|
+
throw new FoirRequestError(`[foir] events: HTTP ${res.status} ${res.statusText}${detail}`, {
|
|
600
|
+
status: res.status,
|
|
601
|
+
graphQLErrors: [{ message: message ?? res.statusText, extensions }]
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
let body;
|
|
605
|
+
try {
|
|
606
|
+
body = await res.json();
|
|
607
|
+
} catch (err) {
|
|
608
|
+
throw new FoirRequestError("[foir] events: response was not JSON", {
|
|
609
|
+
cause: err,
|
|
610
|
+
status: res.status
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
return toPage(body);
|
|
614
|
+
}
|
|
615
|
+
async function* stream(params = {}) {
|
|
616
|
+
const { onPage, ...rest } = params;
|
|
617
|
+
let after = params.after ?? 0;
|
|
618
|
+
for (; ; ) {
|
|
619
|
+
const page = await list({ ...rest, after });
|
|
620
|
+
if (onPage) await onPage(page);
|
|
621
|
+
for (const event of page.events) yield event;
|
|
622
|
+
if (!page.hasMore) return;
|
|
623
|
+
if (page.nextCursor <= after) {
|
|
624
|
+
throw new FoirRequestError(
|
|
625
|
+
`[foir] events: the server reported has_more with a cursor that did not advance (${after} -> ${page.nextCursor}). Refusing to loop.`,
|
|
626
|
+
{ status: 500 }
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
after = page.nextCursor;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return { list, stream };
|
|
633
|
+
}
|
|
347
634
|
export {
|
|
635
|
+
FOIR_EVENTS_PATH,
|
|
636
|
+
FOIR_EVENTS_RETENTION_DAYS,
|
|
637
|
+
FOIR_EVENTS_SCOPE,
|
|
638
|
+
FoirEventCursorExpiredError,
|
|
639
|
+
FoirRequestError,
|
|
348
640
|
ScopedTokenError,
|
|
349
641
|
ServerClientError,
|
|
350
642
|
createCallbackClient,
|
|
643
|
+
createEventsClient,
|
|
351
644
|
createServerClient,
|
|
645
|
+
isFoirEventCursorExpired,
|
|
646
|
+
isFoirForbidden,
|
|
647
|
+
isFoirNotFound,
|
|
648
|
+
isFoirRateLimited,
|
|
649
|
+
isFoirUnauthenticated,
|
|
352
650
|
parseContextHeader,
|
|
353
651
|
parseSubject,
|
|
354
652
|
recordsReadModels,
|