@foir/sdk 0.7.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 +805 -4
- package/README.md +46 -15
- package/dist/auth/auth.d.ts +104 -1
- package/dist/auth/index.d.ts +12 -6
- package/dist/auth/operations.d.ts +85 -64
- package/dist/auth/ops.d.ts +99 -0
- package/dist/auth.js +775 -106
- package/dist/chunk-IGNLWT3H.js +35 -0
- package/dist/chunk-OHYVKTGB.js +45 -0
- package/dist/chunk-TAL5H6VA.js +39 -0
- package/dist/client.js +25 -31
- package/dist/codegen/block-registry.d.ts +13 -19
- package/dist/codegen/block-registry.js +3 -31
- package/dist/codegen/block-scaffold.d.ts +12 -21
- package/dist/codegen/block-scaffold.js +3 -41
- package/dist/codegen/index.d.ts +9 -4
- 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 +14 -7
- package/dist/codegen/shared.d.ts +20 -9
- package/dist/content/content-renderer.d.ts +90 -24
- package/dist/content/index.d.ts +13 -7
- package/dist/content/richtext.d.ts +66 -18
- package/dist/content.js +227 -80
- package/dist/data/builtins.d.ts +302 -92
- package/dist/data/client.d.ts +97 -25
- package/dist/data/document.d.ts +251 -0
- package/dist/data/index.d.ts +38 -19
- package/dist/data/manifest.d.ts +266 -3
- package/dist/data/schema.d.ts +78 -8
- package/dist/data/selection.d.ts +231 -37
- 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 +255 -37
- package/dist/data/ws-transport.d.ts +197 -0
- package/dist/data.js +1419 -45
- package/dist/graphql/cdn-route.d.ts +12 -28
- package/dist/media/index.d.ts +6 -2
- package/dist/media/media.d.ts +2 -2
- package/dist/media/screen-capture.d.ts +81 -0
- package/dist/media.js +97 -0
- package/dist/next.js +663 -182
- package/dist/push.js +700 -90
- package/dist/react/FoirFeedback.d.ts +62 -0
- package/dist/react/feedback-notes.d.ts +101 -0
- package/dist/react/index.d.ts +7 -1
- package/dist/react.js +1272 -194
- package/dist/realtime.js +1 -1
- package/dist/server/events-client.d.ts +279 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server.js +298 -0
- package/dist/transport/foir-http.d.ts +23 -6
- package/dist/transport/persisted-query.d.ts +68 -0
- package/dist/transport/retry.d.ts +1 -1
- package/package.json +13 -1
- package/dist/chunk-ADO5FJNM.js +0 -30
package/dist/realtime.js
CHANGED
|
@@ -91,7 +91,7 @@ function createCollabSession(options) {
|
|
|
91
91
|
if (clientId === awareness.clientID) return;
|
|
92
92
|
const user = state["user"];
|
|
93
93
|
if (user && typeof user.id === "string") {
|
|
94
|
-
next.push({ clientId, id: user.id, name: user.name, color: user.color });
|
|
94
|
+
next.push({ ...user, clientId, id: user.id, name: user.name, color: user.color });
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
97
|
peers = next;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@foir/sdk/server`'s event-log client — cursor-resumable consumption of a
|
|
3
|
+
* project's durable event history (TTS-983, epic TTS-912).
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { createEventsClient } from '@foir/sdk/server';
|
|
7
|
+
*
|
|
8
|
+
* const events = createEventsClient({
|
|
9
|
+
* apiUrl: process.env.PLATFORM_API_URL!,
|
|
10
|
+
* apiKey: process.env.FOIR_SECRET_KEY!, // sk_…, holding events:list.project
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* const page = await events.list({ after: lastSeq, limit: 500 });
|
|
14
|
+
* for (const event of page.events) {
|
|
15
|
+
* await handle(event);
|
|
16
|
+
* lastSeq = event.seq; // durable position; store it
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ---------------------------------------------------------------------------
|
|
21
|
+
* OPERATOR SURFACE. This is not a customer-facing capability.
|
|
22
|
+
* ---------------------------------------------------------------------------
|
|
23
|
+
*
|
|
24
|
+
* DECIDED 2026-08-04 (TTS-981). The event log is reachable by an `sk_` api key
|
|
25
|
+
* and by an admin session. It is NOT reachable by a customer token, there is no
|
|
26
|
+
* customer lane for it on api-public, and a `pk_` key can never be minted
|
|
27
|
+
* holding `events:list.project` at all.
|
|
28
|
+
*
|
|
29
|
+
* The reason is a predicate asymmetry rather than a policy preference.
|
|
30
|
+
* `events.view` is plain tenant+project containment; `record.view` carries
|
|
31
|
+
* owner, admin_owner, access_mode, grantee, comp_parent, read_borrow and
|
|
32
|
+
* slice_parent terms on top of it. A containment-only read over the log
|
|
33
|
+
* therefore describes rows whose underlying records the same caller could not
|
|
34
|
+
* read — and every row carries `actor`, naming which OTHER customer acted. In a
|
|
35
|
+
* project with owner confinement that is a per-record activity feed of other
|
|
36
|
+
* people's actions.
|
|
37
|
+
*
|
|
38
|
+
* Three consequences for the shape of this file, all deliberate:
|
|
39
|
+
*
|
|
40
|
+
* 1. **It lives on `@foir/sdk/server` and nowhere else.** Not `./data`, not
|
|
41
|
+
* `./client`, not `./react`. Those subpaths are the ones a storefront
|
|
42
|
+
* bundles, and `./data` in particular is routinely constructed with a `pk_`
|
|
43
|
+
* key in browser-reachable code. `src/server/__tests__/events-client.test.ts`
|
|
44
|
+
* walks the import graph of every published subpath and fails if this
|
|
45
|
+
* module becomes reachable from one that ships to a browser — because
|
|
46
|
+
* "we only documented it on the server" is not a property anything checks.
|
|
47
|
+
*
|
|
48
|
+
* 2. **A `pk_` key is refused at construction**, before a request is made. The
|
|
49
|
+
* server would refuse it anyway (403), but a 403 at 3am in a deploy log is
|
|
50
|
+
* a worse teacher than a thrown error naming the key type at the line that
|
|
51
|
+
* built the client.
|
|
52
|
+
*
|
|
53
|
+
* 3. **There is no `bearerToken` option, and an `Authorization` header is
|
|
54
|
+
* refused.** On api-public a request presenting `sk_` plus a customer
|
|
55
|
+
* bearer resolves the CUSTOMER as the authority, and `events` is stripped
|
|
56
|
+
* from every customer authority upstream — so attaching a signed-in
|
|
57
|
+
* shopper's token to an operator read turns a working call into a 403 that
|
|
58
|
+
* reads like a scope problem. Refusing it here says what actually happened.
|
|
59
|
+
*
|
|
60
|
+
* Widening this later is easy. Teaching customers a capability they do not have
|
|
61
|
+
* is a support burden that cannot be retracted.
|
|
62
|
+
*
|
|
63
|
+
* ---------------------------------------------------------------------------
|
|
64
|
+
* Why `GET /v1/events` and not the GraphQL `events` field
|
|
65
|
+
* ---------------------------------------------------------------------------
|
|
66
|
+
*
|
|
67
|
+
* Both exist and both call the same platform RPC with the same credential and
|
|
68
|
+
* the same scope requirement, so this is not an authorization difference. It is
|
|
69
|
+
* a caching one: the GraphQL lane sits behind api-public's response cache, and
|
|
70
|
+
* a cursor-paged log served from a cached body can hand a consumer a page it has
|
|
71
|
+
* already processed while `hasMore` says there is nothing left. The REST twin is
|
|
72
|
+
* deliberately not response-cached, which is the property a durable consumer
|
|
73
|
+
* needs. It also keeps this module `graphql`-free, like `@foir/sdk/data`.
|
|
74
|
+
*/
|
|
75
|
+
import { FoirRequestError } from '../transport/foir-http';
|
|
76
|
+
/** The path `GET /v1/events` is mounted on, inside api-public's api-key group. */
|
|
77
|
+
export declare const FOIR_EVENTS_PATH = "/v1/events";
|
|
78
|
+
/**
|
|
79
|
+
* The scope an `sk_` key must hold. Never mintable on a `pk_` — `events` is
|
|
80
|
+
* absent from the platform's `publicSafeScopeResources` — and never present in a
|
|
81
|
+
* customer's resolved authority.
|
|
82
|
+
*/
|
|
83
|
+
export declare const FOIR_EVENTS_SCOPE = "events:list.project";
|
|
84
|
+
/**
|
|
85
|
+
* The flat retention window, in days. Reported on every page as
|
|
86
|
+
* {@link FoirEventRetention.windowDays} too; this constant is the one to compare
|
|
87
|
+
* a hard-coded expectation against, and the two are pinned to each other by
|
|
88
|
+
* `oldestRetainedAt` in the tests.
|
|
89
|
+
*/
|
|
90
|
+
export declare const FOIR_EVENTS_RETENTION_DAYS = 90;
|
|
91
|
+
/** Who caused an event. `null` when the emit site attributed nobody. */
|
|
92
|
+
export interface FoirEventActor {
|
|
93
|
+
/** e.g. `"customer"`, `"admin"`, `"api_key"`, `"system"`. */
|
|
94
|
+
kind?: string;
|
|
95
|
+
/** e.g. `"customer:cus_123"`. */
|
|
96
|
+
ref?: string;
|
|
97
|
+
[key: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
/** One row of the durable event log. */
|
|
100
|
+
export interface FoirEvent {
|
|
101
|
+
id: string;
|
|
102
|
+
/**
|
|
103
|
+
* This event's position in the project's stream, assigned by the relay in
|
|
104
|
+
* COMMIT order — not at insert, and not by `created_at`.
|
|
105
|
+
*
|
|
106
|
+
* Store the highest one you have finished processing and pass it back as
|
|
107
|
+
* `after`. Gaps are normal: the stream carries events your filter did not
|
|
108
|
+
* match, and history ages out of the retention window. `seq` is for
|
|
109
|
+
* comparing, never for counting.
|
|
110
|
+
*/
|
|
111
|
+
seq: number;
|
|
112
|
+
/** The registry name, e.g. `"record.created"`. */
|
|
113
|
+
event: string;
|
|
114
|
+
/**
|
|
115
|
+
* The event's own flat keys, plus `state` and `stateSchema`.
|
|
116
|
+
*
|
|
117
|
+
* `state` is the resource AS OF the event — self-contained, not a pointer to
|
|
118
|
+
* current state — for the customer, session, operation and workspace-plane
|
|
119
|
+
* families. It is null for `record.*` events, deliberately and permanently:
|
|
120
|
+
* `events.view` is plain tenant/project containment while `record.view`
|
|
121
|
+
* carries owner, grant, borrow and per-field terms, so a copy of record data
|
|
122
|
+
* in the log would escape every one of them for the whole retention window.
|
|
123
|
+
*
|
|
124
|
+
* A record event therefore carries the `recordId` / `modelKey` pointer and no
|
|
125
|
+
* field content — read the record back under your own credentials.
|
|
126
|
+
*
|
|
127
|
+
* `stateSchema` versions the snapshot's shape.
|
|
128
|
+
*/
|
|
129
|
+
payload: Record<string, unknown>;
|
|
130
|
+
actor: FoirEventActor | null;
|
|
131
|
+
/** The operation-key history that led here. */
|
|
132
|
+
causationChain: string[];
|
|
133
|
+
/** RFC 3339, as sent. */
|
|
134
|
+
createdAt: string;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The retention window, reported on EVERY page rather than only when something
|
|
138
|
+
* has gone wrong — so a consumer can see how close its cursor is to the edge
|
|
139
|
+
* before it falls off, which is the difference between an alert and an incident.
|
|
140
|
+
*/
|
|
141
|
+
export interface FoirEventRetention {
|
|
142
|
+
/** The flat window in days. 90, no tiering. */
|
|
143
|
+
windowDays: number;
|
|
144
|
+
/** The oldest `createdAt` the API will serve. RFC 3339. */
|
|
145
|
+
oldestRetainedAt: string;
|
|
146
|
+
/**
|
|
147
|
+
* The lowest `seq` still inside the window for this project, or 0 when nothing
|
|
148
|
+
* is retained. Compare your stored cursor against it to see the runway left.
|
|
149
|
+
*/
|
|
150
|
+
oldestAvailableSeq: number;
|
|
151
|
+
}
|
|
152
|
+
export interface FoirEventPage {
|
|
153
|
+
/** The page, ordered by `seq` ascending. */
|
|
154
|
+
events: FoirEvent[];
|
|
155
|
+
/**
|
|
156
|
+
* The cursor to persist and resume from: the `seq` of the last event on this
|
|
157
|
+
* page, or the request's `after` when the page is empty. Never moves
|
|
158
|
+
* backwards, so persisting it unconditionally is safe.
|
|
159
|
+
*/
|
|
160
|
+
nextCursor: number;
|
|
161
|
+
/** True when more events are already available past `nextCursor`. */
|
|
162
|
+
hasMore: boolean;
|
|
163
|
+
retention: FoirEventRetention;
|
|
164
|
+
}
|
|
165
|
+
export interface FoirEventsListParams {
|
|
166
|
+
/**
|
|
167
|
+
* Exclusive cursor: return events with `seq > after`. Omit or pass 0 to start
|
|
168
|
+
* at the oldest event still inside the retention window.
|
|
169
|
+
*/
|
|
170
|
+
after?: number;
|
|
171
|
+
/** Page size. The server defaults to 100 and caps at 1000. */
|
|
172
|
+
limit?: number;
|
|
173
|
+
/**
|
|
174
|
+
* Filter by event name, e.g. `['record.created', 'record.updated']`. Omit for
|
|
175
|
+
* every event.
|
|
176
|
+
*/
|
|
177
|
+
events?: readonly string[];
|
|
178
|
+
/** Aborts the underlying request. */
|
|
179
|
+
signal?: AbortSignal;
|
|
180
|
+
}
|
|
181
|
+
export interface FoirEventsStreamParams extends FoirEventsListParams {
|
|
182
|
+
/**
|
|
183
|
+
* Called after each page is fetched and before its events are yielded, with
|
|
184
|
+
* the cursor that page ended on. Use it to checkpoint at page granularity
|
|
185
|
+
* instead of per event.
|
|
186
|
+
*/
|
|
187
|
+
onPage?: (page: FoirEventPage) => void | Promise<void>;
|
|
188
|
+
}
|
|
189
|
+
export interface FoirEventsClientConfig {
|
|
190
|
+
/**
|
|
191
|
+
* Foir public API origin, e.g. `https://api.foir.dev`. A full
|
|
192
|
+
* `https://api.foir.dev/graphql` is accepted too, since that is what
|
|
193
|
+
* `PLATFORM_API_URL` conventionally holds.
|
|
194
|
+
*/
|
|
195
|
+
apiUrl: string;
|
|
196
|
+
/**
|
|
197
|
+
* An `sk_` secret key holding `events:list.project`.
|
|
198
|
+
*
|
|
199
|
+
* A `pk_` key is refused here rather than at the server — see the operator
|
|
200
|
+
* note at the top of this file.
|
|
201
|
+
*/
|
|
202
|
+
apiKey: string;
|
|
203
|
+
/**
|
|
204
|
+
* Extra headers. An `Authorization` header is refused: a customer bearer
|
|
205
|
+
* flips api-public's resolved authority to the customer, and no customer
|
|
206
|
+
* authority can contain `events:list.project`.
|
|
207
|
+
*/
|
|
208
|
+
headers?: Readonly<Record<string, string>>;
|
|
209
|
+
/** `fetch` implementation. Defaults to `globalThis.fetch`. */
|
|
210
|
+
fetch?: typeof globalThis.fetch;
|
|
211
|
+
/**
|
|
212
|
+
* Merged into every `fetch` call. Applied after method/headers, so those
|
|
213
|
+
* cannot be clobbered by accident.
|
|
214
|
+
*/
|
|
215
|
+
requestInit?: Omit<RequestInit, 'method' | 'headers' | 'body'>;
|
|
216
|
+
}
|
|
217
|
+
export interface FoirEventsClient {
|
|
218
|
+
/** One page of the project's event history. */
|
|
219
|
+
list(params?: FoirEventsListParams): Promise<FoirEventPage>;
|
|
220
|
+
/**
|
|
221
|
+
* Every event from `after` to the current end of the stream, paging as it
|
|
222
|
+
* goes. Finishes when the server reports no more — it does NOT poll, so a
|
|
223
|
+
* long-running consumer wraps it in its own interval.
|
|
224
|
+
*
|
|
225
|
+
* Each yielded event carries its own `seq`, which is the cursor: a consumer
|
|
226
|
+
* that stores `event.seq` after processing resumes exactly where it stopped,
|
|
227
|
+
* including mid-page after a crash.
|
|
228
|
+
*/
|
|
229
|
+
stream(params?: FoirEventsStreamParams): AsyncGenerator<FoirEvent, void, void>;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The cursor named a position older than the retention window, so events the
|
|
233
|
+
* consumer had not read have expired. HTTP 410 Gone.
|
|
234
|
+
*
|
|
235
|
+
* The server REFUSES rather than serving a shortened page, deliberately: a flag
|
|
236
|
+
* on a successful response makes silent data loss the default for every consumer
|
|
237
|
+
* that does not read the flag. Acknowledging a gap should be one deliberate line
|
|
238
|
+
* of code, not an accident.
|
|
239
|
+
*
|
|
240
|
+
* ```ts
|
|
241
|
+
* try {
|
|
242
|
+
* page = await events.list({ after: cursor });
|
|
243
|
+
* } catch (err) {
|
|
244
|
+
* if (err instanceof FoirEventCursorExpiredError) {
|
|
245
|
+
* await recordGap(cursor, err.resumeFrom);
|
|
246
|
+
* page = await events.list({ after: err.resumeFrom ?? 0 });
|
|
247
|
+
* } else throw err;
|
|
248
|
+
* }
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
export declare class FoirEventCursorExpiredError extends FoirRequestError {
|
|
252
|
+
/**
|
|
253
|
+
* The cursor to resume from, accepting the gap — parsed out of the server's
|
|
254
|
+
* message, which names it.
|
|
255
|
+
*
|
|
256
|
+
* `undefined` only if the message ever stops naming one, in which case
|
|
257
|
+
* restarting from 0 and de-duplicating on `id` is the safe fallback. It is
|
|
258
|
+
* optional for that reason and not because the server omits it today; both
|
|
259
|
+
* refusal messages carry it, and both are pinned as fixtures in the tests.
|
|
260
|
+
*/
|
|
261
|
+
readonly resumeFrom?: number;
|
|
262
|
+
/** The cursor that was rejected — whatever was passed as `after`. */
|
|
263
|
+
readonly requestedCursor?: number;
|
|
264
|
+
constructor(message: string, options: {
|
|
265
|
+
status: number;
|
|
266
|
+
resumeFrom?: number;
|
|
267
|
+
requestedCursor?: number;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* True when the failure is a cursor that fell outside the retention window.
|
|
272
|
+
*
|
|
273
|
+
* Provided alongside the class because `instanceof` across two copies of the
|
|
274
|
+
* package — a monorepo with a hoisted and a nested `@foir/sdk` — is false even
|
|
275
|
+
* for the right error, and the SDK's other taxonomy helpers
|
|
276
|
+
* (`isFoirRateLimited`, `isFoirForbidden`) already set this precedent.
|
|
277
|
+
*/
|
|
278
|
+
export declare function isFoirEventCursorExpired(err: unknown): err is FoirEventCursorExpiredError;
|
|
279
|
+
export declare function createEventsClient(config: FoirEventsClientConfig): FoirEventsClient;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -6,3 +6,7 @@ export type { ServerClient, ServerClientOptions, GraphQLResponse, EditorPlacemen
|
|
|
6
6
|
export type { OperationPayload, OperationTrigger, OperationRecordContext, OperationContext, OperationResult, OperationCallback, } from './operation-types';
|
|
7
7
|
export { createCallbackClient } from './callback-client';
|
|
8
8
|
export type { CallbackClient, CallbackResult } from './callback-client';
|
|
9
|
+
export { createEventsClient, FoirEventCursorExpiredError, isFoirEventCursorExpired, FOIR_EVENTS_PATH, FOIR_EVENTS_SCOPE, FOIR_EVENTS_RETENTION_DAYS, } from './events-client';
|
|
10
|
+
export type { FoirEvent, FoirEventActor, FoirEventPage, FoirEventRetention, FoirEventsClient, FoirEventsClientConfig, FoirEventsListParams, FoirEventsStreamParams, } from './events-client';
|
|
11
|
+
export { FoirRequestError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, } from '../transport/foir-http';
|
|
12
|
+
export type { FoirErrorCode, FoirErrorExtensions, FoirGraphQLErrorItem } from '../transport/foir-http';
|
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,
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* - `@foir/sdk/client` — `foirRequest`, which prints a `TypedDocumentNode` and
|
|
7
7
|
* POSTs it to `/graphql`. Needs `graphql` for `print`.
|
|
8
|
-
* - `@foir/sdk/data` — `createFoirClient`, which
|
|
9
|
-
*
|
|
8
|
+
* - `@foir/sdk/data` — `createFoirClient`, which prints a document from a
|
|
9
|
+
* selection tree and must not pull `graphql` into a storefront bundle at
|
|
10
|
+
* all. Printing a pre-resolved tree is string building.
|
|
10
11
|
*
|
|
11
12
|
* The split exists so the second one is possible. The Foir-specific transport
|
|
12
13
|
* concerns — which api key is in play, the preview schema channel header, the
|
|
@@ -196,11 +197,27 @@ export interface FoirHeaderInput {
|
|
|
196
197
|
}
|
|
197
198
|
/**
|
|
198
199
|
* The exact header set every Foir data-plane request carries. Single
|
|
199
|
-
* definition on purpose: `/graphql` and `/
|
|
200
|
+
* definition on purpose: `/graphql` and `/cdn/graphql` authenticate identically,
|
|
200
201
|
* and a header that exists on one surface but not the other is a per-surface
|
|
201
202
|
* authorization difference nobody would notice until it mattered.
|
|
202
203
|
*/
|
|
203
204
|
export declare function buildFoirHeaders(input: FoirHeaderInput): Record<string, string>;
|
|
205
|
+
/**
|
|
206
|
+
* Build a `/v1/...` URL from whatever the caller put in `apiUrl`.
|
|
207
|
+
*
|
|
208
|
+
* Tolerates the three things people actually pass: an origin, an origin with a
|
|
209
|
+
* trailing slash, and the conventional `PLATFORM_API_URL` value
|
|
210
|
+
* (`https://api.foir.dev/graphql`).
|
|
211
|
+
*
|
|
212
|
+
* Lives here rather than beside one of its callers because three surfaces now
|
|
213
|
+
* turn an `apiUrl` into a path on the API origin — `@foir/sdk/data`'s `/graphql`,
|
|
214
|
+
* `@foir/sdk/server`'s `/v1/events`, and anything that follows — and the
|
|
215
|
+
* tolerated inputs are a property of the config field, not of the endpoint. A
|
|
216
|
+
* second copy would accept a slightly different set, and the difference would
|
|
217
|
+
* only ever show up as one surface rejecting a `PLATFORM_API_URL` the other
|
|
218
|
+
* accepted. `data/transport.ts` re-exports it under its published name.
|
|
219
|
+
*/
|
|
220
|
+
export declare function foirApiEndpoint(apiUrl: string, path: `/${string}`): string;
|
|
204
221
|
export interface FoirResponseEnvelope<T> {
|
|
205
222
|
data?: T;
|
|
206
223
|
errors?: Array<FoirGraphQLErrorItem>;
|
|
@@ -210,8 +227,8 @@ export interface FoirResponseEnvelope<T> {
|
|
|
210
227
|
*
|
|
211
228
|
* GraphQL can return partial data alongside errors; when data is present the
|
|
212
229
|
* errors are logged and the data returned (callers handle nulls), and we only
|
|
213
|
-
* throw when there is no data at all.
|
|
214
|
-
*
|
|
215
|
-
* and
|
|
230
|
+
* throw when there is no data at all. Both Foir data-plane routes — `/graphql`
|
|
231
|
+
* and `/cdn/graphql` — hand their document to the same executor, so the two
|
|
232
|
+
* return this envelope and get the same treatment.
|
|
216
233
|
*/
|
|
217
234
|
export declare function readFoirEnvelope<T>(json: FoirResponseEnvelope<T>): T;
|