@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +353 -2
  2. package/README.md +10 -2
  3. package/dist/app/client.d.ts +371 -0
  4. package/dist/app/contract/index.d.ts +23 -0
  5. package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
  6. package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
  7. package/dist/app/dispatch.d.ts +64 -0
  8. package/dist/app/errors.d.ts +50 -0
  9. package/dist/app/index.d.ts +40 -0
  10. package/dist/app/manifest.d.ts +56 -0
  11. package/dist/app/naming.d.ts +31 -0
  12. package/dist/app/record-key.d.ts +17 -0
  13. package/dist/app/ticket.d.ts +37 -0
  14. package/dist/app/vocabulary.generated.d.ts +20 -0
  15. package/dist/app-contract.js +124 -0
  16. package/dist/app.js +998 -0
  17. package/dist/auth/auth.d.ts +24 -6
  18. package/dist/auth/index.d.ts +2 -1
  19. package/dist/auth/ops.d.ts +8 -0
  20. package/dist/auth.js +230 -60
  21. package/dist/client.js +80 -15
  22. package/dist/codegen/preset.d.ts +3 -2
  23. package/dist/data/builtins.d.ts +17 -1
  24. package/dist/data/client.d.ts +10 -8
  25. package/dist/data/document.d.ts +13 -0
  26. package/dist/data/index.d.ts +15 -5
  27. package/dist/data/manifest.d.ts +31 -3
  28. package/dist/data/schema.d.ts +16 -9
  29. package/dist/data/selection.d.ts +15 -0
  30. package/dist/data/transport.d.ts +277 -23
  31. package/dist/data/ws-transport.d.ts +8 -2
  32. package/dist/data.js +313 -72
  33. package/dist/graphql/client.d.ts +1 -1
  34. package/dist/graphql/foir-request.d.ts +1 -1
  35. package/dist/index.js +0 -9
  36. package/dist/live/drift.d.ts +9 -10
  37. package/dist/live.js +1 -1
  38. package/dist/next.js +158 -37
  39. package/dist/placement/index.d.ts +129 -0
  40. package/dist/placement.js +144 -0
  41. package/dist/push.js +212 -50
  42. package/dist/react.js +158 -37
  43. package/dist/server/callback-client.d.ts +30 -9
  44. package/dist/server/continue-run.d.ts +56 -0
  45. package/dist/server/index.d.ts +6 -3
  46. package/dist/server/operation-types.d.ts +114 -65
  47. package/dist/server/read-dispatch.d.ts +53 -0
  48. package/dist/server/require-scoped-token.d.ts +71 -7
  49. package/dist/server/verify-scoped-token.d.ts +109 -3
  50. package/dist/server.js +566 -138
  51. package/dist/transport/foir-http.d.ts +103 -5
  52. 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
- * Throws {@link ScopedTokenError} with `status` 401 (missing/invalid token)
20
- * or 403 (valid token for the wrong tenant/project) — map it to a response
21
- * in one place:
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
- constructor(message: string, status: 401 | 403);
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. Throws on a missing or
56
- * empty segment — the platform never emits a partially-populated subject,
57
- * so a malformed sub indicates a forged or corrupt token.
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;