@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +1126 -0
  2. package/README.md +94 -0
  3. package/dist/auth/auth.d.ts +243 -0
  4. package/dist/auth/hosted.d.ts +40 -0
  5. package/dist/auth/index.d.ts +21 -0
  6. package/dist/auth/operations.d.ts +144 -0
  7. package/dist/auth/ops.d.ts +99 -0
  8. package/dist/auth/session-client.d.ts +76 -0
  9. package/dist/auth-session.js +137 -0
  10. package/dist/auth.js +973 -92
  11. package/dist/chunk-IGNLWT3H.js +35 -0
  12. package/dist/chunk-OHYVKTGB.js +45 -0
  13. package/dist/chunk-TAL5H6VA.js +39 -0
  14. package/dist/client/platform-client.d.ts +29 -0
  15. package/dist/client.js +275 -37
  16. package/dist/codegen/block-registry.d.ts +13 -23
  17. package/dist/codegen/block-registry.js +3 -31
  18. package/dist/codegen/block-scaffold.d.ts +12 -25
  19. package/dist/codegen/block-scaffold.js +3 -41
  20. package/dist/codegen/index.d.ts +12 -61
  21. package/dist/codegen/index.js +7 -11
  22. package/dist/codegen/item-registry.d.ts +28 -0
  23. package/dist/codegen/item-registry.js +7 -0
  24. package/dist/codegen/item-scaffold.d.ts +29 -0
  25. package/dist/codegen/item-scaffold.js +7 -0
  26. package/dist/codegen/preset.d.ts +67 -0
  27. package/dist/codegen/shared.d.ts +36 -0
  28. package/dist/components/EditorProvider.d.ts +36 -0
  29. package/dist/content/content-renderer.d.ts +113 -0
  30. package/dist/content/index.d.ts +18 -0
  31. package/dist/content/richtext.d.ts +118 -0
  32. package/dist/content.js +227 -80
  33. package/dist/data/builtins.d.ts +506 -0
  34. package/dist/data/client.d.ts +166 -0
  35. package/dist/data/document.d.ts +251 -0
  36. package/dist/data/index.d.ts +73 -0
  37. package/dist/data/manifest.d.ts +424 -0
  38. package/dist/data/schema.d.ts +313 -0
  39. package/dist/data/selection.d.ts +524 -0
  40. package/dist/data/subjects.d.ts +221 -0
  41. package/dist/data/subscription-document.d.ts +75 -0
  42. package/dist/data/subscriptions.d.ts +181 -0
  43. package/dist/data/transport.d.ts +325 -0
  44. package/dist/data/ws-transport.d.ts +197 -0
  45. package/dist/data.js +1683 -0
  46. package/dist/graphql/cdn-route.d.ts +26 -0
  47. package/dist/graphql/client.d.ts +30 -0
  48. package/dist/graphql/content-client.d.ts +66 -0
  49. package/dist/graphql/foir-request.d.ts +47 -0
  50. package/dist/hooks/useAutoResize.d.ts +22 -0
  51. package/dist/hooks/useEditor.d.ts +16 -0
  52. package/dist/index.d.ts +10 -394
  53. package/dist/media/BlurhashCanvas.d.ts +10 -0
  54. package/dist/media/ProgressiveMedia.d.ts +56 -0
  55. package/dist/media/index.d.ts +18 -0
  56. package/dist/media/media.d.ts +139 -0
  57. package/dist/media/screen-capture.d.ts +81 -0
  58. package/dist/media/useProgressiveLoad.d.ts +23 -0
  59. package/dist/media.js +102 -0
  60. package/dist/next/cookieAdapter.d.ts +26 -0
  61. package/dist/next/index.d.ts +15 -0
  62. package/dist/next/middleware.d.ts +49 -0
  63. package/dist/next/session.d.ts +53 -0
  64. package/dist/next.js +763 -78
  65. package/dist/protocol/constants.d.ts +6 -0
  66. package/dist/protocol/messages.d.ts +155 -0
  67. package/dist/protocol/validation.d.ts +8 -0
  68. package/dist/push/index.d.ts +14 -0
  69. package/dist/push/operations.d.ts +22 -0
  70. package/dist/push/web-push.d.ts +39 -0
  71. package/dist/push.js +892 -78
  72. package/dist/react/FoirFeedback.d.ts +62 -0
  73. package/dist/react/FoirProvider.d.ts +116 -0
  74. package/dist/react/OAuthButtons.d.ts +20 -0
  75. package/dist/react/SignIn.d.ts +22 -0
  76. package/dist/react/UserButton.d.ts +13 -0
  77. package/dist/react/feedback-notes.d.ts +101 -0
  78. package/dist/react/index.d.ts +25 -0
  79. package/dist/react/localStorageAdapter.d.ts +10 -0
  80. package/dist/react.js +1373 -91
  81. package/dist/{realtime.d.ts → realtime/index.d.ts} +35 -16
  82. package/dist/realtime.js +1 -1
  83. package/dist/server/callback-client.d.ts +92 -0
  84. package/dist/server/events-client.d.ts +279 -0
  85. package/dist/server/index.d.ts +12 -0
  86. package/dist/server/operation-types.d.ts +134 -0
  87. package/dist/server/require-scoped-token.d.ts +76 -0
  88. package/dist/server/server-client.d.ts +34 -0
  89. package/dist/server/types.d.ts +54 -0
  90. package/dist/server/verify-scoped-token.d.ts +100 -0
  91. package/dist/server/webhook-helpers.d.ts +36 -0
  92. package/dist/server.js +298 -0
  93. package/dist/transport/foir-http.d.ts +234 -0
  94. package/dist/transport/persisted-query.d.ts +68 -0
  95. package/dist/transport/retry.d.ts +61 -0
  96. package/dist/types/index.d.ts +124 -0
  97. package/package.json +35 -13
  98. package/dist/auth.d.ts +0 -382
  99. package/dist/chunk-ADO5FJNM.js +0 -30
  100. package/dist/client.d.ts +0 -135
  101. package/dist/content.d.ts +0 -96
  102. package/dist/media.d.ts +0 -234
  103. package/dist/next.d.ts +0 -178
  104. package/dist/push.d.ts +0 -109
  105. package/dist/react.d.ts +0 -319
  106. package/dist/server.d.ts +0 -534
  107. 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,