@foir/sdk 0.10.0 → 0.11.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 +222 -0
- package/README.md +10 -2
- package/dist/auth/ops.d.ts +8 -0
- package/dist/auth.js +91 -37
- package/dist/client.js +44 -13
- package/dist/codegen/preset.d.ts +3 -2
- package/dist/data/builtins.d.ts +17 -1
- package/dist/data/client.d.ts +10 -8
- package/dist/data/document.d.ts +13 -0
- package/dist/data/index.d.ts +13 -3
- package/dist/data/manifest.d.ts +31 -3
- package/dist/data/schema.d.ts +16 -9
- package/dist/data/selection.d.ts +15 -0
- package/dist/data/transport.d.ts +244 -22
- package/dist/data/ws-transport.d.ts +8 -2
- package/dist/data.js +236 -70
- package/dist/index.js +0 -9
- package/dist/live/drift.d.ts +9 -10
- package/dist/live.js +1 -1
- package/dist/next.js +88 -35
- package/dist/push.js +91 -37
- package/dist/react.js +88 -35
- package/dist/server/index.d.ts +1 -0
- package/dist/server/operation-types.d.ts +86 -58
- package/dist/server/read-dispatch.d.ts +53 -0
- package/dist/server/require-scoped-token.d.ts +19 -0
- package/dist/server.js +162 -22
- package/dist/transport/foir-http.d.ts +55 -5
- package/package.json +1 -1
|
@@ -1,41 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Type definitions for platform operations.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* `OperationPayload` is the body the platform POSTs to an operation endpoint,
|
|
5
|
+
* field for field. It is written against the worker that builds it
|
|
6
|
+
* (`buildOperationPayload` in the platform's operation processor), not against
|
|
7
|
+
* what an endpoint might like to receive: every field here is one the platform
|
|
8
|
+
* sends, present exactly when the platform sends it. A field the platform does
|
|
9
|
+
* not send is not here, because a type that promises one is how an endpoint
|
|
10
|
+
* comes to read `undefined` and refuse every call.
|
|
11
|
+
*
|
|
12
|
+
* Read it with `readDispatch(request)`, which parses and checks the body into
|
|
13
|
+
* this type.
|
|
5
14
|
*/
|
|
6
15
|
/**
|
|
7
|
-
*
|
|
16
|
+
* The body the platform sends an operation endpoint.
|
|
17
|
+
*
|
|
18
|
+
* The same shape however the run was started (a person in the console, an
|
|
19
|
+
* `execute<Op>` call, a lifecycle hook, a schedule) and whichever way it is
|
|
20
|
+
* delivered (to the registered endpoint, or down `foir operations dev` to one
|
|
21
|
+
* on your machine).
|
|
8
22
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
23
|
+
* `TInput` types `input`. It is an assertion, not a check: the platform
|
|
24
|
+
* validates `input` against the operation's input schema before dispatching,
|
|
25
|
+
* and this parameter lets the endpoint say what that schema is.
|
|
11
26
|
*/
|
|
12
|
-
export interface OperationPayload {
|
|
13
|
-
/**
|
|
14
|
-
executionId
|
|
15
|
-
/**
|
|
27
|
+
export interface OperationPayload<TInput extends object = Record<string, unknown>> {
|
|
28
|
+
/** The run. Always present; quote it in a callback and in your own logs. */
|
|
29
|
+
executionId: string;
|
|
30
|
+
/** The key of the operation being run, e.g. `send_quote`. */
|
|
16
31
|
operationKey: string;
|
|
17
|
-
/** How
|
|
32
|
+
/** How the run was started. */
|
|
18
33
|
trigger: OperationTrigger;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* What the caller passed as the operation's input. Always an object:
|
|
36
|
+
* `readDispatch` reads a run started with no input (a schedule that declares
|
|
37
|
+
* none, which the platform serialises as `null`) as `{}`.
|
|
38
|
+
*/
|
|
39
|
+
input: TInput;
|
|
40
|
+
/** The tenant, project and principal the run belongs to. */
|
|
26
41
|
context: OperationContext;
|
|
27
|
-
/** Extension credentials (auto-injected by platform) */
|
|
28
|
-
credentials?: Record<string, unknown>;
|
|
29
|
-
/** Extension config (auto-injected by platform) */
|
|
30
|
-
config?: Record<string, unknown>;
|
|
31
42
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
43
|
+
* The content block the caller attached when it started the run, forwarded
|
|
44
|
+
* as the caller wrote it. Absent when there was none, which is every run
|
|
45
|
+
* started by a hook, a schedule or `execute<Op>`.
|
|
46
|
+
*/
|
|
47
|
+
content?: Record<string, unknown>;
|
|
48
|
+
/**
|
|
49
|
+
* The record block the caller attached when it started the run, forwarded
|
|
50
|
+
* as the caller wrote it. Absent when there was none. For a record event,
|
|
51
|
+
* the record's id arrives in `input` (for example `input.recordId`), not
|
|
52
|
+
* here: read the record back as the operation when you need its content.
|
|
53
|
+
*/
|
|
54
|
+
record?: OperationRecordContext;
|
|
55
|
+
/**
|
|
56
|
+
* Caller-supplied context about where the run was started from, forwarded as
|
|
57
|
+
* written. Absent when there was none.
|
|
58
|
+
*/
|
|
59
|
+
triggerContext?: Record<string, unknown>;
|
|
60
|
+
/**
|
|
61
|
+
* Where and how to report back. Present only when the run was dispatched in
|
|
62
|
+
* ASYNC mode: the endpoint answers `202 Accepted` and reports the outcome
|
|
63
|
+
* later through this block (see `createCallbackClient`).
|
|
36
64
|
*
|
|
37
|
-
* Absent
|
|
38
|
-
*
|
|
65
|
+
* Absent in the blocking (SYNC) mode. A blocking run has no callback path:
|
|
66
|
+
* the endpoint's response body is the result. Branch on this field being
|
|
67
|
+
* present to tell the two modes apart.
|
|
39
68
|
*/
|
|
40
69
|
callback?: OperationCallback;
|
|
41
70
|
}
|
|
@@ -61,53 +90,52 @@ export interface OperationCallback {
|
|
|
61
90
|
progress: string;
|
|
62
91
|
cancel: string;
|
|
63
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* The operation keys that led to this run, its own last. Present when the
|
|
95
|
+
* run was caused by another operation's work.
|
|
96
|
+
*/
|
|
97
|
+
causationChain?: string[];
|
|
64
98
|
}
|
|
65
99
|
/**
|
|
66
|
-
*
|
|
100
|
+
* How a run was started. The four values are the platform's own; a caller that
|
|
101
|
+
* names none is recorded as `api`.
|
|
102
|
+
*
|
|
103
|
+
* - `ui`: a console surface that names itself as the trigger, such as an app's
|
|
104
|
+
* configure page.
|
|
105
|
+
* - `api`: an `execute<Op>` call, or any caller that named no trigger.
|
|
106
|
+
* - `lifecycle`: a hook on a platform event (a record published, an operation
|
|
107
|
+
* completed).
|
|
108
|
+
* - `schedule`: a cron schedule firing.
|
|
67
109
|
*/
|
|
68
110
|
export interface OperationTrigger {
|
|
69
|
-
|
|
70
|
-
type: 'ui' | 'api' | 'lifecycle' | 'field';
|
|
71
|
-
/** Field key (for field-triggered operations) */
|
|
72
|
-
fieldKey?: string;
|
|
73
|
-
/** Field type (for field-triggered operations) */
|
|
74
|
-
fieldType?: string;
|
|
111
|
+
type: 'ui' | 'api' | 'lifecycle' | 'schedule';
|
|
75
112
|
}
|
|
76
113
|
/**
|
|
77
|
-
*
|
|
114
|
+
* The record block a caller attached to a run, forwarded as the caller wrote
|
|
115
|
+
* it. The platform does not shape it, so neither does this type.
|
|
78
116
|
*/
|
|
79
|
-
export
|
|
80
|
-
/** Record ID */
|
|
81
|
-
id: string;
|
|
82
|
-
/** Model key (e.g., 'tilly_note') */
|
|
83
|
-
modelKey: string;
|
|
84
|
-
/** Version ID (if versioned record) */
|
|
85
|
-
versionId?: string;
|
|
86
|
-
/** Record content/data */
|
|
87
|
-
data: Record<string, unknown>;
|
|
88
|
-
/** Record metadata */
|
|
89
|
-
metadata: {
|
|
90
|
-
/** Natural key (slug, handle, etc.) */
|
|
91
|
-
naturalKey?: string;
|
|
92
|
-
/** Additional metadata fields */
|
|
93
|
-
[key: string]: unknown;
|
|
94
|
-
};
|
|
95
|
-
}
|
|
117
|
+
export type OperationRecordContext = Record<string, unknown>;
|
|
96
118
|
/**
|
|
97
|
-
*
|
|
119
|
+
* The tenant, project and principal a run belongs to.
|
|
98
120
|
*/
|
|
99
121
|
export interface OperationContext {
|
|
100
122
|
/** Tenant ID */
|
|
101
123
|
tenantId: string;
|
|
102
124
|
/** Project ID */
|
|
103
125
|
projectId: string;
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
126
|
+
/**
|
|
127
|
+
* The principal that started the run. Always present, and the empty string
|
|
128
|
+
* when the run has none, as a hook's run does.
|
|
129
|
+
*/
|
|
130
|
+
userId: string;
|
|
131
|
+
/**
|
|
132
|
+
* The customer the run acts for, when it acts for one: the signed-in
|
|
133
|
+
* customer who ran it, or the owner of the record whose event fired the
|
|
134
|
+
* hook. The dispatch token carries the same customer, so a client built from
|
|
135
|
+
* it acts for them too.
|
|
136
|
+
*/
|
|
107
137
|
customerId?: string;
|
|
108
|
-
/**
|
|
109
|
-
locale?: string;
|
|
110
|
-
/** Timestamp when operation was triggered */
|
|
138
|
+
/** When the platform built this body, RFC 3339 in UTC to the second. */
|
|
111
139
|
timestamp: string;
|
|
112
140
|
}
|
|
113
141
|
/**
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* readDispatch: the body of an operation dispatch, parsed and checked.
|
|
3
|
+
*
|
|
4
|
+
* An operation endpoint is handed two things. The headers carry the dispatch
|
|
5
|
+
* token, which `requireScopedToken` verifies. The body carries the run: its id,
|
|
6
|
+
* the operation's key, how it was started, the context it runs in, and the
|
|
7
|
+
* caller's input under `input`. Reading the body as though it WERE the input is
|
|
8
|
+
* the mistake this exists to prevent, because it fails quietly: every field the
|
|
9
|
+
* endpoint looks for is absent, and the endpoint refuses every call on a
|
|
10
|
+
* missing field it was in fact sent.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const { dispatch } = await requireScopedToken(request, { expectedProjectId });
|
|
14
|
+
* const { input, context } = await readDispatch<{ email: string }>(request);
|
|
15
|
+
* const foir = createFoirClient({ apiUrl, dispatch, generated });
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The shape is the platform's `buildOperationPayload`, field for field; see
|
|
19
|
+
* `OperationPayload`. A body that is not that shape is refused with
|
|
20
|
+
* {@link OperationPayloadError}, naming what is wrong, rather than handed on
|
|
21
|
+
* half-read.
|
|
22
|
+
*
|
|
23
|
+
* What it does not do is drop fields. A key the platform adds later is kept on
|
|
24
|
+
* the returned object, untyped, because a reader that re-built the body from
|
|
25
|
+
* the fields it knows would delete whatever it did not, which is the exact way
|
|
26
|
+
* blocks of this payload have gone missing on their way to an endpoint before.
|
|
27
|
+
*/
|
|
28
|
+
import type { OperationPayload } from './operation-types';
|
|
29
|
+
/**
|
|
30
|
+
* The dispatch body was not an operation payload. `status` is the HTTP status
|
|
31
|
+
* the route should answer with: 400, since the request is malformed rather than
|
|
32
|
+
* unauthorised (the token has already been verified by the time the body is
|
|
33
|
+
* read).
|
|
34
|
+
*/
|
|
35
|
+
export declare class OperationPayloadError extends Error {
|
|
36
|
+
readonly status: 400;
|
|
37
|
+
constructor(message: string);
|
|
38
|
+
}
|
|
39
|
+
/** The minimal request shape needed: satisfied by any fetch-API Request. */
|
|
40
|
+
export interface BodyCarrier {
|
|
41
|
+
text(): Promise<string>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read the dispatch body off an operation request.
|
|
45
|
+
*
|
|
46
|
+
* `TInput` types `input`, as an assertion: the platform has already checked the
|
|
47
|
+
* input against the operation's input schema, and this is where the endpoint
|
|
48
|
+
* says what that schema is.
|
|
49
|
+
*
|
|
50
|
+
* Reads the body, so call it once per request and after nothing else has
|
|
51
|
+
* consumed it.
|
|
52
|
+
*/
|
|
53
|
+
export declare function readDispatch<TInput extends object = Record<string, unknown>>(request: BodyCarrier): Promise<OperationPayload<TInput>>;
|
|
@@ -16,6 +16,17 @@
|
|
|
16
16
|
* });
|
|
17
17
|
* ```
|
|
18
18
|
*
|
|
19
|
+
* The verified token is also the endpoint's credential for calling Foir back.
|
|
20
|
+
* `dispatch` on the result is the token as a data-client credential, so the
|
|
21
|
+
* endpoint acts as the operation, with the capabilities the operation
|
|
22
|
+
* declared, and holds no key of its own. `generated` is the module
|
|
23
|
+
* `foir types --operation <key>` wrote for the operation:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* const { dispatch } = await requireScopedToken(request, { expectedProjectId });
|
|
27
|
+
* const foir = createFoirClient({ apiUrl: env.PLATFORM_API_URL, dispatch, generated });
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
19
30
|
* Throws {@link ScopedTokenError} with `status` 401 (missing/invalid token)
|
|
20
31
|
* or 403 (valid token for the wrong tenant/project) — map it to a response
|
|
21
32
|
* in one place:
|
|
@@ -30,6 +41,7 @@
|
|
|
30
41
|
* ```
|
|
31
42
|
*/
|
|
32
43
|
import type { ScopedTokenClaims, ScopedTokenSubject, VerifyScopedTokenOptions } from './verify-scoped-token';
|
|
44
|
+
import type { FoirDispatchCredential } from '../data/transport';
|
|
33
45
|
/**
|
|
34
46
|
* Verification failure with the HTTP status the route should answer with:
|
|
35
47
|
* 401 for a missing or invalid token, 403 for a valid token bound to the
|
|
@@ -66,6 +78,13 @@ export interface ScopedTokenIngress {
|
|
|
66
78
|
subject: ScopedTokenSubject;
|
|
67
79
|
/** Parsed `X-Foir-Context` correlation metadata (empty when absent). */
|
|
68
80
|
context: Record<string, string>;
|
|
81
|
+
/**
|
|
82
|
+
* The token this call verified, as the credential for a data client that
|
|
83
|
+
* calls Foir AS the operation:
|
|
84
|
+
* `createFoirClient({ apiUrl, dispatch, generated })`. It sends the token as
|
|
85
|
+
* `X-Foir-Token` with the dispatch's `X-Foir-Context`, and never an api key.
|
|
86
|
+
*/
|
|
87
|
+
dispatch: FoirDispatchCredential;
|
|
69
88
|
}
|
|
70
89
|
/**
|
|
71
90
|
* Verify the platform-minted scoped token on an inbound dispatch request.
|
package/dist/server.js
CHANGED
|
@@ -238,13 +238,93 @@ async function requireScopedToken(request, options) {
|
|
|
238
238
|
403
|
|
239
239
|
);
|
|
240
240
|
}
|
|
241
|
+
const rawContext = request.headers.get(CONTEXT_HEADER);
|
|
241
242
|
return {
|
|
242
243
|
claims,
|
|
243
244
|
subject,
|
|
244
|
-
context: parseContextHeader(
|
|
245
|
+
context: parseContextHeader(rawContext),
|
|
246
|
+
// The token handed over is the one verified above, not a fresh read of the
|
|
247
|
+
// header, so the client presents exactly what this guard accepted.
|
|
248
|
+
dispatch: { token: () => token, context: rawContext }
|
|
245
249
|
};
|
|
246
250
|
}
|
|
247
251
|
|
|
252
|
+
// src/server/read-dispatch.ts
|
|
253
|
+
var OperationPayloadError = class extends Error {
|
|
254
|
+
status;
|
|
255
|
+
constructor(message) {
|
|
256
|
+
super(message);
|
|
257
|
+
this.name = "OperationPayloadError";
|
|
258
|
+
this.status = 400;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
262
|
+
function refuse(message) {
|
|
263
|
+
throw new OperationPayloadError(`[foir] not an operation dispatch body: ${message}`);
|
|
264
|
+
}
|
|
265
|
+
function requireString(from, key, where, allowEmpty = false) {
|
|
266
|
+
const value = from[key];
|
|
267
|
+
if (typeof value !== "string" || !allowEmpty && value === "") {
|
|
268
|
+
refuse(`${where}.${key} is ${describe(value)}, expected a${allowEmpty ? "" : " non-empty"} string`);
|
|
269
|
+
}
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
function describe(value) {
|
|
273
|
+
if (value === void 0) return "missing";
|
|
274
|
+
if (value === null) return "null";
|
|
275
|
+
if (Array.isArray(value)) return "an array";
|
|
276
|
+
return `a ${typeof value}`;
|
|
277
|
+
}
|
|
278
|
+
async function readDispatch(request) {
|
|
279
|
+
let text;
|
|
280
|
+
try {
|
|
281
|
+
text = await request.text();
|
|
282
|
+
} catch (err) {
|
|
283
|
+
throw new OperationPayloadError(
|
|
284
|
+
`[foir] could not read the dispatch body (has something already consumed it?): ${err instanceof Error ? err.message : String(err)}`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
let body;
|
|
288
|
+
try {
|
|
289
|
+
body = JSON.parse(text);
|
|
290
|
+
} catch {
|
|
291
|
+
refuse("the body is not JSON");
|
|
292
|
+
}
|
|
293
|
+
if (!isObject(body)) refuse(`the body is ${describe(body)}, expected a JSON object`);
|
|
294
|
+
if (typeof body.executionId !== "string" || typeof body.operationKey !== "string") {
|
|
295
|
+
refuse(
|
|
296
|
+
"it has no executionId and operationKey. The platform sends the run, not the input: the caller's input is under `input`"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
requireString(body, "executionId", "body");
|
|
300
|
+
requireString(body, "operationKey", "body");
|
|
301
|
+
const trigger = body.trigger;
|
|
302
|
+
if (!isObject(trigger)) refuse(`body.trigger is ${describe(trigger)}, expected an object`);
|
|
303
|
+
requireString(trigger, "type", "body.trigger");
|
|
304
|
+
const context = body.context;
|
|
305
|
+
if (!isObject(context)) refuse(`body.context is ${describe(context)}, expected an object`);
|
|
306
|
+
requireString(context, "tenantId", "body.context");
|
|
307
|
+
requireString(context, "projectId", "body.context");
|
|
308
|
+
requireString(context, "userId", "body.context", true);
|
|
309
|
+
requireString(context, "timestamp", "body.context");
|
|
310
|
+
if (context.customerId !== void 0) requireString(context, "customerId", "body.context");
|
|
311
|
+
const input = body.input;
|
|
312
|
+
if (input !== null && !isObject(input)) {
|
|
313
|
+
refuse(`body.input is ${describe(input)}, expected an object`);
|
|
314
|
+
}
|
|
315
|
+
const payload = { ...body, input: input ?? {} };
|
|
316
|
+
for (const block of ["content", "record", "triggerContext", "callback"]) {
|
|
317
|
+
const value = payload[block];
|
|
318
|
+
if (value === void 0) continue;
|
|
319
|
+
if (value === null) {
|
|
320
|
+
delete payload[block];
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (!isObject(value)) refuse(`body.${block} is ${describe(value)}, expected an object`);
|
|
324
|
+
}
|
|
325
|
+
return payload;
|
|
326
|
+
}
|
|
327
|
+
|
|
248
328
|
// src/server/callback-client.ts
|
|
249
329
|
function createCallbackClient(callback, opts) {
|
|
250
330
|
const { executionId, signingSecret } = opts;
|
|
@@ -428,19 +508,46 @@ function readFoirRetryAfterMs(headers) {
|
|
|
428
508
|
const seconds = Number(headers.get("Retry-After"));
|
|
429
509
|
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
430
510
|
}
|
|
511
|
+
var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
512
|
+
"x-api-key",
|
|
513
|
+
"authorization",
|
|
514
|
+
"x-foir-token",
|
|
515
|
+
"x-foir-context"
|
|
516
|
+
]);
|
|
517
|
+
function refuseCredentialHeaders(extra) {
|
|
518
|
+
if (!extra) return;
|
|
519
|
+
for (const name of Object.keys(extra)) {
|
|
520
|
+
if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
521
|
+
throw new FoirRequestError(
|
|
522
|
+
`[foir] a client built from a dispatch token cannot send "${name}" in \`headers\`. The dispatch token is the credential, and an api key or a bearer beside it would make the request two actors at once. Build a separate client for the other credential.`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
431
527
|
function buildFoirHeaders(input) {
|
|
432
528
|
const headers = {
|
|
433
|
-
"Content-Type": "application/json"
|
|
434
|
-
"x-api-key": input.apiKey
|
|
529
|
+
"Content-Type": "application/json"
|
|
435
530
|
};
|
|
436
|
-
if (input.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
headers["
|
|
531
|
+
if (input.dispatchToken !== void 0) {
|
|
532
|
+
refuseCredentialHeaders(input.extra);
|
|
533
|
+
headers["X-Foir-Token"] = input.dispatchToken;
|
|
534
|
+
if (input.dispatchContext) {
|
|
535
|
+
headers["X-Foir-Context"] = input.dispatchContext;
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
headers["x-api-key"] = input.apiKey;
|
|
539
|
+
if (input.draftChannel) {
|
|
540
|
+
headers["X-Foir-Schema-Channel"] = "draft";
|
|
541
|
+
}
|
|
542
|
+
if (input.bearerToken) {
|
|
543
|
+
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
544
|
+
}
|
|
545
|
+
if (input.orgId) {
|
|
546
|
+
headers["X-Org-Id"] = input.orgId;
|
|
547
|
+
}
|
|
548
|
+
if (input.orgUrl) {
|
|
549
|
+
headers["X-Org-Url"] = input.orgUrl;
|
|
550
|
+
}
|
|
444
551
|
}
|
|
445
552
|
if (input.extra) {
|
|
446
553
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -463,6 +570,38 @@ async function resolveBearerToken(config) {
|
|
|
463
570
|
if (typeof source === "function") return await source() ?? void 0;
|
|
464
571
|
return source ?? void 0;
|
|
465
572
|
}
|
|
573
|
+
async function resolveDispatchToken(dispatch) {
|
|
574
|
+
const token = (await dispatch.token())?.trim();
|
|
575
|
+
if (!token) {
|
|
576
|
+
throw new FoirRequestError(
|
|
577
|
+
"[foir] the dispatch token provider returned no token. A client built from a dispatch calls Foir as that operation and holds no other credential. Build it from the token the endpoint was handed: `(await requireScopedToken(request)).dispatch`."
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
return token;
|
|
581
|
+
}
|
|
582
|
+
async function resolveFoirAuth(config) {
|
|
583
|
+
if (config.dispatch !== void 0) {
|
|
584
|
+
return {
|
|
585
|
+
headers: buildFoirHeaders({
|
|
586
|
+
dispatchToken: await resolveDispatchToken(config.dispatch),
|
|
587
|
+
dispatchContext: config.dispatch.context ?? void 0,
|
|
588
|
+
extra: config.headers
|
|
589
|
+
})
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const bearerToken = await resolveBearerToken(config);
|
|
593
|
+
return {
|
|
594
|
+
headers: buildFoirHeaders({
|
|
595
|
+
apiKey: config.apiKey,
|
|
596
|
+
draftChannel: config.channel === "draft",
|
|
597
|
+
bearerToken,
|
|
598
|
+
orgId: config.orgId,
|
|
599
|
+
orgUrl: config.orgUrl,
|
|
600
|
+
extra: config.headers
|
|
601
|
+
}),
|
|
602
|
+
key: { apiKey: config.apiKey, bearerToken }
|
|
603
|
+
};
|
|
604
|
+
}
|
|
466
605
|
|
|
467
606
|
// src/data/ws-transport.ts
|
|
468
607
|
var GQL = {
|
|
@@ -500,6 +639,11 @@ var FoirSocketTransport = class {
|
|
|
500
639
|
keepAlive = null;
|
|
501
640
|
retryTimer = null;
|
|
502
641
|
constructor(config) {
|
|
642
|
+
if (config.dispatch !== void 0) {
|
|
643
|
+
throw new FoirRequestError(
|
|
644
|
+
"[foir] a client built from a dispatch token cannot subscribe. The subscription socket authenticates an api key, an admin token or a customer bearer, and has no path for a dispatch token. Queries and mutations made as the operation go to /graphql and work as they are."
|
|
645
|
+
);
|
|
646
|
+
}
|
|
503
647
|
this.config = config;
|
|
504
648
|
this.policy = { ...DEFAULT_RECONNECT, ...config.reconnect ?? {} };
|
|
505
649
|
}
|
|
@@ -571,13 +715,7 @@ var FoirSocketTransport = class {
|
|
|
571
715
|
async sendConnectionInit(socket) {
|
|
572
716
|
this.connecting = false;
|
|
573
717
|
try {
|
|
574
|
-
const headers =
|
|
575
|
-
apiKey: this.config.apiKey,
|
|
576
|
-
draftChannel: this.config.channel === "draft",
|
|
577
|
-
bearerToken: await resolveBearerToken(this.config),
|
|
578
|
-
orgId: this.config.orgId,
|
|
579
|
-
extra: this.config.headers
|
|
580
|
-
});
|
|
718
|
+
const { headers } = await resolveFoirAuth(this.config);
|
|
581
719
|
const { "Content-Type": _contentType, ...params } = headers;
|
|
582
720
|
const extra = await this.config.connectionParams?.() ?? {};
|
|
583
721
|
this.send(socket, {
|
|
@@ -684,7 +822,7 @@ var FoirSocketTransport = class {
|
|
|
684
822
|
return;
|
|
685
823
|
}
|
|
686
824
|
case GQL.error: {
|
|
687
|
-
const error = new FoirRequestError(`[foir] subscription refused: ${
|
|
825
|
+
const error = new FoirRequestError(`[foir] subscription refused: ${describe2(message.payload)}`, {
|
|
688
826
|
graphQLErrors: graphQLErrorItems(message.payload)
|
|
689
827
|
});
|
|
690
828
|
if (!message.id) {
|
|
@@ -805,17 +943,17 @@ function graphQLErrorItems(payload) {
|
|
|
805
943
|
const { message, path, extensions } = entry;
|
|
806
944
|
if (extensions === void 0 && path === void 0) continue;
|
|
807
945
|
items.push({
|
|
808
|
-
message: typeof message === "string" ? message :
|
|
946
|
+
message: typeof message === "string" ? message : describe2(entry),
|
|
809
947
|
...Array.isArray(path) ? { path } : {},
|
|
810
948
|
...typeof extensions === "object" && extensions !== null ? { extensions } : {}
|
|
811
949
|
});
|
|
812
950
|
}
|
|
813
951
|
return items.length > 0 ? items : void 0;
|
|
814
952
|
}
|
|
815
|
-
function
|
|
953
|
+
function describe2(payload) {
|
|
816
954
|
if (payload == null) return "no reason given";
|
|
817
955
|
if (typeof payload === "string") return payload;
|
|
818
|
-
if (Array.isArray(payload)) return payload.map(
|
|
956
|
+
if (Array.isArray(payload)) return payload.map(describe2).join("; ");
|
|
819
957
|
if (typeof payload === "object") {
|
|
820
958
|
const message = payload.message;
|
|
821
959
|
if (typeof message === "string") return message;
|
|
@@ -1089,6 +1227,7 @@ export {
|
|
|
1089
1227
|
FOIR_EVENTS_SCOPE,
|
|
1090
1228
|
FoirEventCursorExpiredError,
|
|
1091
1229
|
FoirRequestError,
|
|
1230
|
+
OperationPayloadError,
|
|
1092
1231
|
ScopedTokenError,
|
|
1093
1232
|
ServerClientError,
|
|
1094
1233
|
createCallbackClient,
|
|
@@ -1101,6 +1240,7 @@ export {
|
|
|
1101
1240
|
isFoirUnauthenticated,
|
|
1102
1241
|
parseContextHeader,
|
|
1103
1242
|
parseSubject,
|
|
1243
|
+
readDispatch,
|
|
1104
1244
|
recordsReadModels,
|
|
1105
1245
|
recordsWriteModels,
|
|
1106
1246
|
requireScopedToken,
|
|
@@ -30,16 +30,17 @@ export interface FoirEnv {
|
|
|
30
30
|
* Returns true when `FOIR_PREVIEW=true` is set in the environment.
|
|
31
31
|
*
|
|
32
32
|
* Preview is selected at the environment level, not per-request: load a
|
|
33
|
-
* preview env file that sets `FOIR_PREVIEW=true` +
|
|
34
|
-
* `
|
|
33
|
+
* preview env file that sets `FOIR_PREVIEW=true` + the preview environment's
|
|
34
|
+
* own `pk_…` key holding `drafts:read` as `PLATFORM_PREVIEW_API_KEY`, instead
|
|
35
|
+
* of the default env.
|
|
35
36
|
* The site-wide mode keeps draft semantics consistent — every loader sees
|
|
36
37
|
* the same preview/published view of the world. Intended for non-production
|
|
37
38
|
* surfaces only.
|
|
38
39
|
*/
|
|
39
40
|
export declare function isFoirPreview(env: FoirEnv): boolean;
|
|
40
41
|
/**
|
|
41
|
-
* The api key a request should carry for this env: the
|
|
42
|
-
* `FOIR_PREVIEW=true` (it is the one with `drafts:read`), the
|
|
42
|
+
* The api key a request should carry for this env: the preview key when
|
|
43
|
+
* `FOIR_PREVIEW=true` (it is the one with `drafts:read`), the production key
|
|
43
44
|
* otherwise. Returns `undefined` when neither is configured, so the caller can
|
|
44
45
|
* raise the "add them to .env" error with its own wording.
|
|
45
46
|
*/
|
|
@@ -230,6 +231,12 @@ export interface FoirRequestOptions {
|
|
|
230
231
|
* covers what the request is about.
|
|
231
232
|
*/
|
|
232
233
|
orgId?: string;
|
|
234
|
+
/**
|
|
235
|
+
* The organization this request acts in, named by the address of the page it
|
|
236
|
+
* is for, sent as `X-Org-Url`. See `FoirClientConfig.orgUrl` for the full
|
|
237
|
+
* contract. With `orgId` as well, the two must name the same organization.
|
|
238
|
+
*/
|
|
239
|
+
orgUrl?: string;
|
|
233
240
|
/**
|
|
234
241
|
* Called with the `X-RateLimit-*` headers of every response, successful or
|
|
235
242
|
* not, and with `undefined` when a response carried none.
|
|
@@ -252,7 +259,8 @@ export interface FoirRequestOptions {
|
|
|
252
259
|
* they would end up accepting subtly different things.
|
|
253
260
|
*/
|
|
254
261
|
export type FoirTokenProvider = () => string | null | undefined | Promise<string | null | undefined>;
|
|
255
|
-
|
|
262
|
+
/** A request made with an api key, optionally for a signed-in customer. */
|
|
263
|
+
export interface FoirKeyHeaderInput {
|
|
256
264
|
apiKey: string;
|
|
257
265
|
/**
|
|
258
266
|
* Opt into the draft *schema* channel (`X-Foir-Schema-Channel: draft`) so
|
|
@@ -270,14 +278,56 @@ export interface FoirHeaderInput {
|
|
|
270
278
|
* spells the header in `extra` still wins, because `extra` is applied last.
|
|
271
279
|
*/
|
|
272
280
|
orgId?: string;
|
|
281
|
+
/**
|
|
282
|
+
* The organization the request acts in, named by a page URL (`X-Org-Url`).
|
|
283
|
+
* Set from `FoirClientConfig.orgUrl` / `FoirRequestOptions.orgUrl`.
|
|
284
|
+
*/
|
|
285
|
+
orgUrl?: string;
|
|
286
|
+
dispatchToken?: never;
|
|
287
|
+
dispatchContext?: never;
|
|
273
288
|
/** Caller-supplied extras, applied last so a caller can override. */
|
|
274
289
|
extra?: Readonly<Record<string, string>>;
|
|
275
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* A request made AS an operation, with the dispatch token its endpoint was
|
|
293
|
+
* handed.
|
|
294
|
+
*
|
|
295
|
+
* The token is the whole credential. api-public takes the scoped path when
|
|
296
|
+
* `X-Foir-Token` is present and never reads `x-api-key` on it, and the
|
|
297
|
+
* organization, the customer and the schema channel all come from the token's
|
|
298
|
+
* own claims, so none of the key-side inputs has anything to say here.
|
|
299
|
+
*/
|
|
300
|
+
export interface FoirDispatchHeaderInput {
|
|
301
|
+
/** Sent as `X-Foir-Token`. */
|
|
302
|
+
dispatchToken: string;
|
|
303
|
+
/** The dispatch's `X-Foir-Context`, forwarded as it arrived. */
|
|
304
|
+
dispatchContext?: string;
|
|
305
|
+
apiKey?: never;
|
|
306
|
+
draftChannel?: never;
|
|
307
|
+
bearerToken?: never;
|
|
308
|
+
orgId?: never;
|
|
309
|
+
orgUrl?: never;
|
|
310
|
+
/**
|
|
311
|
+
* Caller-supplied extras, applied last. They may not name a credential
|
|
312
|
+
* header: see {@link refuseCredentialHeaders}.
|
|
313
|
+
*/
|
|
314
|
+
extra?: Readonly<Record<string, string>>;
|
|
315
|
+
}
|
|
316
|
+
export type FoirHeaderInput = FoirKeyHeaderInput | FoirDispatchHeaderInput;
|
|
317
|
+
/**
|
|
318
|
+
* Refuse extra headers that would add a second credential to a request made as
|
|
319
|
+
* an operation. Throws naming the header; returns nothing when there is none.
|
|
320
|
+
*/
|
|
321
|
+
export declare function refuseCredentialHeaders(extra: Readonly<Record<string, string>> | undefined): void;
|
|
276
322
|
/**
|
|
277
323
|
* The exact header set every Foir data-plane request carries. Single
|
|
278
324
|
* definition on purpose: `/graphql` and `/cdn/graphql` authenticate identically,
|
|
279
325
|
* and a header that exists on one surface but not the other is a per-surface
|
|
280
326
|
* authorization difference nobody would notice until it mattered.
|
|
327
|
+
*
|
|
328
|
+
* Two credentials, and a request carries exactly one: an api key (with its
|
|
329
|
+
* optional customer bearer, organization and channel), or an operation's
|
|
330
|
+
* dispatch token. A dispatch request never writes `x-api-key`.
|
|
281
331
|
*/
|
|
282
332
|
export declare function buildFoirHeaders(input: FoirHeaderInput): Record<string, string>;
|
|
283
333
|
/**
|