@foir/sdk 0.10.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 +353 -2
- package/README.md +10 -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/ops.d.ts +8 -0
- package/dist/auth.js +230 -60
- package/dist/client.js +80 -15
- 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 +15 -5
- 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 +277 -23
- package/dist/data/ws-transport.d.ts +8 -2
- package/dist/data.js +313 -72
- package/dist/graphql/client.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/index.js +0 -9
- package/dist/live/drift.d.ts +9 -10
- package/dist/live.js +1 -1
- package/dist/next.js +158 -37
- package/dist/placement/index.d.ts +129 -0
- package/dist/placement.js +144 -0
- package/dist/push.js +212 -50
- package/dist/react.js +158 -37
- package/dist/server/callback-client.d.ts +30 -9
- package/dist/server/continue-run.d.ts +56 -0
- package/dist/server/index.d.ts +6 -3
- package/dist/server/operation-types.d.ts +114 -65
- package/dist/server/read-dispatch.d.ts +53 -0
- package/dist/server/require-scoped-token.d.ts +71 -7
- package/dist/server/verify-scoped-token.d.ts +109 -3
- package/dist/server.js +566 -138
- package/dist/transport/foir-http.d.ts +103 -5
- package/package.json +24 -6
|
@@ -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,9 +16,21 @@
|
|
|
16
16
|
* });
|
|
17
17
|
* ```
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
+
*
|
|
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:
|
|
22
34
|
*
|
|
23
35
|
* ```ts
|
|
24
36
|
* catch (err) {
|
|
@@ -29,15 +41,31 @@
|
|
|
29
41
|
* }
|
|
30
42
|
* ```
|
|
31
43
|
*/
|
|
32
|
-
import type { ScopedTokenClaims, ScopedTokenSubject, VerifyScopedTokenOptions } from './verify-scoped-token';
|
|
44
|
+
import type { ScopedTokenClaims, ScopedTokenSubject, ScopedTokenVerifier, VerifyScopedTokenOptions } from './verify-scoped-token';
|
|
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";
|
|
33
50
|
/**
|
|
34
51
|
* Verification failure with the HTTP status the route should answer with:
|
|
35
52
|
* 401 for a missing or invalid token, 403 for a valid token bound to the
|
|
36
|
-
* 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.
|
|
37
57
|
*/
|
|
38
58
|
export declare class ScopedTokenError extends Error {
|
|
39
|
-
readonly status: 401 | 403;
|
|
40
|
-
|
|
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');
|
|
41
69
|
}
|
|
42
70
|
/** The minimal request shape needed — satisfied by any fetch-API Request. */
|
|
43
71
|
export interface HeaderCarrier {
|
|
@@ -60,12 +88,48 @@ export interface RequireScopedTokenOptions extends VerifyScopedTokenOptions {
|
|
|
60
88
|
expectedProjectId?: string;
|
|
61
89
|
/** Tenant analogue of {@link expectedProjectId}. */
|
|
62
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;
|
|
63
98
|
}
|
|
64
99
|
export interface ScopedTokenIngress {
|
|
65
100
|
claims: ScopedTokenClaims;
|
|
66
101
|
subject: ScopedTokenSubject;
|
|
67
102
|
/** Parsed `X-Foir-Context` correlation metadata (empty when absent). */
|
|
68
103
|
context: Record<string, string>;
|
|
104
|
+
/**
|
|
105
|
+
* The token this call verified, as the credential for a data client that
|
|
106
|
+
* calls Foir AS the operation:
|
|
107
|
+
* `createFoirClient({ apiUrl, dispatch, generated })`. It sends the token as
|
|
108
|
+
* `X-Foir-Token` with the dispatch's `X-Foir-Context`, and never an api key.
|
|
109
|
+
*/
|
|
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;
|
|
69
133
|
}
|
|
70
134
|
/**
|
|
71
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;
|