@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +131 -2
  2. package/dist/app/client.d.ts +371 -0
  3. package/dist/app/contract/index.d.ts +23 -0
  4. package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
  5. package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
  6. package/dist/app/dispatch.d.ts +64 -0
  7. package/dist/app/errors.d.ts +50 -0
  8. package/dist/app/index.d.ts +40 -0
  9. package/dist/app/manifest.d.ts +56 -0
  10. package/dist/app/naming.d.ts +31 -0
  11. package/dist/app/record-key.d.ts +17 -0
  12. package/dist/app/ticket.d.ts +37 -0
  13. package/dist/app/vocabulary.generated.d.ts +20 -0
  14. package/dist/app-contract.js +124 -0
  15. package/dist/app.js +998 -0
  16. package/dist/auth/auth.d.ts +24 -6
  17. package/dist/auth/index.d.ts +2 -1
  18. package/dist/auth.js +139 -23
  19. package/dist/client.js +36 -2
  20. package/dist/data/index.d.ts +2 -2
  21. package/dist/data/transport.d.ts +34 -2
  22. package/dist/data.js +77 -2
  23. package/dist/graphql/client.d.ts +1 -1
  24. package/dist/graphql/foir-request.d.ts +1 -1
  25. package/dist/next.js +70 -2
  26. package/dist/placement/index.d.ts +129 -0
  27. package/dist/placement.js +144 -0
  28. package/dist/push.js +121 -13
  29. package/dist/react.js +70 -2
  30. package/dist/server/callback-client.d.ts +30 -9
  31. package/dist/server/continue-run.d.ts +56 -0
  32. package/dist/server/index.d.ts +5 -3
  33. package/dist/server/operation-types.d.ts +28 -7
  34. package/dist/server/require-scoped-token.d.ts +52 -7
  35. package/dist/server/verify-scoped-token.d.ts +109 -3
  36. package/dist/server.js +415 -127
  37. package/dist/transport/foir-http.d.ts +48 -0
  38. 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
- The tag must point at a commit on `main`: `main` is what is released, and a tag
99
- on `dev` would publish unreviewed work to npm.
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';