@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/CHANGELOG.md
CHANGED
|
@@ -95,8 +95,12 @@ move or drop that `.npmrc`, tag by hand:
|
|
|
95
95
|
git tag sdk-v<version> && git push origin sdk-v<version>
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
Where the tagged commit is decides the npm tag it goes out under. A tag on a
|
|
99
|
+
commit `main` holds publishes as `latest`. A tag on a commit only `dev` holds
|
|
100
|
+
publishes as `next`: use it when a release needs something production does not
|
|
101
|
+
serve until the next promotion, so the version can be installed by name and
|
|
102
|
+
nobody asking for `@foir/sdk` gets it early. The push that carries the commit to
|
|
103
|
+
`main` moves `latest` onto it.
|
|
100
104
|
|
|
101
105
|
---
|
|
102
106
|
|
|
@@ -104,6 +108,131 @@ on `dev` would publish unreviewed work to npm.
|
|
|
104
108
|
|
|
105
109
|
Changes on `dev` that have not been published to npm.
|
|
106
110
|
|
|
111
|
+
## [0.12.0] — 2026-10-01
|
|
112
|
+
|
|
113
|
+
Breaking changes under the pre-1.0 note, so this release is a MINOR. Published
|
|
114
|
+
under npm's `next` tag from `dev`, because it calls parts of the platform that
|
|
115
|
+
production serves from the next promotion; `latest` moves to it then.
|
|
116
|
+
|
|
117
|
+
### Added
|
|
118
|
+
|
|
119
|
+
- **`@foir/sdk/app`**, a new subpath: everything an app built on Foir asks of
|
|
120
|
+
the platform, as one client. An app is a service the platform dispatches to,
|
|
121
|
+
holding the token each dispatch arrives with.
|
|
122
|
+
- `createAppClient({ apiUrl, dispatch })` calls the data API with that token:
|
|
123
|
+
`whoami`, `install.config`, `install.credentials`,
|
|
124
|
+
`install.writeCredentials`, `install.reportStatus`, `records.upsert`,
|
|
125
|
+
`read`, `delete`, `refresh`, `rename`, `updateMany` and `aggregate`,
|
|
126
|
+
`records.customer.save`, `read`, `erase` and `identities`,
|
|
127
|
+
`customers.ensure`, `notifications.send` and `notifications.setConsent`, and
|
|
128
|
+
`request` for a document of your own. Nothing is checked before a call: what
|
|
129
|
+
the token may do is the platform's to hold it to, and a refusal is a
|
|
130
|
+
`FoirRequestError` carrying the platform's answer.
|
|
131
|
+
- `appLookupConflictOf`, `appCustomerUnlinkedOf` and `isAppFieldWithheld` read
|
|
132
|
+
the refusals an app acts on, as data.
|
|
133
|
+
- `readAppDispatch` and `readCredentialProbe` read the two bodies the platform
|
|
134
|
+
posts to an app, and `redeemExchangeTicket` trades an OAuth exchange ticket
|
|
135
|
+
for the token that writes the install's credentials.
|
|
136
|
+
- `validateAppManifest({ apiUrl, apiKey, manifest })` asks the platform
|
|
137
|
+
whether it would accept a manifest, as a dry run, and answers with every
|
|
138
|
+
issue and where it is. Any key may ask, a publishable one included.
|
|
139
|
+
- `APP_CAPABILITIES`, `APP_RECORD_CAPABILITY_PREFIXES` and `APP_HOOK_EVENTS`
|
|
140
|
+
are the capabilities an operation may declare and the events a hook may
|
|
141
|
+
name, with their types, generated from the platform's contract.
|
|
142
|
+
- `appModelTypeName`, `appModelFieldName`, `appSelectorEnumValue` and
|
|
143
|
+
`isSelectableFieldKey` name a model's fields on the data API from its key,
|
|
144
|
+
and `RECORD_KEY_RULE` and `recordKeyRefusal` state what a record key may be.
|
|
145
|
+
- **`@foir/sdk/app/contract`**, a new subpath: the manifest and discovery
|
|
146
|
+
contracts as the messages the platform decodes them with (`AppConfigSchema`,
|
|
147
|
+
`DiscoveryResultSchema` and what they are built from). Building a manifest
|
|
148
|
+
from them means a key the platform does not accept cannot be written. It
|
|
149
|
+
needs `@bufbuild/protobuf`, an optional peer dependency nothing else in the
|
|
150
|
+
package asks for.
|
|
151
|
+
- **`createScopedTokenVerifier`** in `@foir/sdk/server`, for a long-lived
|
|
152
|
+
service: it keeps the platform's signing keys, refreshes them in the
|
|
153
|
+
background, keeps serving the ones it holds through an outage, and looks
|
|
154
|
+
again at most once per cooldown when a token names a key it has not seen.
|
|
155
|
+
`requireScopedToken` takes it as `verifier`. `ScopedTokenUnavailableError` is
|
|
156
|
+
what it throws when it has no keys to judge a token by.
|
|
157
|
+
- **`requireScopedToken` answers more about who is calling.** The ingress it
|
|
158
|
+
resolves with gains `orgId` (the organisation level the token was minted at,
|
|
159
|
+
`null` when it states none), `customerId`, `expiresAt` and `tokenId`.
|
|
160
|
+
`ScopedTokenError` gains `reason` (`missing`, `invalid`, `forbidden` or
|
|
161
|
+
`unavailable`), and its `status` may now be 503: keys that cannot be fetched
|
|
162
|
+
are this service's outage, which the platform retries, and not a verdict on
|
|
163
|
+
the token. `FOIR_TOKEN_HEADER` and `FOIR_CONTEXT_HEADER` name the two dispatch
|
|
164
|
+
headers.
|
|
165
|
+
- **`createCallbackClient` needs no signing secret.** It signs with the run's
|
|
166
|
+
own key, `callback.signingKey`, which the platform sends with every ASYNC
|
|
167
|
+
dispatch, so `signingSecret` is now optional and is the key used only when
|
|
168
|
+
passed. It takes `apiUrl`, used when a dispatch names no address to report
|
|
169
|
+
to, `retry`, and `signal`. A call the platform did not answer, or answered
|
|
170
|
+
with a 5xx or a 429, is tried again, with the same signed bytes.
|
|
171
|
+
- **`@foir/sdk/placement`**, a new subpath for an app's own admin console shown
|
|
172
|
+
inside Foir's as a full-page placement. `connectPlacement` tells the console
|
|
173
|
+
the page is ready, takes the token it posts into the frame (only from the
|
|
174
|
+
consoles you list), keeps it fresh, and gives you `request`, `can` and
|
|
175
|
+
`onSignedOut`. `placementFrameAncestors` builds the `frame-ancestors` value a
|
|
176
|
+
page must send to be shown at all, and `FOIR_CONSOLE_ORIGINS` names the
|
|
177
|
+
consoles per environment. `PLACEMENT_MESSAGE` and the message types are the
|
|
178
|
+
protocol the console speaks. Additive: no existing export changes.
|
|
179
|
+
- **`continueRun`** in `@foir/sdk/server`, for an ASYNC operation whose work is
|
|
180
|
+
longer than one call: it answers the dispatch with a continuation (a cursor,
|
|
181
|
+
and optionally a delay and progress), and the platform queues the next slice
|
|
182
|
+
with a fresh callback, handing it the cursor. `CONTINUATION_MEDIA_TYPE`,
|
|
183
|
+
`MAX_CONTINUATION_CURSOR_BYTES` and `MAX_CONTINUATION_DELAY_MS` state the
|
|
184
|
+
contract, `Continuation` is its type, and `OperationContinuationBlock` is the
|
|
185
|
+
envelope block a slice reads its cursor from (`readDispatch` validates it).
|
|
186
|
+
Additive: no existing export changes.
|
|
187
|
+
- **`FoirAuthDowngradedError`** and **`FoirSessionExpiredError`**, exported from
|
|
188
|
+
`@foir/sdk/data`, `@foir/sdk/server`, `@foir/sdk/auth` and `@foir/sdk/client`,
|
|
189
|
+
and **`refreshSessionOnce`** in `@foir/sdk/auth`. `requireAuth` takes an
|
|
190
|
+
optional `env`, and the data client's config takes `refreshBearer`.
|
|
191
|
+
- **`visitor`** on the data client: the address of the visitor a server
|
|
192
|
+
rendering pages is calling for, or a function resolving it per request, sent
|
|
193
|
+
as `X-Foir-Visitor-IP` so the public API's per-visitor limit applies to that
|
|
194
|
+
visitor. From a secret key holding `visitors:forward`; a client built from a
|
|
195
|
+
public key is refused it. The SDK reads no address off a request: pass the one
|
|
196
|
+
your host guarantees. `FoirVisitorSource` and `FOIR_VISITOR_IP_HEADER` are
|
|
197
|
+
exported from `@foir/sdk/data`.
|
|
198
|
+
|
|
199
|
+
### Changed
|
|
200
|
+
|
|
201
|
+
- **Breaking: a scoped token's app is everything after the second `|` of its
|
|
202
|
+
subject.** `verifyScopedToken` required exactly three `|`-separated parts,
|
|
203
|
+
none empty, so it refused a token whose app name holds a `|`, and one minted
|
|
204
|
+
for a project and no app. The platform splits on the first two: both now
|
|
205
|
+
verify, and `appName` is `''` for a token that names no app.
|
|
206
|
+
- **Breaking: `createCallbackClient` throws `FoirRequestError`**, where it threw
|
|
207
|
+
a plain `Error`, so a caller can tell a refusal (`status`, `graphQLErrors`)
|
|
208
|
+
from a call that got no answer. It also throws when it is built with nothing
|
|
209
|
+
to sign with, or with nowhere to report to.
|
|
210
|
+
- **Breaking: a refusal in a 200 is not retryable.**
|
|
211
|
+
`FoirRequestError.retryable` was true for any error with no HTTP status, which
|
|
212
|
+
is the shape of a network fault and also of a GraphQL refusal answered with a
|
|
213
|
+
200. A refusal is now retryable only when its code is `RATE_LIMITED`,
|
|
214
|
+
`UNAVAILABLE` or `TIMEOUT`; a stripped selection never is.
|
|
215
|
+
- **Breaking: a signed-in request is no longer handed the signed-out answer.**
|
|
216
|
+
A bearer the server cannot verify is not refused: the request is served as
|
|
217
|
+
nobody, and the response says so in `extensions.auth`. The SDK used to warn
|
|
218
|
+
and return that answer as though it were the signed-in one.
|
|
219
|
+
- `authedOp` and `authedRequest` now treat it like a 401. They refresh the
|
|
220
|
+
session once, retry once, and fail with `FoirSessionExpiredError` when it
|
|
221
|
+
cannot be renewed. A token this environment never issued fails the same way,
|
|
222
|
+
without a refresh.
|
|
223
|
+
- Requests that find the session lapsed together share one refresh, because a
|
|
224
|
+
refresh token rotates when it is spent.
|
|
225
|
+
- The data client, for a request that sent a bearer, fails with
|
|
226
|
+
`FoirAuthDowngradedError`. Give it `refreshBearer` and it renews the bearer
|
|
227
|
+
once and asks again, failing with `FoirSessionExpiredError` when the session
|
|
228
|
+
is over.
|
|
229
|
+
- A request that sent no bearer is untouched.
|
|
230
|
+
- **A refresh that names no customer keeps the session's identity.**
|
|
231
|
+
`saveSession` used to write the customer id and email unconditionally, so a
|
|
232
|
+
refresh answered without them left a valid token beside an empty identity, and
|
|
233
|
+
`requireAuth` turned the session away. Given `env`, `requireAuth` now reads a
|
|
234
|
+
missing identity once from `currentUser` rather than failing.
|
|
235
|
+
|
|
107
236
|
## [0.11.0] — 2026-09-26
|
|
108
237
|
|
|
109
238
|
Breaking changes under the pre-1.0 note, so this release is a MINOR:
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client an installed app calls Foir with.
|
|
3
|
+
*
|
|
4
|
+
* An app is installed in many projects, and each install maps the app's
|
|
5
|
+
* sources onto whichever models that project's operator chose. So an app has
|
|
6
|
+
* no schema to generate a client from: it learns the model keys at request
|
|
7
|
+
* time, from the install's own configuration, and names them in each call.
|
|
8
|
+
* `createFoirClient` is the client for an endpoint that knows its project;
|
|
9
|
+
* this is the one for an endpoint that does not.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* const { dispatch } = await requireScopedToken(request, { verifier });
|
|
13
|
+
* const foir = createAppClient({ apiUrl, dispatch });
|
|
14
|
+
* const config = await foir.install.config();
|
|
15
|
+
* const products = config.sources.find((source) => source.key === 'product');
|
|
16
|
+
* await foir.records.upsert({ model: products.model, naturalKey: handle, data });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The credential is the token the dispatch carried, and nothing else. Every
|
|
20
|
+
* call goes to the data API as that token, so what it may do is exactly what
|
|
21
|
+
* the operation declared. NOTHING IS CHECKED HERE FIRST: the client does not
|
|
22
|
+
* read the token, and a call the token cannot make is refused by the platform,
|
|
23
|
+
* whose answer is the error thrown (see `./errors`).
|
|
24
|
+
*
|
|
25
|
+
* A client is cheap and holds no state. Build one per dispatch and let it go
|
|
26
|
+
* with the request, so a credential revoked in Foir stops working on the next
|
|
27
|
+
* call rather than whenever a cache expires.
|
|
28
|
+
*/
|
|
29
|
+
import type { FoirDispatchCredential } from '../data/transport';
|
|
30
|
+
import { type FoirRetryPolicy } from '../transport/retry';
|
|
31
|
+
type Json = null | boolean | number | string | Json[] | {
|
|
32
|
+
[key: string]: Json;
|
|
33
|
+
};
|
|
34
|
+
type JsonObject = {
|
|
35
|
+
[key: string]: Json;
|
|
36
|
+
};
|
|
37
|
+
export interface AppClientConfig {
|
|
38
|
+
/** The data API's origin, e.g. `https://api.foir.dev`. */
|
|
39
|
+
apiUrl: string;
|
|
40
|
+
/**
|
|
41
|
+
* The token to call as: `dispatch` from `requireScopedToken`, the token an
|
|
42
|
+
* exchange ticket was redeemed for (`redeemExchangeTicket`), or an ASYNC
|
|
43
|
+
* run's callback token (`{ token: () => payload.callback.token }`), which
|
|
44
|
+
* keeps writing records after the dispatch token has expired.
|
|
45
|
+
*/
|
|
46
|
+
dispatch: FoirDispatchCredential;
|
|
47
|
+
/** Defaults to the global `fetch`. */
|
|
48
|
+
fetch?: typeof globalThis.fetch;
|
|
49
|
+
/** Extra init for every request, e.g. an abort signal. */
|
|
50
|
+
requestInit?: Omit<RequestInit, 'method' | 'headers' | 'body'>;
|
|
51
|
+
/**
|
|
52
|
+
* How a call that got no answer, a 5xx or a 429 is tried again. Three
|
|
53
|
+
* attempts unless told otherwise; `{ attempts: 1 }` turns it off. Every call
|
|
54
|
+
* here is safe to repeat except a notification sent without an
|
|
55
|
+
* `idempotencyKey`, which a retry after a lost answer can deliver twice.
|
|
56
|
+
*/
|
|
57
|
+
retry?: FoirRetryPolicy;
|
|
58
|
+
}
|
|
59
|
+
/** Which way one mapped field is allowed to move. */
|
|
60
|
+
export type AppSyncDirection = 'SOURCE_ONLY' | 'SINK_ONLY' | 'BIDIRECTIONAL';
|
|
61
|
+
export interface AppFieldMapping {
|
|
62
|
+
/** The app's own field name, from its manifest. */
|
|
63
|
+
appField: string;
|
|
64
|
+
/** The project model's field key. */
|
|
65
|
+
modelField: string;
|
|
66
|
+
/**
|
|
67
|
+
* SINK_ONLY means the operator has said Foir owns the field: a value from
|
|
68
|
+
* the app's side must not be written over it. An unrecognised direction
|
|
69
|
+
* reads as SINK_ONLY, which withholds the field rather than writing on a
|
|
70
|
+
* guess.
|
|
71
|
+
*/
|
|
72
|
+
direction: AppSyncDirection;
|
|
73
|
+
}
|
|
74
|
+
/** One of the operator's mappings: a source or sink of the app onto a model. */
|
|
75
|
+
export interface AppBinding {
|
|
76
|
+
/** The app's source or sink name. */
|
|
77
|
+
key: string;
|
|
78
|
+
/** The project model the operator mapped it onto. */
|
|
79
|
+
model: string;
|
|
80
|
+
/** The model field the app's records are keyed on. Empty when the mapping names none. */
|
|
81
|
+
naturalKey: string;
|
|
82
|
+
fields: AppFieldMapping[];
|
|
83
|
+
/** REFERENCE: Foir reads the source's fields live. SYNC: it holds a copy. A sink is a copy. */
|
|
84
|
+
mode: 'SYNC' | 'REFERENCE';
|
|
85
|
+
}
|
|
86
|
+
/** An install's own configuration, as the operator left it. */
|
|
87
|
+
export interface AppInstallConfig {
|
|
88
|
+
name: string;
|
|
89
|
+
enabled: boolean;
|
|
90
|
+
middlewareUrl: string;
|
|
91
|
+
targetModels: string[];
|
|
92
|
+
/** The address the app's vendor is told to deliver webhooks to. Empty when the app declares none. */
|
|
93
|
+
webhookUrl: string;
|
|
94
|
+
/** The operator's answers to the manifest's settings schema. */
|
|
95
|
+
settings: JsonObject;
|
|
96
|
+
sources: AppBinding[];
|
|
97
|
+
sinks: AppBinding[];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The status an install reports. Leaving out `subscriptionHealth`,
|
|
101
|
+
* `authStatus`, `deletionStrategy` or `extra` keeps the stored value; leaving
|
|
102
|
+
* out any of the `lastSync*` or `lastError*` fields clears it. So send the
|
|
103
|
+
* sync fields together or not at all.
|
|
104
|
+
*/
|
|
105
|
+
export interface AppStatusReport {
|
|
106
|
+
lastSyncAt?: string;
|
|
107
|
+
lastSyncDurationMs?: number;
|
|
108
|
+
lastSyncRecordCount?: number;
|
|
109
|
+
/** null clears the field. */
|
|
110
|
+
lastError?: string | null;
|
|
111
|
+
lastErrorAt?: string | null;
|
|
112
|
+
subscriptionHealth?: 'healthy' | 'degraded' | 'failed';
|
|
113
|
+
authStatus?: 'authenticated' | 'unverified' | 'stale' | 'expired';
|
|
114
|
+
deletionStrategy?: string;
|
|
115
|
+
/** Replaced whole on every report that carries it. */
|
|
116
|
+
extra?: JsonObject;
|
|
117
|
+
}
|
|
118
|
+
/** A record as a read by model key answers it. */
|
|
119
|
+
export interface AppRecord {
|
|
120
|
+
/** The platform's own id. What `records.delete` takes. */
|
|
121
|
+
id: string;
|
|
122
|
+
naturalKey: string | null;
|
|
123
|
+
/** The customer the record belongs to, or null. */
|
|
124
|
+
ownerId: string | null;
|
|
125
|
+
/** The fields that were asked for and could be read, by model field key. */
|
|
126
|
+
fields: JsonObject;
|
|
127
|
+
/** Fields that were asked for and could not be selected, so the answer says nothing about them. */
|
|
128
|
+
unreadable: string[];
|
|
129
|
+
}
|
|
130
|
+
export interface AppUpsertResult {
|
|
131
|
+
/** The record as the platform holds it now. */
|
|
132
|
+
record: Record<string, unknown>;
|
|
133
|
+
/** The platform's own id for it, when the answer carried one. */
|
|
134
|
+
id: string | null;
|
|
135
|
+
created: boolean;
|
|
136
|
+
/** True when nothing was written because Foir already holds a newer copy (`sourceModifiedAt`). */
|
|
137
|
+
stale: boolean;
|
|
138
|
+
}
|
|
139
|
+
export type AppCustomerSaveResult = (AppUpsertResult & {
|
|
140
|
+
unlinked: false;
|
|
141
|
+
customerCreated: boolean;
|
|
142
|
+
}) | {
|
|
143
|
+
unlinked: true;
|
|
144
|
+
};
|
|
145
|
+
export interface AppCustomerEraseResult {
|
|
146
|
+
recordDeleted: boolean;
|
|
147
|
+
identityRemoved: boolean;
|
|
148
|
+
customerDeleted: boolean;
|
|
149
|
+
/** Another sign-in method, or something else they own; null when the customer went or none was found. */
|
|
150
|
+
customerKeptBecause: string | null;
|
|
151
|
+
}
|
|
152
|
+
export interface AppNotification {
|
|
153
|
+
customerId: string;
|
|
154
|
+
/** The in-app title, and the email's subject when `email` names none. */
|
|
155
|
+
title: string;
|
|
156
|
+
message?: string;
|
|
157
|
+
/** A notification category the project defines. */
|
|
158
|
+
category?: string;
|
|
159
|
+
actionUrl?: string;
|
|
160
|
+
imageUrl?: string;
|
|
161
|
+
/** The email, when the send should reach one. Without it, in-app and push only. */
|
|
162
|
+
email?: {
|
|
163
|
+
subject?: string;
|
|
164
|
+
body: string;
|
|
165
|
+
ctas?: ReadonlyArray<{
|
|
166
|
+
label: string;
|
|
167
|
+
url: string;
|
|
168
|
+
}>;
|
|
169
|
+
};
|
|
170
|
+
/** Makes the send safe to repeat: the same key from the same operation tells the customer once. */
|
|
171
|
+
idempotencyKey?: string;
|
|
172
|
+
}
|
|
173
|
+
export interface AppConsentChange {
|
|
174
|
+
customerId: string;
|
|
175
|
+
/** A notification category the project makes opt-in. */
|
|
176
|
+
category: string;
|
|
177
|
+
/** The channel the consent is for, as the project's preferences name it (for example `email`). */
|
|
178
|
+
channel: string;
|
|
179
|
+
enabled: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* When the change was made at its source, as an RFC 3339 time. The platform
|
|
182
|
+
* keeps the latest it has taken and writes nothing for an older one,
|
|
183
|
+
* answering `stale`.
|
|
184
|
+
*/
|
|
185
|
+
sourceModifiedAt?: string;
|
|
186
|
+
}
|
|
187
|
+
/** What the calling token is, and what it is served. Read from the platform, never off the token. */
|
|
188
|
+
export interface AppWhoami {
|
|
189
|
+
/** The app the token acts for, when it acts for one. */
|
|
190
|
+
app: string | null;
|
|
191
|
+
/** The capabilities the token carries. */
|
|
192
|
+
capabilities: string[];
|
|
193
|
+
/** The models its record capabilities name, by verb. */
|
|
194
|
+
models: {
|
|
195
|
+
read: string[];
|
|
196
|
+
write: string[];
|
|
197
|
+
delete: string[];
|
|
198
|
+
publish: string[];
|
|
199
|
+
};
|
|
200
|
+
/** The scopes its calls are held to. */
|
|
201
|
+
effective: string[];
|
|
202
|
+
expiresAt: string | null;
|
|
203
|
+
}
|
|
204
|
+
export interface AppClient {
|
|
205
|
+
/**
|
|
206
|
+
* Run a hand-written document as the app and answer its whole `data`. For a
|
|
207
|
+
* field this client has no method for. A read asking for something the token
|
|
208
|
+
* is not served throws rather than answering the rest without it.
|
|
209
|
+
*/
|
|
210
|
+
request<T = Record<string, unknown>>(document: string, variables?: Record<string, unknown>): Promise<T>;
|
|
211
|
+
/**
|
|
212
|
+
* What the platform says this token is and may do. For an app that has to
|
|
213
|
+
* SHOW that; a call the token cannot make needs no check first, since the
|
|
214
|
+
* platform's refusal already says why.
|
|
215
|
+
*/
|
|
216
|
+
whoami(): Promise<AppWhoami>;
|
|
217
|
+
/** The install this token acts for: its configuration, its credential, its status. */
|
|
218
|
+
install: {
|
|
219
|
+
/** The operator's settings and mappings. Needs `config:read`. */
|
|
220
|
+
config(): Promise<AppInstallConfig>;
|
|
221
|
+
/**
|
|
222
|
+
* The install's credential, by the manifest's credential field names.
|
|
223
|
+
* Values are always strings. Needs `secrets:read.app`.
|
|
224
|
+
*/
|
|
225
|
+
credentials(): Promise<Record<string, string>>;
|
|
226
|
+
/**
|
|
227
|
+
* Write or rotate credential fields. An empty value is skipped, not
|
|
228
|
+
* cleared, and a call naming no non-empty field is refused. Needs
|
|
229
|
+
* `secrets:put.app`.
|
|
230
|
+
*/
|
|
231
|
+
writeCredentials(values: Record<string, string>): Promise<void>;
|
|
232
|
+
/** Report the install's health. Needs `status:write`. See {@link AppStatusReport}. */
|
|
233
|
+
reportStatus(status: AppStatusReport): Promise<void>;
|
|
234
|
+
};
|
|
235
|
+
/** Records of a model the install maps, named by its key. */
|
|
236
|
+
records: {
|
|
237
|
+
/**
|
|
238
|
+
* Write the record at a natural key, making it when there is none.
|
|
239
|
+
* `ownerId` names the customer it belongs to (needs
|
|
240
|
+
* `records:attribute:<model>`); `sourceModifiedAt` is when the object this
|
|
241
|
+
* mirrors last changed at its source, so an older copy arriving late is
|
|
242
|
+
* not written.
|
|
243
|
+
*/
|
|
244
|
+
upsert(input: {
|
|
245
|
+
model: string;
|
|
246
|
+
naturalKey: string;
|
|
247
|
+
data: JsonObject;
|
|
248
|
+
ownerId?: string;
|
|
249
|
+
sourceModifiedAt?: string;
|
|
250
|
+
}): Promise<AppUpsertResult>;
|
|
251
|
+
/**
|
|
252
|
+
* One record by natural key or by id, or null. It reads the PUBLISHED
|
|
253
|
+
* version: on a model with publishing, a record that was never published
|
|
254
|
+
* reads as null, the same as one that does not exist. A field that cannot
|
|
255
|
+
* be selected as written is reported in `unreadable` rather than failing
|
|
256
|
+
* the read.
|
|
257
|
+
*/
|
|
258
|
+
read(model: string, by: {
|
|
259
|
+
naturalKey: string;
|
|
260
|
+
} | {
|
|
261
|
+
id: string;
|
|
262
|
+
}, fields: readonly string[]): Promise<AppRecord | null>;
|
|
263
|
+
/** Delete one record by its platform id, which only a read supplies. */
|
|
264
|
+
delete(model: string, id: string): Promise<{
|
|
265
|
+
deleted: boolean;
|
|
266
|
+
}>;
|
|
267
|
+
/**
|
|
268
|
+
* Drop what the data API has cached for one record, by natural key or id,
|
|
269
|
+
* so its next read resolves it again. False when Foir holds no such
|
|
270
|
+
* record. For a source read live from its host.
|
|
271
|
+
*/
|
|
272
|
+
refresh(model: string, idOrKey: string): Promise<{
|
|
273
|
+
refreshed: boolean;
|
|
274
|
+
}>;
|
|
275
|
+
/**
|
|
276
|
+
* Change the natural key of the one record a filter matches. The platform
|
|
277
|
+
* refuses a filter matching none or several, and a key already held.
|
|
278
|
+
*/
|
|
279
|
+
rename(model: string, where: JsonObject, naturalKey: string): Promise<void>;
|
|
280
|
+
/**
|
|
281
|
+
* Update every record matching a filter, in one call. `where` and `data`
|
|
282
|
+
* are the model's typed filter and update, by model field key. An empty
|
|
283
|
+
* filter is refused: it would change every record.
|
|
284
|
+
*/
|
|
285
|
+
updateMany(model: string, input: {
|
|
286
|
+
where: JsonObject;
|
|
287
|
+
data: JsonObject;
|
|
288
|
+
idempotencyKey?: string;
|
|
289
|
+
}): Promise<{
|
|
290
|
+
count: number;
|
|
291
|
+
ids: string[];
|
|
292
|
+
}>;
|
|
293
|
+
/**
|
|
294
|
+
* How many records match, grouped by one field, counted by the platform.
|
|
295
|
+
* It groups only by an indexed field, largest group first; `truncated`
|
|
296
|
+
* says `limit` cut the list short.
|
|
297
|
+
*/
|
|
298
|
+
aggregate(model: string, query: {
|
|
299
|
+
groupBy: string;
|
|
300
|
+
where?: JsonObject;
|
|
301
|
+
limit?: number;
|
|
302
|
+
}): Promise<{
|
|
303
|
+
groups: Array<{
|
|
304
|
+
key: string | null;
|
|
305
|
+
count: number;
|
|
306
|
+
}>;
|
|
307
|
+
truncated: boolean;
|
|
308
|
+
}>;
|
|
309
|
+
/**
|
|
310
|
+
* The record of a customer the app names by identity: their id at the
|
|
311
|
+
* external system, which is also the subject of their sign-in with it. For
|
|
312
|
+
* a source that declares an identity. The platform finds the customer
|
|
313
|
+
* through the sign-in the install pairs with the source, at the install's
|
|
314
|
+
* level, and never by email.
|
|
315
|
+
*/
|
|
316
|
+
customer: {
|
|
317
|
+
/**
|
|
318
|
+
* Merge `data` into the customer's record, making the record when they
|
|
319
|
+
* have none, and the customer when `email` is given and nobody holds the
|
|
320
|
+
* identity. An identity nobody holds, with no email, is `{ unlinked:
|
|
321
|
+
* true }`: nothing was written, and they are known once they sign in.
|
|
322
|
+
*/
|
|
323
|
+
save(input: {
|
|
324
|
+
model: string;
|
|
325
|
+
identity: string;
|
|
326
|
+
data: JsonObject;
|
|
327
|
+
email?: string;
|
|
328
|
+
sourceModifiedAt?: string;
|
|
329
|
+
}): Promise<AppCustomerSaveResult>;
|
|
330
|
+
/** The customer's record, or null when nobody holds the identity or they hold no record. */
|
|
331
|
+
read(model: string, identity: string): Promise<AppRecord | null>;
|
|
332
|
+
/**
|
|
333
|
+
* Erase what the app holds for the customer, as a privacy request asks:
|
|
334
|
+
* their record and the identity's link, and the customer when nothing
|
|
335
|
+
* else is attached to them. Needs `customers:write` as well.
|
|
336
|
+
*/
|
|
337
|
+
erase(model: string, identity: string): Promise<AppCustomerEraseResult>;
|
|
338
|
+
/**
|
|
339
|
+
* The other direction: the identities a customer the app names by id
|
|
340
|
+
* signs in with there, most recently seen first. Empty when they sign in
|
|
341
|
+
* with none. For an app acting on an event that names the Foir customer.
|
|
342
|
+
*/
|
|
343
|
+
identities(model: string, customerId: string): Promise<string[]>;
|
|
344
|
+
};
|
|
345
|
+
};
|
|
346
|
+
customers: {
|
|
347
|
+
/**
|
|
348
|
+
* The customer who has this email at the install's level, made when there
|
|
349
|
+
* is none. The same customer every time for one email. Needs
|
|
350
|
+
* `customers:write`.
|
|
351
|
+
*/
|
|
352
|
+
ensure(email: string): Promise<{
|
|
353
|
+
id: string;
|
|
354
|
+
email: string;
|
|
355
|
+
}>;
|
|
356
|
+
};
|
|
357
|
+
notifications: {
|
|
358
|
+
/** Tell a customer something. Needs `notifications:write`. */
|
|
359
|
+
send(notification: AppNotification): Promise<void>;
|
|
360
|
+
/**
|
|
361
|
+
* Set a named customer's consent in an opt-in category. Needs
|
|
362
|
+
* `notifications:consent`, which only an operation's token carries.
|
|
363
|
+
*/
|
|
364
|
+
setConsent(change: AppConsentChange): Promise<{
|
|
365
|
+
stale: boolean;
|
|
366
|
+
}>;
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
/** Build the client an installed app calls Foir with. See the module doc. */
|
|
370
|
+
export declare function createAppClient(config: AppClientConfig): AppClient;
|
|
371
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app contract, as the messages the platform itself reads and writes.
|
|
3
|
+
*
|
|
4
|
+
* A manifest is `AppConfig`, and a discovery answer is `DiscoveryResult`. They
|
|
5
|
+
* are generated from the same definitions the platform decodes them with, so a
|
|
6
|
+
* manifest built with `create(AppConfigSchema, …)` and written with `toJson`
|
|
7
|
+
* cannot carry a key the platform would refuse: it would not compile.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { create, toJson } from '@bufbuild/protobuf';
|
|
11
|
+
* import { AppConfigSchema } from '@foir/sdk/app/contract';
|
|
12
|
+
*
|
|
13
|
+
* const manifest = create(AppConfigSchema, { name: 'my-app', version: '1.0.0' });
|
|
14
|
+
* const served = toJson(AppConfigSchema, manifest, { useProtoFieldName: true });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* These are protobuf-es messages, so this subpath needs `@bufbuild/protobuf`
|
|
18
|
+
* installed beside the SDK; the rest of the SDK does not. The messages follow
|
|
19
|
+
* the platform's contract, which only ever grows: a field or a message can
|
|
20
|
+
* appear in any release, and none is removed without a breaking one.
|
|
21
|
+
*/
|
|
22
|
+
export * from './proto/apps/v1/apps_pb.js';
|
|
23
|
+
export * from './proto/apps/v1/discovery_pb.js';
|