@foir/sdk 0.1.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.
@@ -0,0 +1,457 @@
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
+ 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
+ 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
+ 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
+ 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
+ }
55
+
56
+ /**
57
+ * Server-side client for runtime communication with the Foir platform.
58
+ *
59
+ * Used by config API backends (e.g., Hono servers) to query the platform
60
+ * GraphQL API at runtime. Provisioning (models, operations, schedules) is
61
+ * handled by `foir push`. Record writes from upstream sources go through
62
+ * the scoped-token `upsertRecord` GraphQL mutation served by the
63
+ * integrations gateway, not through this client.
64
+ *
65
+ * Auth: All requests use an API key (x-api-key header).
66
+ */
67
+
68
+ /** Error thrown when a platform API request fails. */
69
+ declare class ServerClientError extends Error {
70
+ status?: number | undefined;
71
+ response?: unknown | undefined;
72
+ constructor(message: string, status?: number | undefined, response?: unknown | undefined);
73
+ }
74
+ /**
75
+ * Create a ServerClient for runtime platform communication.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * import { createServerClient } from '@foir/sdk/server';
80
+ *
81
+ * const client = createServerClient({
82
+ * baseUrl: 'https://api.example.com',
83
+ * apiKey: 'sk_project_xxx',
84
+ * });
85
+ *
86
+ * const { data } = await client.query('query { models { items { key } } }');
87
+ * ```
88
+ */
89
+ declare function createServerClient(options: ServerClientOptions): ServerClient;
90
+
91
+ /**
92
+ * Webhook verification helpers for extension API backends.
93
+ *
94
+ * Verifies HMAC-SHA256 signatures on incoming webhook requests
95
+ * from the EIDE platform.
96
+ *
97
+ * The platform sends two security headers:
98
+ * - `x-foir-signature`: HMAC-SHA256 hex signature of the request body
99
+ * - `x-foir-token`: Short-lived JWT (5min) containing extension context + API key
100
+ *
101
+ * Extensions should verify the signature and extract the API key from the token.
102
+ */
103
+ /**
104
+ * Verify a webhook signature from the platform.
105
+ *
106
+ * The platform signs webhook payloads with HMAC-SHA256 using the
107
+ * extension's webhook secret. The signature is sent in the
108
+ * `x-foir-signature` header as a hex-encoded string.
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * import { verifyWebhookSignature } from '@foir/sdk/server';
113
+ *
114
+ * app.post('/webhooks/entity-published', async (c) => {
115
+ * const body = await c.req.text();
116
+ * const signature = c.req.header('x-foir-signature') ?? '';
117
+ *
118
+ * const valid = await verifyWebhookSignature(body, signature, WEBHOOK_SECRET);
119
+ * if (!valid) return c.text('Invalid signature', 401);
120
+ *
121
+ * const payload = JSON.parse(body);
122
+ * // process webhook...
123
+ * });
124
+ * ```
125
+ */
126
+ declare function verifyWebhookSignature(payload: string, signature: string, secret: string): Promise<boolean>;
127
+
128
+ /**
129
+ * Verify a Foir scoped-token JWT (Ed25519, EdDSA) using Web Crypto.
130
+ *
131
+ * Operation handlers receive this token in the `X-Foir-Token` header (the
132
+ * dispatch token authenticating the inbound request) or embedded in the
133
+ * dispatch envelope's `callback.token` field (the callback token used as
134
+ * Bearer for mid-execution GraphQL calls back to api-public). Replaces the
135
+ * legacy HMAC + shared-secret verification for operation dispatches — the
136
+ * handler no longer needs `FOIR_SIGNING_SECRET` for the inbound path, only
137
+ * the URL of the platform's JWKS endpoint
138
+ * (`https://api.foir.dev/.well-known/jwks.json`).
139
+ *
140
+ * Lifted verbatim from the inline implementations in
141
+ * belstaff-translation-portal/src/lib/verify-scoped-token.ts and
142
+ * hydrogen-storefront/app/lib/foir/verify-scoped-token.ts so consumers can
143
+ * drop their copies in favor of the shared export. JWKS keys are cached
144
+ * per-isolate via `globalThis`; TTL matches the endpoint's
145
+ * Cache-Control max-age (5 minutes).
146
+ */
147
+ interface ScopedTokenClaims {
148
+ iss: string;
149
+ /**
150
+ * Pipe-delimited `<tenantId>|<projectId>|<appName>` triple. Use
151
+ * {@link parseSubject} to split into a typed object.
152
+ */
153
+ sub: string;
154
+ cap: Array<string>;
155
+ jti: string;
156
+ exp: number;
157
+ nbf: number;
158
+ iat: number;
159
+ ctx?: Record<string, string>;
160
+ /**
161
+ * Records-read model allow-list. Present when the operation declared one
162
+ * or more `records:read:<model>` capabilities. Pair with the
163
+ * `records:read` marker in {@link ScopedTokenClaims.cap} to authorize a
164
+ * record read for a specific model.
165
+ */
166
+ rrm?: Array<string>;
167
+ /**
168
+ * Records-write model allow-list. Present when the operation declared
169
+ * one or more `records:write:<model>` capabilities. Pair with the
170
+ * `records:write` marker in {@link ScopedTokenClaims.cap} to authorize
171
+ * a record write for a specific model.
172
+ */
173
+ rwm?: Array<string>;
174
+ }
175
+ interface ScopedTokenSubject {
176
+ tenantId: string;
177
+ projectId: string;
178
+ appName: string;
179
+ }
180
+ /**
181
+ * Parse the pipe-delimited `sub` claim emitted by the platform's
182
+ * scoped-token minter into its three components. Throws on a missing or
183
+ * empty segment — the platform never emits a partially-populated subject,
184
+ * so a malformed sub indicates a forged or corrupt token.
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * const { tenantId, projectId, appName } = parseSubject(claims.sub);
189
+ * ```
190
+ */
191
+ declare function parseSubject(sub: string): ScopedTokenSubject;
192
+ /**
193
+ * Extract the `records:read:<model>` allow-list from a verified claim
194
+ * set. Returns an empty array when the token grants no per-model read
195
+ * capabilities. Convenience over reading {@link ScopedTokenClaims.rrm}
196
+ * directly, mirroring the platform's
197
+ * `scopedtoken.Claims.RecordsReadModels` field.
198
+ */
199
+ declare function recordsReadModels(claims: ScopedTokenClaims): Array<string>;
200
+ /**
201
+ * Extract the `records:write:<model>` allow-list from a verified claim
202
+ * set. Returns an empty array when the token grants no per-model write
203
+ * capabilities. Mirror of {@link recordsReadModels} for the write side.
204
+ */
205
+ declare function recordsWriteModels(claims: ScopedTokenClaims): Array<string>;
206
+ interface VerifyScopedTokenOptions {
207
+ /**
208
+ * Expected `iss` claim. Defaults to `'foir-platform'`, the issuer the
209
+ * platform's scoped-token minter uses. Override only when verifying
210
+ * tokens from a non-default issuer (test harnesses, alternate deploys).
211
+ */
212
+ expectedIssuer?: string;
213
+ }
214
+ /**
215
+ * Verify a scoped-token JWT against the platform's JWKS.
216
+ *
217
+ * Throws on malformed header, unknown kid, bad signature, missing/expired
218
+ * exp, future nbf (with 30s skew tolerance), or mismatched iss. Returns
219
+ * the decoded claim set on success.
220
+ */
221
+ declare function verifyScopedToken(token: string, jwksUrl: string, options?: VerifyScopedTokenOptions): Promise<ScopedTokenClaims>;
222
+ /**
223
+ * Parse the semicolon-delimited `X-Foir-Context` header the platform
224
+ * attaches to dispatched operation requests. Shape:
225
+ * `project=P;app=A;operation=O;triggered_by=T;execution_id=E;causation_chain=a,b,c`.
226
+ */
227
+ declare function parseContextHeader(raw: string | null): Record<string, string>;
228
+
229
+ /**
230
+ * Type definitions for platform operations.
231
+ *
232
+ * These types match the platform's operation execution payload structure.
233
+ */
234
+ /**
235
+ * Operation payload sent by the platform to extension endpoints.
236
+ *
237
+ * All operations receive this standardized payload structure regardless
238
+ * of how they're triggered (UI, API, lifecycle hook, schedule).
239
+ */
240
+ interface OperationPayload {
241
+ /** Execution id — present so the extension can include it in callbacks. */
242
+ executionId?: string;
243
+ /** Unique key identifying this operation (e.g., 'tilly-process-note') */
244
+ operationKey: string;
245
+ /** How this operation was triggered */
246
+ trigger: OperationTrigger;
247
+ /** Input data provided by the caller */
248
+ input: Record<string, unknown>;
249
+ /** Content/field value (for field-triggered operations) */
250
+ content: unknown;
251
+ /** Record context (if operation was triggered by a record event) */
252
+ record: OperationRecordContext | null;
253
+ /** Platform context (tenant, project, user, locale) */
254
+ context: OperationContext;
255
+ /** Extension credentials (auto-injected by platform) */
256
+ credentials?: Record<string, unknown>;
257
+ /** Extension config (auto-injected by platform) */
258
+ config?: Record<string, unknown>;
259
+ /**
260
+ * Async-callback dispatch metadata. Present iff the operation was
261
+ * dispatched in `async-callback` mode. Contains the scoped token, the
262
+ * api-public GraphQL endpoint, and the per-op typed mutation names the
263
+ * extension should call back to.
264
+ *
265
+ * Absent for SYNC operations — the extension returns its result in the
266
+ * HTTP response body and there's nothing to call back to.
267
+ */
268
+ callback?: OperationCallback;
269
+ }
270
+ /**
271
+ * Callback context the platform hands the extension when it dispatches an
272
+ * async-callback operation. Everything the extension needs to call back is
273
+ * here — endpoint, auth token, signing secret reference (the secret itself
274
+ * lives on the extension, set out of band; the platform only sends the
275
+ * token), and the per-op mutation names so the extension doesn't need to
276
+ * derive them from the operation key.
277
+ */
278
+ interface OperationCallback {
279
+ /** api-public GraphQL endpoint, e.g. https://api.foir.dev/graphql */
280
+ gqlEndpoint: string;
281
+ /** Bearer token for the Authorization header (Ed25519, single execution scope) */
282
+ token: string;
283
+ /** RFC3339 expiry — extensions can short-circuit work past this. */
284
+ expiresAt: string;
285
+ /** Per-op typed mutation names the extension should call back with. */
286
+ mutations: {
287
+ complete: string;
288
+ fail: string;
289
+ progress: string;
290
+ cancel: string;
291
+ };
292
+ }
293
+ /**
294
+ * Trigger information describing how the operation was invoked.
295
+ */
296
+ interface OperationTrigger {
297
+ /** Trigger type */
298
+ type: 'ui' | 'api' | 'lifecycle' | 'field';
299
+ /** Field key (for field-triggered operations) */
300
+ fieldKey?: string;
301
+ /** Field type (for field-triggered operations) */
302
+ fieldType?: string;
303
+ }
304
+ /**
305
+ * Record context provided when operation is triggered by a record event.
306
+ */
307
+ interface OperationRecordContext {
308
+ /** Record ID */
309
+ id: string;
310
+ /** Model key (e.g., 'tilly_note') */
311
+ modelKey: string;
312
+ /** Version ID (if versioned record) */
313
+ versionId?: string;
314
+ /** Record content/data */
315
+ data: Record<string, unknown>;
316
+ /** Record metadata */
317
+ metadata: {
318
+ /** Natural key (slug, handle, etc.) */
319
+ naturalKey?: string;
320
+ /** Additional metadata fields */
321
+ [key: string]: unknown;
322
+ };
323
+ }
324
+ /**
325
+ * Platform context provided to all operations.
326
+ */
327
+ interface OperationContext {
328
+ /** Tenant ID */
329
+ tenantId: string;
330
+ /** Project ID */
331
+ projectId: string;
332
+ /** User ID (if user-triggered) */
333
+ userId?: string;
334
+ /** Customer ID (if customer-scoped operation) */
335
+ customerId?: string;
336
+ /** Locale code (if locale-specific) */
337
+ locale?: string;
338
+ /** Timestamp when operation was triggered */
339
+ timestamp: string;
340
+ }
341
+ /**
342
+ * Standard operation result shape.
343
+ *
344
+ * All operations should return this structure.
345
+ */
346
+ interface OperationResult {
347
+ /** Whether the operation succeeded */
348
+ success: boolean;
349
+ /** Operation result data (if successful) */
350
+ result?: unknown;
351
+ /** Error information (if failed) */
352
+ error?: {
353
+ /** Error code (e.g., 'MISSING_INPUT', 'PROCESSING_FAILED') */
354
+ code: string;
355
+ /** Human-readable error message */
356
+ message: string;
357
+ /** Additional error details */
358
+ details?: Record<string, unknown>;
359
+ };
360
+ /** Optional metadata (timing, cost, etc.) */
361
+ metadata?: Record<string, unknown>;
362
+ }
363
+
364
+ /**
365
+ * Callback client for extensions running async operations.
366
+ *
367
+ * The platform dispatches with `mode='async'` and includes a `callback`
368
+ * block in the operation payload. The extension acks 202, kicks the work
369
+ * off in its own durable runtime (CF Queue, BullMQ, etc.), and later
370
+ * calls back with one of:
371
+ *
372
+ * - `complete(result)` — operation succeeded; result conforms to the
373
+ * operation's `output_schema` (per-op typed via codegen).
374
+ * - `fail(code, message, opts?)` — operation failed; `retryable=true`
375
+ * pushes a DLQ entry.
376
+ * - `progress({pct, message, metadata})` — non-terminal update; the
377
+ * monotonic guard at the platform DB layer drops out-of-order packets.
378
+ * - `cancel()` — extension self-cancels (rare; admins normally drive
379
+ * cancel via the admin app's ConnectRPC path).
380
+ *
381
+ * Each call carries:
382
+ * - `Authorization: Bearer <callback.token>` — Ed25519 scoped token.
383
+ * - `X-Foir-Signature: sha256=<hex>` — HMAC of the raw request body
384
+ * keyed on the project's `operations.signing_secret`. The secret is
385
+ * out-of-band (CLI publishes it to the extension on push); the SDK
386
+ * only handles the HMAC computation.
387
+ *
388
+ * Both response body shape and content-type follow the GraphQL spec —
389
+ * extensions importing this client receive typed result data.
390
+ */
391
+
392
+ /** Shape of a successful response from any callback mutation. */
393
+ interface CallbackResult {
394
+ /**
395
+ * Status as seen by the platform AFTER the call. One of:
396
+ * pending|dispatched|running|completed|failed|cancelled|timed_out|progress_stale.
397
+ * Always set even on idempotent / cancelled calls.
398
+ */
399
+ status: string;
400
+ /**
401
+ * True when the admin cancelled the execution while the extension was
402
+ * processing — the platform refused to overwrite the cancelled state.
403
+ * Extensions should stop work and (optionally) call `fail('CANCELLED')`.
404
+ */
405
+ cancelled: boolean;
406
+ /**
407
+ * Progress mutations only: false when the monotonic guard rejected an
408
+ * out-of-order update. Extensions can use this to detect packet
409
+ * reordering without erroring; missing on complete/fail/cancel.
410
+ */
411
+ applied?: boolean;
412
+ }
413
+ /**
414
+ * Per-operation callback client. `TResult` is the operation's typed result
415
+ * shape — supply the type generated by graphql-codegen against the
416
+ * api-public schema (`Complete{Op}ExecutionMutationVariables['result']`).
417
+ */
418
+ interface CallbackClient<TResult = unknown> {
419
+ complete(result: TResult): Promise<CallbackResult>;
420
+ fail(code: string, message: string, opts?: {
421
+ retryable?: boolean;
422
+ details?: Record<string, unknown>;
423
+ }): Promise<CallbackResult>;
424
+ /**
425
+ * Returns `{cancelled: true}` when the admin has cancelled — extension
426
+ * must stop work. `applied: false` means the monotonic guard dropped
427
+ * the update because pct was lower than the persisted value.
428
+ */
429
+ progress(update: {
430
+ pct?: number;
431
+ message?: string;
432
+ metadata?: Record<string, unknown>;
433
+ }): Promise<CallbackResult>;
434
+ cancel(): Promise<CallbackResult>;
435
+ }
436
+ /**
437
+ * Construct a callback client from the dispatch payload's callback block
438
+ * plus the runtime-provided execution id and signing secret.
439
+ *
440
+ * `executionId` comes from the same operation payload that delivered
441
+ * `callback`; pass them together so the SDK doesn't have to re-parse the
442
+ * payload structure on every call.
443
+ *
444
+ * `signingSecret` lives on the extension (set as `FOIR_SIGNING_SECRET` or
445
+ * equivalent env var). The CLI publishes it during `foir push` so it's
446
+ * available before any callback runs — see foir-cli writeConfigCredential.
447
+ *
448
+ * `fetch` is overridable for testability and to support runtimes (CF
449
+ * Workers, Node) where `fetch` may not be the global.
450
+ */
451
+ declare function createCallbackClient<TResult = unknown>(callback: OperationCallback, opts: {
452
+ executionId: string;
453
+ signingSecret: string;
454
+ fetch?: typeof fetch;
455
+ }): CallbackClient<TResult>;
456
+
457
+ export { type CallbackClient, type CallbackResult, type EditorPlacement, type GraphQLResponse, type OperationCallback, type OperationContext, type OperationPayload, type OperationRecordContext, type OperationResult, type OperationTrigger, type ScopedTokenClaims, type ScopedTokenSubject, type ServerClient, ServerClientError, type ServerClientOptions, type VerifyScopedTokenOptions, createCallbackClient, createServerClient, parseContextHeader, parseSubject, recordsReadModels, recordsWriteModels, verifyScopedToken, verifyWebhookSignature };