@foir/sdk 0.11.0 → 0.12.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 +131 -2
- package/dist/app/client.d.ts +371 -0
- package/dist/app/contract/index.d.ts +23 -0
- package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
- package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
- package/dist/app/dispatch.d.ts +64 -0
- package/dist/app/errors.d.ts +50 -0
- package/dist/app/index.d.ts +40 -0
- package/dist/app/manifest.d.ts +56 -0
- package/dist/app/naming.d.ts +31 -0
- package/dist/app/record-key.d.ts +17 -0
- package/dist/app/ticket.d.ts +37 -0
- package/dist/app/vocabulary.generated.d.ts +20 -0
- package/dist/app-contract.js +124 -0
- package/dist/app.js +998 -0
- package/dist/auth/auth.d.ts +24 -6
- package/dist/auth/index.d.ts +2 -1
- package/dist/auth.js +139 -23
- package/dist/client.js +36 -2
- package/dist/data/index.d.ts +2 -2
- package/dist/data/transport.d.ts +34 -2
- package/dist/data.js +77 -2
- package/dist/graphql/client.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/next.js +70 -2
- package/dist/placement/index.d.ts +129 -0
- package/dist/placement.js +144 -0
- package/dist/push.js +121 -13
- package/dist/react.js +70 -2
- package/dist/server/callback-client.d.ts +30 -9
- package/dist/server/continue-run.d.ts +56 -0
- package/dist/server/index.d.ts +5 -3
- package/dist/server/operation-types.d.ts +28 -7
- package/dist/server/require-scoped-token.d.ts +52 -7
- package/dist/server/verify-scoped-token.d.ts +109 -3
- package/dist/server.js +415 -127
- package/dist/transport/foir-http.d.ts +48 -0
- package/package.json +24 -6
package/dist/react.js
CHANGED
|
@@ -81,7 +81,12 @@ var FoirRequestError = class extends Error {
|
|
|
81
81
|
* failed on the first attempt for an hour.
|
|
82
82
|
*/
|
|
83
83
|
get retryable() {
|
|
84
|
-
if (this.status === void 0)
|
|
84
|
+
if (this.status === void 0) {
|
|
85
|
+
if (this.graphQLErrors?.length || this.prunedSelections?.length) {
|
|
86
|
+
return this.code === "RATE_LIMITED" || this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
85
90
|
if (this.status === 429 || this.code === "RATE_LIMITED") return true;
|
|
86
91
|
if (this.status >= 500) return true;
|
|
87
92
|
return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
|
|
@@ -111,6 +116,7 @@ function refuseCredentialHeaders(extra) {
|
|
|
111
116
|
}
|
|
112
117
|
}
|
|
113
118
|
}
|
|
119
|
+
var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
|
|
114
120
|
function buildFoirHeaders(input) {
|
|
115
121
|
const headers = {
|
|
116
122
|
"Content-Type": "application/json"
|
|
@@ -135,6 +141,9 @@ function buildFoirHeaders(input) {
|
|
|
135
141
|
if (input.orgUrl) {
|
|
136
142
|
headers["X-Org-Url"] = input.orgUrl;
|
|
137
143
|
}
|
|
144
|
+
if (input.visitorAddress) {
|
|
145
|
+
headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
|
|
146
|
+
}
|
|
138
147
|
}
|
|
139
148
|
if (input.extra) {
|
|
140
149
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -150,6 +159,26 @@ function foirApiEndpoint(apiUrl, path) {
|
|
|
150
159
|
}
|
|
151
160
|
return base + path;
|
|
152
161
|
}
|
|
162
|
+
var FoirAuthDowngradedError = class extends FoirRequestError {
|
|
163
|
+
downgrade;
|
|
164
|
+
constructor(downgrade) {
|
|
165
|
+
super(
|
|
166
|
+
`[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}), so the request was served as ${downgrade.servedAs}`,
|
|
167
|
+
{ graphQLErrors: [{ message: "customer token rejected", extensions: { code: "AUTH_DOWNGRADED" } }] }
|
|
168
|
+
);
|
|
169
|
+
this.name = "FoirAuthDowngradedError";
|
|
170
|
+
this.downgrade = downgrade;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
var FoirSessionExpiredError = class extends FoirRequestError {
|
|
174
|
+
constructor(options) {
|
|
175
|
+
super("[foir] the customer session has expired and could not be refreshed: sign in again", {
|
|
176
|
+
cause: options?.cause,
|
|
177
|
+
graphQLErrors: [{ message: "session expired", extensions: { code: "SESSION_EXPIRED" } }]
|
|
178
|
+
});
|
|
179
|
+
this.name = "FoirSessionExpiredError";
|
|
180
|
+
}
|
|
181
|
+
};
|
|
153
182
|
var warnedDowngrade = null;
|
|
154
183
|
function reportAuthDowngrade(envelope) {
|
|
155
184
|
const downgrade = envelope.extensions?.auth;
|
|
@@ -182,6 +211,9 @@ function answeredAnything(data) {
|
|
|
182
211
|
return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
|
|
183
212
|
}
|
|
184
213
|
function readFoirEnvelope(json, options) {
|
|
214
|
+
if (options?.rejectDowngrade && json.extensions?.auth) {
|
|
215
|
+
throw new FoirAuthDowngradedError(json.extensions.auth);
|
|
216
|
+
}
|
|
185
217
|
reportAuthDowngrade(json);
|
|
186
218
|
refusePrunedDocument(json, options);
|
|
187
219
|
if (json.errors?.length) {
|
|
@@ -700,6 +732,12 @@ function operationName(scope, op) {
|
|
|
700
732
|
function foirDataEndpoint(apiUrl, path) {
|
|
701
733
|
return foirApiEndpoint(apiUrl, path);
|
|
702
734
|
}
|
|
735
|
+
async function resolveVisitorAddress(config) {
|
|
736
|
+
const source = config.visitor;
|
|
737
|
+
const value = typeof source === "function" ? await source() : source;
|
|
738
|
+
const address = value?.trim();
|
|
739
|
+
return address ? address : void 0;
|
|
740
|
+
}
|
|
703
741
|
async function resolveBearerToken(config) {
|
|
704
742
|
const source = config.bearerToken;
|
|
705
743
|
if (typeof source === "function") return await source() ?? void 0;
|
|
@@ -732,6 +770,7 @@ async function resolveFoirAuth(config) {
|
|
|
732
770
|
bearerToken,
|
|
733
771
|
orgId: config.orgId,
|
|
734
772
|
orgUrl: config.orgUrl,
|
|
773
|
+
visitorAddress: await resolveVisitorAddress(config),
|
|
735
774
|
extra: config.headers
|
|
736
775
|
}),
|
|
737
776
|
key: { apiKey: config.apiKey, bearerToken }
|
|
@@ -755,7 +794,33 @@ async function executeFoirDocument(config, plan) {
|
|
|
755
794
|
}
|
|
756
795
|
return data[plan.rootField];
|
|
757
796
|
}
|
|
797
|
+
var renewing = /* @__PURE__ */ new WeakMap();
|
|
798
|
+
function renewBearerOnce(refresh) {
|
|
799
|
+
const running = renewing.get(refresh);
|
|
800
|
+
if (running) return running;
|
|
801
|
+
const next = refresh().finally(() => renewing.delete(refresh));
|
|
802
|
+
renewing.set(refresh, next);
|
|
803
|
+
return next;
|
|
804
|
+
}
|
|
758
805
|
async function runFoirDocument(config, document2, mutation, variables, options) {
|
|
806
|
+
try {
|
|
807
|
+
return await sendFoirDocument(config, document2, mutation, variables, options);
|
|
808
|
+
} catch (err) {
|
|
809
|
+
const refresh = config.refreshBearer;
|
|
810
|
+
if (!(err instanceof FoirAuthDowngradedError) || !refresh || err.downgrade.reason === "wrong_project") {
|
|
811
|
+
throw err;
|
|
812
|
+
}
|
|
813
|
+
const token = err.downgrade.reason === "expired" ? await renewBearerOnce(refresh) : null;
|
|
814
|
+
if (!token) throw new FoirSessionExpiredError({ cause: err });
|
|
815
|
+
try {
|
|
816
|
+
return await sendFoirDocument({ ...config, bearerToken: token }, document2, mutation, variables, options);
|
|
817
|
+
} catch (again) {
|
|
818
|
+
if (again instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: again });
|
|
819
|
+
throw again;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
async function sendFoirDocument(config, document2, mutation, variables, options) {
|
|
759
824
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
760
825
|
const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
|
|
761
826
|
const { headers, key } = await resolveFoirAuth(config);
|
|
@@ -804,7 +869,7 @@ async function runFoirDocument(config, document2, mutation, variables, options)
|
|
|
804
869
|
});
|
|
805
870
|
}
|
|
806
871
|
const json = await res.json();
|
|
807
|
-
return readFoirEnvelope(json, options);
|
|
872
|
+
return readFoirEnvelope(json, { ...options, rejectDowngrade: Boolean(key?.bearerToken) });
|
|
808
873
|
}
|
|
809
874
|
async function readErrorBody(res) {
|
|
810
875
|
try {
|
|
@@ -970,6 +1035,9 @@ async function getAuthProviders(env) {
|
|
|
970
1035
|
async function saveSession(adapter, result) {
|
|
971
1036
|
await adapter.set(TOKEN_KEY, result.token);
|
|
972
1037
|
await adapter.set(REFRESH_KEY, result.refreshToken);
|
|
1038
|
+
if (!result.customerId) {
|
|
1039
|
+
return await getSessionCustomer(adapter) ?? { id: "", email: "" };
|
|
1040
|
+
}
|
|
973
1041
|
await adapter.set(CUSTOMER_ID_KEY, result.customerId);
|
|
974
1042
|
await adapter.set(EMAIL_KEY, result.email);
|
|
975
1043
|
return { id: result.customerId, email: result.email };
|
|
@@ -17,14 +17,21 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Each call carries:
|
|
19
19
|
* - `Authorization: Bearer <callback.token>` — Ed25519 scoped token.
|
|
20
|
-
* - `X-Foir-Signature: sha256=<hex>` — HMAC of the raw request body
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* - `X-Foir-Signature: sha256=<hex>` — HMAC of the raw request body, keyed
|
|
21
|
+
* on the run's own key (`callback.signingKey`), which the platform makes
|
|
22
|
+
* for each run and sends with it. An endpoint that still holds the
|
|
23
|
+
* project's signing secret may pass it as `signingSecret` instead; the
|
|
24
|
+
* platform accepts either.
|
|
25
|
+
*
|
|
26
|
+
* A call that fails before the platform answers, or that the platform answers
|
|
27
|
+
* with a 5xx or a 429, is tried again: a report that is lost leaves the run to
|
|
28
|
+
* be closed by the platform's watchdog as timed out, and every one of these
|
|
29
|
+
* calls is safe to repeat.
|
|
24
30
|
*
|
|
25
31
|
* Both response body shape and content-type follow the GraphQL spec —
|
|
26
32
|
* extensions importing this client receive typed result data.
|
|
27
33
|
*/
|
|
34
|
+
import { type FoirRetryPolicy } from '../transport/retry';
|
|
28
35
|
import type { OperationCallback } from './operation-types.js';
|
|
29
36
|
/** Shape of a successful response from any callback mutation. */
|
|
30
37
|
export interface CallbackResult {
|
|
@@ -78,15 +85,29 @@ export interface CallbackClient<TResult = unknown> {
|
|
|
78
85
|
* `callback`; pass them together so the SDK doesn't have to re-parse the
|
|
79
86
|
* payload structure on every call.
|
|
80
87
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
88
|
+
* The signature is keyed on `callback.signingKey`, the run's own key, which
|
|
89
|
+
* the platform sends with every ASYNC dispatch. `signingSecret` is for an
|
|
90
|
+
* endpoint that holds the project's signing secret instead (`foir push`
|
|
91
|
+
* writes it to `FOIR_SIGNING_SECRET`); when it is passed it is the key used.
|
|
92
|
+
* One of the two must be there.
|
|
84
93
|
*
|
|
85
94
|
* `fetch` is overridable for testability and to support runtimes (CF
|
|
86
|
-
* Workers, Node) where `fetch` may not be the global.
|
|
95
|
+
* Workers, Node) where `fetch` may not be the global. `retry` is how a call
|
|
96
|
+
* that got no answer, a 5xx or a 429 is tried again (three attempts unless
|
|
97
|
+
* told otherwise; `{ attempts: 1 }` turns it off).
|
|
98
|
+
*
|
|
99
|
+
* `apiUrl` is the Foir API origin the endpoint is configured with. The
|
|
100
|
+
* dispatch names the address to report to and that address is the one used; a
|
|
101
|
+
* platform with no public address of its own configured names none, and the
|
|
102
|
+
* run then reports to the data API at `apiUrl`.
|
|
87
103
|
*/
|
|
88
104
|
export declare function createCallbackClient<TResult = unknown>(callback: OperationCallback, opts: {
|
|
89
105
|
executionId: string;
|
|
90
|
-
signingSecret
|
|
106
|
+
signingSecret?: string;
|
|
107
|
+
/** The Foir API origin, used when the dispatch names no address to report to. */
|
|
108
|
+
apiUrl?: string;
|
|
91
109
|
fetch?: typeof fetch;
|
|
110
|
+
retry?: FoirRetryPolicy;
|
|
111
|
+
/** Aborts a call in flight, and the wait before a retry is not started. */
|
|
112
|
+
signal?: AbortSignal;
|
|
92
113
|
}): CallbackClient<TResult>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* continueRun: answer one slice of an ASYNC run and ask to be called again.
|
|
3
|
+
*
|
|
4
|
+
* A run that takes longer than one call is cut into slices. Each dispatch does
|
|
5
|
+
* a bounded amount of work and then either answers `202 Accepted` and reports
|
|
6
|
+
* the outcome through the callback, as an ASYNC run always has, or answers a
|
|
7
|
+
* continuation: the cursor the next slice resumes from. The platform queues the
|
|
8
|
+
* next call, at once or after `delayMs`, and hands the cursor back as
|
|
9
|
+
* `continuation` on the dispatch body (`readDispatch` reads it). Each slice
|
|
10
|
+
* gets a fresh callback, so the one that finishes reports through its own.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const payload = await readDispatch(request);
|
|
14
|
+
* const page = await syncOnePage(payload.continuation?.cursor);
|
|
15
|
+
* if (page.next) return continueRun({ cursor: page.next, progressPct: page.pct });
|
|
16
|
+
* await createCallbackClient(payload.callback!, { executionId, signingSecret }).complete(page.summary);
|
|
17
|
+
* return new Response(null, { status: 202 });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Only an ASYNC run can continue: a caller waiting on a SYNC call cannot be
|
|
21
|
+
* answered by a later one, so the platform fails a SYNC call answered this
|
|
22
|
+
* way. A continuation is not a retry and does not spend the retry budget; a run
|
|
23
|
+
* takes at most 1000 slices, and one that asks for more is dead-lettered
|
|
24
|
+
* holding its last cursor, so a retry resumes from it.
|
|
25
|
+
*
|
|
26
|
+
* On the wire it is a 200 under {@link CONTINUATION_MEDIA_TYPE} with the
|
|
27
|
+
* continuation as the JSON body. The content type is what marks it, so a
|
|
28
|
+
* framework without fetch `Response` objects sends the same thing by setting
|
|
29
|
+
* that header and writing `JSON.stringify(continuation)`.
|
|
30
|
+
*/
|
|
31
|
+
/** The content type a continuation is answered under. */
|
|
32
|
+
export declare const CONTINUATION_MEDIA_TYPE = "application/vnd.foir.continuation+json";
|
|
33
|
+
/** The longest cursor the platform keeps, in bytes. */
|
|
34
|
+
export declare const MAX_CONTINUATION_CURSOR_BYTES: number;
|
|
35
|
+
/** The longest wait a continuation may ask for, in milliseconds. */
|
|
36
|
+
export declare const MAX_CONTINUATION_DELAY_MS: number;
|
|
37
|
+
/** What a slice answers when the run is not finished. */
|
|
38
|
+
export interface Continuation {
|
|
39
|
+
/** Where the next slice resumes. Opaque to the platform, never empty. */
|
|
40
|
+
cursor: string;
|
|
41
|
+
/**
|
|
42
|
+
* How long to wait before the next slice, for a slice waiting on a third
|
|
43
|
+
* party, such as an export that finishes later. At most 15 minutes.
|
|
44
|
+
*/
|
|
45
|
+
delayMs?: number;
|
|
46
|
+
/** Progress shown on the execution while it runs, 0 to 100. */
|
|
47
|
+
progressPct?: number;
|
|
48
|
+
/** A line shown beside the progress. */
|
|
49
|
+
progressMessage?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the continuation answer. Throws, before anything is sent, on a
|
|
53
|
+
* continuation the platform would refuse, so the mistake surfaces in the
|
|
54
|
+
* endpoint rather than as a failed run.
|
|
55
|
+
*/
|
|
56
|
+
export declare function continueRun(continuation: Continuation): Response;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
export { createServerClient, ServerClientError } from './server-client';
|
|
2
2
|
export { verifyWebhookSignature } from './webhook-helpers';
|
|
3
3
|
export { verifyScopedToken, parseContextHeader, parseSubject, recordsReadModels, recordsWriteModels, type ScopedTokenClaims, type ScopedTokenSubject, type VerifyScopedTokenOptions, } from './verify-scoped-token';
|
|
4
|
-
export {
|
|
4
|
+
export { createScopedTokenVerifier, ScopedTokenUnavailableError, type ScopedTokenVerifier, type ScopedTokenVerifierOptions, type ScopedTokenVerifierStats, } from './verify-scoped-token';
|
|
5
|
+
export { requireScopedToken, ScopedTokenError, FOIR_TOKEN_HEADER, FOIR_CONTEXT_HEADER, type RequireScopedTokenOptions, type ScopedTokenIngress, } from './require-scoped-token';
|
|
5
6
|
export { readDispatch, OperationPayloadError, type BodyCarrier, } from './read-dispatch';
|
|
6
7
|
export type { ServerClient, ServerClientOptions, GraphQLResponse, EditorPlacement, } from './types';
|
|
7
|
-
export type { OperationPayload, OperationTrigger, OperationRecordContext, OperationContext, OperationResult, OperationCallback, } from './operation-types';
|
|
8
|
+
export type { OperationPayload, OperationTrigger, OperationRecordContext, OperationContext, OperationResult, OperationCallback, OperationContinuationBlock, } from './operation-types';
|
|
9
|
+
export { continueRun, CONTINUATION_MEDIA_TYPE, MAX_CONTINUATION_CURSOR_BYTES, MAX_CONTINUATION_DELAY_MS, type Continuation, } from './continue-run';
|
|
8
10
|
export { createCallbackClient } from './callback-client';
|
|
9
11
|
export type { CallbackClient, CallbackResult } from './callback-client';
|
|
10
12
|
export { createEventsClient, FoirEventCursorExpiredError, isFoirEventCursorExpired, FOIR_EVENTS_PATH, FOIR_EVENTS_SCOPE, FOIR_EVENTS_RETENTION_DAYS, } from './events-client';
|
|
11
13
|
export type { FoirEvent, FoirEventActor, FoirEventPage, FoirEventRetention, FoirEventsClient, FoirEventsClientConfig, FoirEventsListParams, FoirEventsStreamParams, FoirEventsSubscribeParams, } from './events-client';
|
|
12
14
|
export type { FoirSubscription, FoirSubscriptionHandlers } from '../data/ws-transport';
|
|
13
|
-
export { FoirRequestError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, } from '../transport/foir-http';
|
|
15
|
+
export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, } from '../transport/foir-http';
|
|
14
16
|
export type { FoirErrorCode, FoirErrorExtensions, FoirGraphQLErrorItem, FoirAuthDowngrade, } from '../transport/foir-http';
|
|
@@ -67,17 +67,32 @@ export interface OperationPayload<TInput extends object = Record<string, unknown
|
|
|
67
67
|
* present to tell the two modes apart.
|
|
68
68
|
*/
|
|
69
69
|
callback?: OperationCallback;
|
|
70
|
+
/**
|
|
71
|
+
* Where this slice of a run resumes. Present when the previous slice answered
|
|
72
|
+
* with `continueRun`: the cursor it answered, and which slice this is. Slice
|
|
73
|
+
* 0 is a run a dead-letter retry resumes. Absent on a run's first call.
|
|
74
|
+
*/
|
|
75
|
+
continuation?: OperationContinuationBlock;
|
|
76
|
+
}
|
|
77
|
+
/** The dispatch body's continuation block (see `continueRun`). */
|
|
78
|
+
export interface OperationContinuationBlock {
|
|
79
|
+
/** The cursor the previous slice answered, as it wrote it. */
|
|
80
|
+
cursor: string;
|
|
81
|
+
/** Which slice of the run this is. */
|
|
82
|
+
slice: number;
|
|
70
83
|
}
|
|
71
84
|
/**
|
|
72
|
-
* Callback context the platform hands the
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* token), and the per-op mutation names so the extension doesn't need to
|
|
77
|
-
* derive them from the operation key.
|
|
85
|
+
* Callback context the platform hands the endpoint when it dispatches an
|
|
86
|
+
* ASYNC operation. Everything the endpoint needs to call back is here: the
|
|
87
|
+
* address, the token, the key this run signs with, and the per-operation
|
|
88
|
+
* mutation names, so the endpoint derives none of them from the operation key.
|
|
78
89
|
*/
|
|
79
90
|
export interface OperationCallback {
|
|
80
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* The address to report to, e.g. https://api.foir.dev/graphql. Empty when the
|
|
93
|
+
* platform that dispatched has no public address of its own configured;
|
|
94
|
+
* `createCallbackClient` then reports to the `apiUrl` it is given.
|
|
95
|
+
*/
|
|
81
96
|
gqlEndpoint: string;
|
|
82
97
|
/** Bearer token for the Authorization header (Ed25519, single execution scope) */
|
|
83
98
|
token: string;
|
|
@@ -95,6 +110,12 @@ export interface OperationCallback {
|
|
|
95
110
|
* run was caused by another operation's work.
|
|
96
111
|
*/
|
|
97
112
|
causationChain?: string[];
|
|
113
|
+
/**
|
|
114
|
+
* The key this run signs its callbacks with, made by the platform for this
|
|
115
|
+
* run alone. `createCallbackClient` signs with it, so an endpoint holds no
|
|
116
|
+
* signing secret of its own. Treat it as a secret: never log or store it.
|
|
117
|
+
*/
|
|
118
|
+
signingKey?: string;
|
|
98
119
|
}
|
|
99
120
|
/**
|
|
100
121
|
* How a run was started. The four values are the platform's own; a caller that
|
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
* const foir = createFoirClient({ apiUrl: env.PLATFORM_API_URL, dispatch, generated });
|
|
28
28
|
* ```
|
|
29
29
|
*
|
|
30
|
-
* Throws {@link ScopedTokenError} with `status` 401 (missing/invalid token)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* Throws {@link ScopedTokenError} with `status` 401 (missing/invalid token),
|
|
31
|
+
* 403 (valid token for the wrong tenant/project) or 503 (the token could not
|
|
32
|
+
* be checked, because a `verifier`'s keys could not be read) — map it to a
|
|
33
|
+
* response in one place:
|
|
33
34
|
*
|
|
34
35
|
* ```ts
|
|
35
36
|
* catch (err) {
|
|
@@ -40,16 +41,31 @@
|
|
|
40
41
|
* }
|
|
41
42
|
* ```
|
|
42
43
|
*/
|
|
43
|
-
import type { ScopedTokenClaims, ScopedTokenSubject, VerifyScopedTokenOptions } from './verify-scoped-token';
|
|
44
|
+
import type { ScopedTokenClaims, ScopedTokenSubject, ScopedTokenVerifier, VerifyScopedTokenOptions } from './verify-scoped-token';
|
|
44
45
|
import type { FoirDispatchCredential } from '../data/transport';
|
|
46
|
+
/** The header the platform sends a dispatch's token in. */
|
|
47
|
+
export declare const FOIR_TOKEN_HEADER = "X-Foir-Token";
|
|
48
|
+
/** The header carrying a dispatch's context: which operation, how it was triggered, the run's id. */
|
|
49
|
+
export declare const FOIR_CONTEXT_HEADER = "X-Foir-Context";
|
|
45
50
|
/**
|
|
46
51
|
* Verification failure with the HTTP status the route should answer with:
|
|
47
52
|
* 401 for a missing or invalid token, 403 for a valid token bound to the
|
|
48
|
-
* wrong tenant or project
|
|
53
|
+
* wrong tenant or project, and 503 when the token could not be checked at all
|
|
54
|
+
* (a `verifier` whose keys could not be read and which holds none). The 503
|
|
55
|
+
* matters: the platform retries it, where a 401 fails the run for this
|
|
56
|
+
* server's own outage.
|
|
49
57
|
*/
|
|
50
58
|
export declare class ScopedTokenError extends Error {
|
|
51
|
-
readonly status: 401 | 403;
|
|
52
|
-
|
|
59
|
+
readonly status: 401 | 403 | 503;
|
|
60
|
+
/**
|
|
61
|
+
* Why, for a route that answers the cases differently: `missing` (the
|
|
62
|
+
* request carried no token at all, so it did not come from the platform),
|
|
63
|
+
* `invalid` (a token that does not verify), `forbidden` (a valid token for
|
|
64
|
+
* another tenant or project) and `unavailable` (the token could not be
|
|
65
|
+
* checked).
|
|
66
|
+
*/
|
|
67
|
+
readonly reason: 'missing' | 'invalid' | 'forbidden' | 'unavailable';
|
|
68
|
+
constructor(message: string, status: 401 | 403 | 503, reason?: 'missing' | 'invalid' | 'forbidden' | 'unavailable');
|
|
53
69
|
}
|
|
54
70
|
/** The minimal request shape needed — satisfied by any fetch-API Request. */
|
|
55
71
|
export interface HeaderCarrier {
|
|
@@ -72,6 +88,13 @@ export interface RequireScopedTokenOptions extends VerifyScopedTokenOptions {
|
|
|
72
88
|
expectedProjectId?: string;
|
|
73
89
|
/** Tenant analogue of {@link expectedProjectId}. */
|
|
74
90
|
expectedTenantId?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Verify with this verifier's own key cache instead of fetching `jwksUrl`
|
|
93
|
+
* per isolate: see `createScopedTokenVerifier`. For a server that stays up
|
|
94
|
+
* and has to keep answering through a JWKS outage. `jwksUrl` and
|
|
95
|
+
* `expectedIssuer` are then the verifier's.
|
|
96
|
+
*/
|
|
97
|
+
verifier?: ScopedTokenVerifier;
|
|
75
98
|
}
|
|
76
99
|
export interface ScopedTokenIngress {
|
|
77
100
|
claims: ScopedTokenClaims;
|
|
@@ -85,6 +108,28 @@ export interface ScopedTokenIngress {
|
|
|
85
108
|
* `X-Foir-Token` with the dispatch's `X-Foir-Context`, and never an api key.
|
|
86
109
|
*/
|
|
87
110
|
dispatch: FoirDispatchCredential;
|
|
111
|
+
/**
|
|
112
|
+
* The organization the token acts at, as the platform signed it: the id of
|
|
113
|
+
* one organization, or the empty string for the project itself. `null` when
|
|
114
|
+
* the token states no level at all.
|
|
115
|
+
*
|
|
116
|
+
* An app installed at two organizations of one project is dispatched once
|
|
117
|
+
* for each, and this is what tells the two apart: name it, with the project,
|
|
118
|
+
* in anything you keep about one install. Treat `null` as a token you cannot
|
|
119
|
+
* place rather than as the project.
|
|
120
|
+
*/
|
|
121
|
+
orgId: string | null;
|
|
122
|
+
/**
|
|
123
|
+
* The customer the run acts for, when it acts for one: the signed-in
|
|
124
|
+
* customer who ran the operation, or the owner of the record whose event
|
|
125
|
+
* fired it. Verified with the rest of the token, so it is the one to trust
|
|
126
|
+
* over anything in the input.
|
|
127
|
+
*/
|
|
128
|
+
customerId?: string;
|
|
129
|
+
/** When the token stops being accepted. There is no refresh: finish, or report, before it. */
|
|
130
|
+
expiresAt: Date;
|
|
131
|
+
/** The token's own id (`jti`). The run's id on a dispatch that carries no other. */
|
|
132
|
+
tokenId: string;
|
|
88
133
|
}
|
|
89
134
|
/**
|
|
90
135
|
* Verify the platform-minted scoped token on an inbound dispatch request.
|
|
@@ -44,17 +44,31 @@ export interface ScopedTokenClaims {
|
|
|
44
44
|
* a record write for a specific model.
|
|
45
45
|
*/
|
|
46
46
|
rwm?: Array<string>;
|
|
47
|
+
/**
|
|
48
|
+
* The customer the token acts for, when it acts for one: the signed-in
|
|
49
|
+
* customer who ran the operation, or the owner of the record whose event
|
|
50
|
+
* fired it. `requireScopedToken` answers it as `customerId`.
|
|
51
|
+
*/
|
|
52
|
+
cid?: string;
|
|
47
53
|
}
|
|
48
54
|
export interface ScopedTokenSubject {
|
|
49
55
|
tenantId: string;
|
|
50
56
|
projectId: string;
|
|
57
|
+
/**
|
|
58
|
+
* What the minting site put in the third segment: the app's name on an app's
|
|
59
|
+
* dispatch, the operation's key on a callback token, and empty on a token
|
|
60
|
+
* bound to the project and to no app. It names nothing reliably, so route on
|
|
61
|
+
* your own URL and never on this.
|
|
62
|
+
*/
|
|
51
63
|
appName: string;
|
|
52
64
|
}
|
|
53
65
|
/**
|
|
54
66
|
* Parse the pipe-delimited `sub` claim emitted by the platform's
|
|
55
|
-
* scoped-token minter into its three components
|
|
56
|
-
*
|
|
57
|
-
*
|
|
67
|
+
* scoped-token minter into its three components, as the platform itself
|
|
68
|
+
* parses it: split on the first two pipes only, so the third segment keeps any
|
|
69
|
+
* pipe it holds. The tenant and the project are required, and a subject
|
|
70
|
+
* missing either is a forged or corrupt token. The third segment may be empty:
|
|
71
|
+
* a token bound to the project and to no app.
|
|
58
72
|
*
|
|
59
73
|
* @example
|
|
60
74
|
* ```ts
|
|
@@ -98,3 +112,95 @@ export declare function verifyScopedToken(token: string, jwksUrl: string, option
|
|
|
98
112
|
* `project=P;app=A;operation=O;triggered_by=T;execution_id=E;causation_chain=a,b,c`.
|
|
99
113
|
*/
|
|
100
114
|
export declare function parseContextHeader(raw: string | null): Record<string, string>;
|
|
115
|
+
/**
|
|
116
|
+
* The key set could not be read and none is cached, so the token could not be
|
|
117
|
+
* checked either way. Not a verdict on the token: answer 503, never 401. A 401
|
|
118
|
+
* tells the platform its own token was refused, which fails a run with no
|
|
119
|
+
* retry; a 503 is retried.
|
|
120
|
+
*/
|
|
121
|
+
export declare class ScopedTokenUnavailableError extends Error {
|
|
122
|
+
constructor(message: string, options?: {
|
|
123
|
+
cause?: unknown;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** How a {@link ScopedTokenVerifier}'s key cache is doing. */
|
|
127
|
+
export interface ScopedTokenVerifierStats {
|
|
128
|
+
/** Keys currently held. 0 before the first successful fetch. */
|
|
129
|
+
keyCount: number;
|
|
130
|
+
/** How long ago the held set was fetched or confirmed, or null when none is held. */
|
|
131
|
+
ageMs: number | null;
|
|
132
|
+
/** Verifications answered from a set past its lifetime, because a refetch failed. */
|
|
133
|
+
staleServes: number;
|
|
134
|
+
/** Fetches that failed. */
|
|
135
|
+
refetchFailures: number;
|
|
136
|
+
/** Refetches the endpoint answered 304 to. */
|
|
137
|
+
notModified: number;
|
|
138
|
+
}
|
|
139
|
+
export interface ScopedTokenVerifierOptions extends VerifyScopedTokenOptions {
|
|
140
|
+
/** The platform's JWKS endpoint, e.g. `https://api.foir.dev/.well-known/jwks.json`. */
|
|
141
|
+
jwksUrl: string;
|
|
142
|
+
/** Defaults to the global `fetch`. */
|
|
143
|
+
fetch?: typeof globalThis.fetch;
|
|
144
|
+
/** Extra init for the JWKS fetch, e.g. a runtime's own connection options. The signal is the verifier's. */
|
|
145
|
+
requestInit?: Omit<RequestInit, 'method' | 'headers' | 'signal'>;
|
|
146
|
+
/** The deadline for one JWKS fetch, in milliseconds. Default 5000. */
|
|
147
|
+
timeoutMs?: number;
|
|
148
|
+
/** How long a fetched set is used before it is refreshed, in milliseconds. Default one hour. */
|
|
149
|
+
ttlMs?: number;
|
|
150
|
+
/** Injectable clock, in milliseconds since the epoch. */
|
|
151
|
+
now?: () => number;
|
|
152
|
+
/**
|
|
153
|
+
* Called when a refetch fails and the held set is served past its lifetime.
|
|
154
|
+
* For logging; must not throw.
|
|
155
|
+
*/
|
|
156
|
+
onStale?: (info: {
|
|
157
|
+
error: unknown;
|
|
158
|
+
ageMs: number;
|
|
159
|
+
staleServes: number;
|
|
160
|
+
}) => void;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A scoped-token verifier with a key cache of its own, for a server that
|
|
164
|
+
* stays up for a long time and takes every dispatch through one process.
|
|
165
|
+
*/
|
|
166
|
+
export interface ScopedTokenVerifier {
|
|
167
|
+
/**
|
|
168
|
+
* Verify a token. Throws {@link ScopedTokenUnavailableError} when the keys
|
|
169
|
+
* cannot be read and none are held, and an ordinary Error naming the fault
|
|
170
|
+
* for a token that does not verify.
|
|
171
|
+
*/
|
|
172
|
+
verify(token: string): Promise<ScopedTokenClaims>;
|
|
173
|
+
/** Fetch the keys now, so the first dispatch does not wait on it. Never throws. */
|
|
174
|
+
warm(): Promise<void>;
|
|
175
|
+
stats(): ScopedTokenVerifierStats;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Build a verifier that keeps answering when the JWKS endpoint does not.
|
|
179
|
+
*
|
|
180
|
+
* `verifyScopedToken` fetches the key set and caches it for five minutes, which
|
|
181
|
+
* suits a function that lives for one request. A server that lives for days
|
|
182
|
+
* needs the outage behaviour decided:
|
|
183
|
+
*
|
|
184
|
+
* - **A failed refetch serves the held keys**, for as long as it keeps failing.
|
|
185
|
+
* Rotation adds keys, so a stale set can only lack a new key; it never accepts
|
|
186
|
+
* a removed one for longer than the platform's own caches already do.
|
|
187
|
+
* - **Nothing held and nothing readable is `ScopedTokenUnavailableError`**, which
|
|
188
|
+
* `requireScopedToken` answers as 503. A 401 there would refuse the
|
|
189
|
+
* platform's own token over this server's outage.
|
|
190
|
+
* - **The fetch is bounded** (`timeoutMs`), so a host that accepts the
|
|
191
|
+
* connection and goes quiet cannot hold every dispatch behind it, and **a
|
|
192
|
+
* failure holds off the next attempt for a minute**, so an outage costs one
|
|
193
|
+
* attempt a minute rather than one per request.
|
|
194
|
+
* - **An unknown key id refetches at once**, at most once a minute, so a
|
|
195
|
+
* rotation is picked up without waiting out the lifetime and a flood of made
|
|
196
|
+
* up key ids cannot become a flood of fetches.
|
|
197
|
+
* - **`If-None-Match` is sent**, so an unchanged set costs a 304.
|
|
198
|
+
*
|
|
199
|
+
* ```ts
|
|
200
|
+
* const verifier = createScopedTokenVerifier({ jwksUrl });
|
|
201
|
+
* await verifier.warm();
|
|
202
|
+
* // per request:
|
|
203
|
+
* const { subject, dispatch } = await requireScopedToken(request, { verifier });
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
export declare function createScopedTokenVerifier(options: ScopedTokenVerifierOptions): ScopedTokenVerifier;
|