@foir/sdk 0.18.0 → 0.20.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 +365 -0
- package/README.md +8 -1
- package/dist/app/client.d.ts +129 -21
- package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +223 -211
- package/dist/app/errors.d.ts +2 -2
- package/dist/app/index.d.ts +4 -4
- package/dist/app/manifest.d.ts +1 -1
- package/dist/app/vocabulary.generated.d.ts +13 -10
- package/dist/app-contract.js +27 -31
- package/dist/app.js +90 -25
- package/dist/auth/access.d.ts +106 -0
- package/dist/auth/grant-table.generated.d.ts +13 -0
- package/dist/auth/grants.d.ts +38 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/operations.d.ts +6 -1
- package/dist/auth/ops.d.ts +1 -0
- package/dist/auth.js +269 -1
- package/dist/data/builtins.d.ts +59 -3
- package/dist/data/compatibility.d.ts +95 -0
- package/dist/data/document.d.ts +11 -1
- package/dist/data/index.d.ts +1 -1
- package/dist/data/transport.d.ts +51 -27
- package/dist/data.js +337 -7
- package/dist/next.js +3 -0
- package/dist/placement/index.d.ts +40 -8
- package/dist/placement.js +224 -2
- package/dist/push.js +3 -0
- package/dist/react/FoirProvider.d.ts +40 -1
- package/dist/react/feedback-notes.d.ts +1 -1
- package/dist/react/index.d.ts +5 -4
- package/dist/react.js +347 -3
- package/dist/server/events-client.d.ts +5 -5
- package/dist/server/index.d.ts +1 -1
- package/dist/server/older-claims.generated.d.ts +8 -0
- package/dist/server/require-scoped-token.d.ts +1 -1
- package/dist/server/verify-scoped-token.d.ts +36 -17
- package/dist/server.js +267 -5
- package/package.json +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -108,6 +108,371 @@ nobody asking for `@foir/sdk` gets it early. The push that carries the commit to
|
|
|
108
108
|
|
|
109
109
|
Changes on `dev` that have not been published to npm.
|
|
110
110
|
|
|
111
|
+
### Added
|
|
112
|
+
|
|
113
|
+
- **An operation provisions an organization** (`@foir/sdk/app`).
|
|
114
|
+
`organizations.provision({ provisionId, name, parentId?, settings?, firstMember? })`
|
|
115
|
+
makes an organization with a customer as its first member, in the role the
|
|
116
|
+
project names for a founder, and answers `{ organizationId }`. It needs
|
|
117
|
+
`organizations:create`. With `settings: { modelKey, data }` it writes the
|
|
118
|
+
organization's settings record too and answers `settingsRecordId`; that
|
|
119
|
+
takes `records:write:<modelKey>`. Sending the same `provisionId` again with
|
|
120
|
+
the same request answers the same organization. New type:
|
|
121
|
+
`AppOrganizationProvision`. It needs the platform release that carries
|
|
122
|
+
`provisionOrganization`.
|
|
123
|
+
- **An operation schedules the deletion of the organization it runs in**
|
|
124
|
+
(`@foir/sdk/app`). `organizations.scheduleDeletion()` takes nothing and
|
|
125
|
+
answers `{ organizationId, deletionScheduledAt, organizations }`. It needs
|
|
126
|
+
`organizations:delete`, reaches only the organization the run acts in, and
|
|
127
|
+
needs the platform release that carries `scheduleOrganizationDeletion`.
|
|
128
|
+
- **`FoirWriteNoteArgs`** (`@foir/sdk/data`): the arguments of
|
|
129
|
+
`foir.ns.notes.writeNote`, the one field that writes a note for a signed-in
|
|
130
|
+
customer and for a visitor. It asks `notes:create` and needs the platform
|
|
131
|
+
release that carries `writeNote`. `foir.ns.notes` answers any field the
|
|
132
|
+
platform serves, so the call itself needs no new SDK.
|
|
133
|
+
|
|
134
|
+
### Changed
|
|
135
|
+
|
|
136
|
+
- **`FOIR_EVENTS_SCOPE` is `events:read`** (`@foir/sdk/server`). It was
|
|
137
|
+
`events:list.project`, the name the event log's permission had when a key
|
|
138
|
+
was written with scopes. A key is made with the permission catalogue's
|
|
139
|
+
names, where it is `events:read`, and that is what a refusal names. A key
|
|
140
|
+
minted under the older name holds `events:read` and needs no change.
|
|
141
|
+
|
|
142
|
+
### Deprecated
|
|
143
|
+
|
|
144
|
+
- **`FoirCreateMyNoteArgs`, `FoirSubmitAnonymousNoteArgs` and
|
|
145
|
+
`FoirAnonymousNoteReceipt`** (`@foir/sdk/data`). `createMyNote` and
|
|
146
|
+
`submitAnonymousNote` are the older names of `writeNote`. They still answer.
|
|
147
|
+
`<FoirFeedback>` writes through `createMyNote` in this release, and reads
|
|
148
|
+
whichever permission a refusal names, so it explains a role that is short
|
|
149
|
+
`notes:create` as it explained one short `notes:write`.
|
|
150
|
+
|
|
151
|
+
## [0.20.0] — 2026-10-08
|
|
152
|
+
|
|
153
|
+
An operation, and the token it is called with, say what they may do as
|
|
154
|
+
`grants`, in the permission catalogue's names. Everything marked "with the
|
|
155
|
+
platform release that carries it" needs the next promotion, and `latest` moves
|
|
156
|
+
to this version then.
|
|
157
|
+
|
|
158
|
+
### Added
|
|
159
|
+
|
|
160
|
+
- **A token says what it carries as grants** (`@foir/sdk/server`).
|
|
161
|
+
`ScopedTokenClaims.grants` is the whole of what a verified token may do:
|
|
162
|
+
`records:read:<model>`, `records:write:<model>`, `records:create:<model>`,
|
|
163
|
+
`users:create` and the rest, as the
|
|
164
|
+
[operation permissions](https://docs.foir.dev/api/operation-permissions) page
|
|
165
|
+
lists them. It is always set. A token minted before the platform release
|
|
166
|
+
that says grants carries only the older claims, and `verifyScopedToken`
|
|
167
|
+
reads it as what those were worth, exactly as the platform does.
|
|
168
|
+
|
|
169
|
+
A record grant reaches the operation's own records: the project's, or those
|
|
170
|
+
of the customer a run acts for. `records:read.all:<model>`, and the write,
|
|
171
|
+
delete and publish forms like it, reach every owner's records of the model.
|
|
172
|
+
An older token's write of a model was every owner's, so it is read as
|
|
173
|
+
`records:write.all:<model>` and `records:create:<model>`, and takes that
|
|
174
|
+
model's read, delete and publish to `.all` with it.
|
|
175
|
+
|
|
176
|
+
`recordsCreateModels(claims)` is the models a token may make records of.
|
|
177
|
+
|
|
178
|
+
- **The grants a manifest may declare** (`@foir/sdk/app`). `APP_GRANTS` and
|
|
179
|
+
`AppGrant` are the grants an operation declares as written, and
|
|
180
|
+
`APP_RECORD_GRANT_PREFIXES` and `AppRecordGrantPrefix` the ones it declares
|
|
181
|
+
once per model, the every-owner forms (`records:read.all:` and the like)
|
|
182
|
+
among them.
|
|
183
|
+
|
|
184
|
+
- **An embedded page reads what its token carries as grants**
|
|
185
|
+
(`@foir/sdk/placement`). `session.grant.grants` is what the token carries, in
|
|
186
|
+
the permission catalogue's names, and `session.grant.withheldGrants` is each
|
|
187
|
+
grant the placement declares that it does not carry whole, with why. Both
|
|
188
|
+
are always set: a console of the release before sends the two older lists
|
|
189
|
+
alone, and the session reads `grants` from those.
|
|
190
|
+
|
|
191
|
+
- **A feature can say which notification category it sends under**
|
|
192
|
+
(`@foir/sdk/app/contract`). `Feature.notificationCategory` names the string
|
|
193
|
+
setting that holds the category's key, and the category the app proposes
|
|
194
|
+
(`key`, `name`, `description`, `optIn`). The setup then offers the project's
|
|
195
|
+
categories, or makes the app's own, in place of a key typed by hand, and the
|
|
196
|
+
platform holds a running install's setting to a category the project has.
|
|
197
|
+
|
|
198
|
+
- **An app can say what connecting changes** (`@foir/sdk/app/contract`).
|
|
199
|
+
`Credentials.connectNotice` is the app's own words, shown to the operator
|
|
200
|
+
beside the step that connects their account and, for an install the vendor
|
|
201
|
+
started, before the install is confirmed. A vendor that keeps one live
|
|
202
|
+
connection per account (Shopify, per store) says here that connecting moves
|
|
203
|
+
it from any other project. Empty when there is nothing to say.
|
|
204
|
+
|
|
205
|
+
- **The roles a signed-in customer holds, and what they may do**
|
|
206
|
+
(`@foir/sdk/auth`, `@foir/sdk/react`). A storefront can now ask which customer
|
|
207
|
+
roles the signed-in customer holds and show the page to match, where before
|
|
208
|
+
it had to treat one of a role's permissions as a stand-in for the role.
|
|
209
|
+
|
|
210
|
+
- `getSessionAccess(adapter, env)` and `customerAccess(env, { bearerToken })`
|
|
211
|
+
return a `FoirAccess`: the customer's id, the keys of the roles they hold
|
|
212
|
+
(`roles`), and everything they may do through the key (`scopes`). Plain
|
|
213
|
+
data, so a loader can return it. `null` when nobody is signed in.
|
|
214
|
+
- `hasRole(access, key)` and `hasScope(access, scope)` read it. `hasScope`
|
|
215
|
+
applies the platform's own rule: `records:write` covers
|
|
216
|
+
`records:write:post`, `records:read.all` covers `records:read`, and never
|
|
217
|
+
the reverse.
|
|
218
|
+
- `requireRole(adapter, env, key)` and `requireScope(adapter, env, scope)`
|
|
219
|
+
guard a server route. They return the access, or throw
|
|
220
|
+
`FoirAccessDeniedError` with a `reason` of `signed_out`, `role_not_held`,
|
|
221
|
+
`roles_unknown` or `scope_not_held`.
|
|
222
|
+
- `useFoirAccess()` gives a component `hasRole(key)`, `hasScope(scope)`, a
|
|
223
|
+
`status` and `reload()`. It asks once however many components use it, and
|
|
224
|
+
again when the session refreshes.
|
|
225
|
+
|
|
226
|
+
The platform resolves the roles on every request, so nothing is cached here
|
|
227
|
+
and a role taken away is gone the next time it is asked.
|
|
228
|
+
|
|
229
|
+
`roles` is `null`, not an empty list, when the platform did not say which
|
|
230
|
+
roles the customer holds. `hasRole` answers false for it and `requireRole`
|
|
231
|
+
refuses with `roles_unknown`, so an outage is never read as "not staff". A
|
|
232
|
+
platform that does not serve the field yet reads the same way: the role
|
|
233
|
+
helpers need the platform release that carries it.
|
|
234
|
+
|
|
235
|
+
These decide what a page shows. They protect nothing: the platform checks
|
|
236
|
+
every request whatever the page offers.
|
|
237
|
+
|
|
238
|
+
`AUTH_OPS` gains `whoami`, and `FoirContextValue` gains `access`,
|
|
239
|
+
`accessStatus`, `loadAccess` and `ensureAccess`.
|
|
240
|
+
|
|
241
|
+
- **A save says when it made a record the operation may not read**
|
|
242
|
+
(`@foir/sdk/app`). `records.upsert` and `records.customer.save` answer
|
|
243
|
+
`fieldsWithheld`. It is true when the operation declared the create of a
|
|
244
|
+
model and not the read: the save made the record, and `record` carries its
|
|
245
|
+
`id` and `modelKey` and nothing else. The record is not empty.
|
|
246
|
+
|
|
247
|
+
### Changed
|
|
248
|
+
|
|
249
|
+
- **Breaking, with the platform release that carries it: an operation is
|
|
250
|
+
called with what it declares and nothing else** (`@foir/sdk/app`). The token
|
|
251
|
+
used to carry the read of its owner's secrets whatever the operation
|
|
252
|
+
declared. An operation whose handler reads the app's credentials
|
|
253
|
+
(`install.credentials()`) declares `appsecrets:read`, and a project's own
|
|
254
|
+
operation that reads a project secret declares `secrets:read`. An installed
|
|
255
|
+
app's operations are given the one they could use, so an installed app keeps
|
|
256
|
+
running; a manifest served from then on says it.
|
|
257
|
+
|
|
258
|
+
- **Breaking, with the platform release that carries it: erasing a customer
|
|
259
|
+
takes `identities:delete`** (`@foir/sdk/app`). `records.customer.erase` needed
|
|
260
|
+
`users:create` beside the delete and the read of the model, and now needs
|
|
261
|
+
`identities:delete` there. `APP_GRANTS` carries it, with `files:delete`,
|
|
262
|
+
`notifications:read` and every owner's files (`files:read.all`,
|
|
263
|
+
`files:write.all`, `files:delete.all`), which a manifest may now declare.
|
|
264
|
+
`verifyScopedToken` reads a token minted before, that carried the customer
|
|
265
|
+
create beside the delete of a model, as holding `identities:delete`, as the
|
|
266
|
+
platform does.
|
|
267
|
+
|
|
268
|
+
- **Breaking, with the platform release that carries it: an operation declares
|
|
269
|
+
`grants`** (`@foir/sdk/app`, `@foir/sdk/app/contract`). A manifest's
|
|
270
|
+
operation says what it may do as `grants`, in the permission catalogue's
|
|
271
|
+
names, and the platform refuses one that still declares `capabilities`,
|
|
272
|
+
saying what to declare. An installed app keeps running on what it was
|
|
273
|
+
installed with; it is installing and updating that need the new manifest.
|
|
274
|
+
|
|
275
|
+
Most names are written as they were. These changed:
|
|
276
|
+
|
|
277
|
+
| Before | Now |
|
|
278
|
+
|---|---|
|
|
279
|
+
| `config:read` | `appconfig:read` |
|
|
280
|
+
| `secrets:read.app`, `secrets:put.app`, `secrets:list.app`, `secrets:delete.app` | `appsecrets:read`, `appsecrets:put`, `appsecrets:list`, `appsecrets:delete` |
|
|
281
|
+
| `files:list` | `files:read` |
|
|
282
|
+
| `customers:write` | `users:create` |
|
|
283
|
+
| `customers:read` | `users:read.all` |
|
|
284
|
+
| `customers:read:named` | `users:read.named` |
|
|
285
|
+
| `customers:read:identity` | `identities:read` |
|
|
286
|
+
| `notifications:write` | `notifications:write.named` |
|
|
287
|
+
| `records:write:<model>` | `records:write:<model>` and `records:create:<model>` |
|
|
288
|
+
|
|
289
|
+
The last row is a change in what the name means. `records:write:<model>`
|
|
290
|
+
changes a record that is there, and making one is `records:create:<model>`.
|
|
291
|
+
An operation that does both declares both. `records.upsert` and
|
|
292
|
+
`records.customer.save` are let through on either, and the platform asks for
|
|
293
|
+
the one the save turns out to need.
|
|
294
|
+
|
|
295
|
+
- **Breaking, with the platform release that carries it: a full-page placement
|
|
296
|
+
declares `grants`** (`@foir/sdk/app/contract`, `@foir/sdk/placement`). A
|
|
297
|
+
placement in a manifest says what its page acts with as `grants`, and the
|
|
298
|
+
platform refuses one that still declares `scopes`, saying what to declare.
|
|
299
|
+
An installed app's page keeps working on what it was installed with.
|
|
300
|
+
|
|
301
|
+
The names are the ones a team member's role is composed from, so three
|
|
302
|
+
things change in what a placement writes:
|
|
303
|
+
|
|
304
|
+
- **Every owner's records are written `.all`.** `records:read:order` was
|
|
305
|
+
every order and is now the operator's own. Declare
|
|
306
|
+
`records:read.all:order`.
|
|
307
|
+
- **A write no longer includes its read.** `records:write:order` also read
|
|
308
|
+
and created, and `apps:write` also read apps. Declare each one the page
|
|
309
|
+
uses: `records:write.all:order`, `records:read.all:order`,
|
|
310
|
+
`records:create:order`.
|
|
311
|
+
- **A wildcard and a permission that gives others are refused**, as `*`
|
|
312
|
+
always was.
|
|
313
|
+
|
|
314
|
+
The token then carries what the placement declares and the operator holds,
|
|
315
|
+
permission by permission. An operator who holds part of a declared grant is
|
|
316
|
+
given that part, where before the whole name was withheld.
|
|
317
|
+
|
|
318
|
+
- **`session.can` answers by what the token covers** (`@foir/sdk/placement`).
|
|
319
|
+
It was an exact match on the spelling. `can('records:read.all:order')` is
|
|
320
|
+
now true for a token that carries `records:read.all`, as Foir's API would
|
|
321
|
+
answer. A name the token carries as it is written is still held.
|
|
322
|
+
|
|
323
|
+
- **`PlacementGrant.grantedScopes` and `PlacementGrant.withheld` are
|
|
324
|
+
deprecated** (`@foir/sdk/placement`). They are still set, and say what
|
|
325
|
+
`grants` and `withheldGrants` say.
|
|
326
|
+
|
|
327
|
+
- **Breaking, with the platform release that carries it: `whoami` answers
|
|
328
|
+
`grants`** (`@foir/sdk/app`). `AppWhoami.capabilities` is gone and
|
|
329
|
+
`AppWhoami.grants` takes its place, and `AppWhoami.models` gains `create`.
|
|
330
|
+
|
|
331
|
+
- **Breaking: the older claims on `ScopedTokenClaims` are optional**
|
|
332
|
+
(`@foir/sdk/server`). `cap`, `rrm` and `rwm` are still on a token the
|
|
333
|
+
platform mints, in the older names, and are marked deprecated: they never
|
|
334
|
+
say more than `grants` and can say less. `recordsReadModels` and
|
|
335
|
+
`recordsWriteModels` now read `grants`, so `recordsWriteModels` is the models
|
|
336
|
+
a token may change records of, and no longer the ones it may make them in.
|
|
337
|
+
Code that builds a `ScopedTokenClaims` by hand sets `grants`.
|
|
338
|
+
|
|
339
|
+
- **Breaking: `AppUpsertResult` has a new required field, `fieldsWithheld`.**
|
|
340
|
+
Code that builds one by hand, a test double for `records.upsert` for
|
|
341
|
+
instance, has to set it.
|
|
342
|
+
|
|
343
|
+
- **Breaking, with the platform release that carries it: only making a record
|
|
344
|
+
takes no read.** A write that finds a record already there is charged the
|
|
345
|
+
read of it as well. This is the platform's rule and reaches every caller,
|
|
346
|
+
whatever version of this package they run.
|
|
347
|
+
|
|
348
|
+
- `create` and `submit` (`@foir/sdk/data`) ask for the whole record by
|
|
349
|
+
default. A credential that may create records of a model and may not read
|
|
350
|
+
them is refused that, before anything is written, and told the read it
|
|
351
|
+
lacks. Pass `pick: ['_id']` to make the record and be answered with its id.
|
|
352
|
+
- `update`, `rename`, `delete`, `publish` and `unpublish` need
|
|
353
|
+
`records:read` for the model beside their own permission.
|
|
354
|
+
- An operation that declares the create of a model and not the read
|
|
355
|
+
(`@foir/sdk/app`) still makes its records. `records.upsert` landing on a
|
|
356
|
+
record already there, `records.customer.save` likewise, `records.delete`,
|
|
357
|
+
`records.refresh`, `records.rename` and `records.updateMany` are refused
|
|
358
|
+
until it declares the read too.
|
|
359
|
+
|
|
360
|
+
- **Breaking, with the platform release that carries it: `records.customer.erase`
|
|
361
|
+
needs the delete of the model** (`@foir/sdk/app`). An erase removes a record,
|
|
362
|
+
so the operation declares `records:delete` and `records:read` for the model,
|
|
363
|
+
and `users:create`. Until now the grant to save the record was enough. An
|
|
364
|
+
operation that erased on its write lane is refused until it declares the
|
|
365
|
+
delete. Nothing is taken from holding a write.
|
|
366
|
+
|
|
367
|
+
- **Breaking, with the platform release that carries it: a role in
|
|
368
|
+
`myOrganizations` carries `grants`** (`@foir/sdk/auth`).
|
|
369
|
+
`MyOrganizationRole.permissions` is gone and `MyOrganizationRole.grants`
|
|
370
|
+
takes its place: what the role gives, in the permission catalogue's names. A
|
|
371
|
+
grant stands for itself, so changing a record and creating one are two
|
|
372
|
+
names (`records:write`, `records:create`), reading every customer's records
|
|
373
|
+
is `records:read.all`, and staffing an organization is `members:read` and
|
|
374
|
+
`members:write`. Code that looked for `organizations:staff` or
|
|
375
|
+
`records:list.all` in a role's list looks for the new names.
|
|
376
|
+
|
|
377
|
+
- **Breaking, with the platform release that carries it: `customers.byIdentity`
|
|
378
|
+
and `customers.identities` need `identities:read`, declared by name**
|
|
379
|
+
(`@foir/sdk/app`). The read of every customer, `users:read.all`, no longer
|
|
380
|
+
includes them.
|
|
381
|
+
|
|
382
|
+
### Removed
|
|
383
|
+
|
|
384
|
+
- **Breaking: `APP_CAPABILITIES`, `APP_RECORD_CAPABILITY_PREFIXES`,
|
|
385
|
+
`AppCapability` and `AppRecordCapabilityPrefix`** (`@foir/sdk/app`). They
|
|
386
|
+
listed the names an operation was declared with before, which the platform
|
|
387
|
+
now refuses. `APP_GRANTS`, `APP_RECORD_GRANT_PREFIXES`, `AppGrant` and
|
|
388
|
+
`AppRecordGrantPrefix` list what it declares now.
|
|
389
|
+
|
|
390
|
+
## [0.19.0] — 2026-10-02
|
|
391
|
+
|
|
392
|
+
Published under npm's `next` tag from `dev`. The data client's change needs no
|
|
393
|
+
platform release. `setConsent` by email calls a field the platform serves from
|
|
394
|
+
the next promotion; `latest` moves to this version then.
|
|
395
|
+
|
|
396
|
+
### Changed
|
|
397
|
+
|
|
398
|
+
- **Breaking: schema drift by itself no longer refuses a typed call**
|
|
399
|
+
(`@foir/sdk/data`). What `strictSchemaDrift` refuses has changed; its name
|
|
400
|
+
and its default have not.
|
|
401
|
+
|
|
402
|
+
Before, a client built with a generated module refused every typed call,
|
|
403
|
+
with `FoirSchemaDriftError`, from the moment it saw the server serving another
|
|
404
|
+
surface version. That version is a hash over everything `foir types` reads,
|
|
405
|
+
so it moves whenever the surface grows. A field published on a model, or an
|
|
406
|
+
op the platform added to every model, made every deployed client refuse its
|
|
407
|
+
second typed call, though nothing it prints had changed.
|
|
408
|
+
|
|
409
|
+
Now the drift is reported once, exactly as before (`onSchemaDrift`, or the
|
|
410
|
+
`console.warn`), and it refuses nothing by itself. When the manifest arrives
|
|
411
|
+
the client compares its generated trees with the served ones, and refuses a
|
|
412
|
+
typed call only when something that call depends on is no longer served the
|
|
413
|
+
way the module describes it:
|
|
414
|
+
|
|
415
|
+
- its op is gone, or the op's root field or result kind changed;
|
|
416
|
+
- an argument the module declares is gone, or has a type the generated one no
|
|
417
|
+
longer fits, or the op now requires an argument the module does not know;
|
|
418
|
+
- a field its selection names is gone or changed shape, in the model the op
|
|
419
|
+
returns or in a union that model embeds;
|
|
420
|
+
- a relation the call includes changed target, became a list or a
|
|
421
|
+
connection, or its target model lost a field.
|
|
422
|
+
|
|
423
|
+
Anything added refuses nothing: a model, a namespace, an op, an optional
|
|
424
|
+
argument, a field, a relation, a union member, a subscription. An argument
|
|
425
|
+
that went from required to optional refuses nothing either.
|
|
426
|
+
|
|
427
|
+
`FoirSchemaDriftError`'s message now names what the refused call depends on
|
|
428
|
+
(`models.order.selection.status (no longer served)`), and still names
|
|
429
|
+
`foir types` as the remedy. The warning for a drift that broke nothing says
|
|
430
|
+
the surface has grown and that every typed call keeps working.
|
|
431
|
+
|
|
432
|
+
Unchanged: `strictSchemaDrift: false` turns refusal off entirely, the first
|
|
433
|
+
call on a client is never blocked on the manifest, `raw()` is never refused,
|
|
434
|
+
and `checkSchemaDrift: false` skips the fetch. `createFoirDriftHandler` in
|
|
435
|
+
`@foir/sdk/live` still resyncs on every drift, whether or not anything is
|
|
436
|
+
incompatible.
|
|
437
|
+
|
|
438
|
+
A client wired by hand with `surfaceVersion` and no `selections` prints from
|
|
439
|
+
the served manifest, so it has nothing older to compare and is no longer
|
|
440
|
+
refused on drift.
|
|
441
|
+
|
|
442
|
+
Migration: none for an app that only catches `FoirSchemaDriftError`. An app
|
|
443
|
+
that used the refusal to learn that its module was stale in ANY way should
|
|
444
|
+
read `onSchemaDrift`, which still fires for every drift, or gate on
|
|
445
|
+
`foir types --check` in CI.
|
|
446
|
+
- **Breaking: `FoirSchemaDrift` has a new required field, `incompatible`**
|
|
447
|
+
(`@foir/sdk/data`). Code that reads a drift is unaffected. Code that builds
|
|
448
|
+
one by hand, for example to test a handler given to `onSchemaDrift` or made by
|
|
449
|
+
`createFoirDriftHandler`, must add `incompatible: []`.
|
|
450
|
+
- **Breaking (types): `AppConsentChange` names its customer one way**
|
|
451
|
+
(`@foir/sdk/app`). It was an interface with a required `customerId`. It is
|
|
452
|
+
now a type that holds a `customerId` or an `email`, never both. A change
|
|
453
|
+
written as `{ customerId, ... }` is unaffected. An interface that extended
|
|
454
|
+
`AppConsentChange` no longer compiles: intersect with it instead.
|
|
455
|
+
|
|
456
|
+
### Added
|
|
457
|
+
|
|
458
|
+
- `notifications.setConsent` takes `email` in place of `customerId`
|
|
459
|
+
(`@foir/sdk/app`), for an opt-in from somebody who may have no customer yet,
|
|
460
|
+
such as a sign-up form. The platform finds the customer holding the address
|
|
461
|
+
at the install's level, or makes one, together with the change, so a change
|
|
462
|
+
it refuses (a category the project does not have, a confirmation email the
|
|
463
|
+
project cannot send) leaves no customer behind. Finding or making the
|
|
464
|
+
customer first with `customers.ensure` did. The operation must declare
|
|
465
|
+
`customers:write` as well as `notifications:consent`. `AppConsentSubject` is
|
|
466
|
+
exported.
|
|
467
|
+
|
|
468
|
+
- `FoirSchemaDrift.incompatible`: what the generated module describes that the
|
|
469
|
+
server no longer serves that way, as sorted paths into the manifest
|
|
470
|
+
(`models.order.selection.status`, `models.order.ops.update`,
|
|
471
|
+
`namespaces.search.ops.searchRecords.args.query`,
|
|
472
|
+
`models.order.relations.customer`, `selectionUnions.Block.HeroBlock.title`).
|
|
473
|
+
Empty when the surface has only grown. `changed` keeps its meaning: what
|
|
474
|
+
moved, not what broke.
|
|
475
|
+
|
|
111
476
|
## [0.18.0] — 2026-10-02
|
|
112
477
|
|
|
113
478
|
Published under npm's `next` tag from `dev`, because it calls fields the
|
package/README.md
CHANGED
|
@@ -88,7 +88,14 @@ was generated from: a hash over exactly what `foir types` reads, which moves onl
|
|
|
88
88
|
when regenerating would write a different file. Pushing an operation, a role or
|
|
89
89
|
a key moves the server's invalidation counter and not the surface, so it is not
|
|
90
90
|
drift. Publishing a field on a model the types include is, and the client
|
|
91
|
-
|
|
91
|
+
reports it once, naming the model.
|
|
92
|
+
|
|
93
|
+
**Drift does not stop a call.** A surface that has grown still answers every
|
|
94
|
+
document the module prints. A typed call is refused, with `FoirSchemaDriftError`,
|
|
95
|
+
only when something it depends on is no longer served as generated: a field its
|
|
96
|
+
selection names, its op, one of its arguments, or the target of a relation it
|
|
97
|
+
includes. The message names that part, and `drift.incompatible` lists every
|
|
98
|
+
such part as a path (`models.order.selection.status`). The remedy is
|
|
92
99
|
`foir types`; `strictSchemaDrift: false` never is.
|
|
93
100
|
|
|
94
101
|
**Scalars are always complete**, so `post.title` is typed present and *is*
|
package/dist/app/client.d.ts
CHANGED
|
@@ -144,11 +144,22 @@ export interface AppRecord {
|
|
|
144
144
|
unreadable: string[];
|
|
145
145
|
}
|
|
146
146
|
export interface AppUpsertResult {
|
|
147
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* The record as the platform holds it now. When `fieldsWithheld` is true it
|
|
149
|
+
* is only which record it is, its `id` and `modelKey`: see there.
|
|
150
|
+
*/
|
|
148
151
|
record: Record<string, unknown>;
|
|
149
152
|
/** The platform's own id for it, when the answer carried one. */
|
|
150
153
|
id: string | null;
|
|
151
154
|
created: boolean;
|
|
155
|
+
/**
|
|
156
|
+
* True when the save made a record the operation may not read: it declared
|
|
157
|
+
* the write for the model and not the read. Making a record takes no read, so
|
|
158
|
+
* the save worked, and its answer carries which record it made and nothing of
|
|
159
|
+
* it. The record is not empty. A save that lands on a record already there
|
|
160
|
+
* takes the read as well, and is refused without it.
|
|
161
|
+
*/
|
|
162
|
+
fieldsWithheld: boolean;
|
|
152
163
|
/** True when nothing was written because Foir already holds a newer copy (`sourceModifiedAt`). */
|
|
153
164
|
stale: boolean;
|
|
154
165
|
}
|
|
@@ -186,8 +197,56 @@ export interface AppNotification {
|
|
|
186
197
|
/** Makes the send safe to repeat: the same key from the same operation tells the customer once. */
|
|
187
198
|
idempotencyKey?: string;
|
|
188
199
|
}
|
|
189
|
-
|
|
200
|
+
/** An organisation an operation makes for one of its customers. */
|
|
201
|
+
export interface AppOrganizationProvision {
|
|
202
|
+
/**
|
|
203
|
+
* Yours to choose and to keep. Sending the same one again with the same
|
|
204
|
+
* request answers the same organisation and makes no second, so a run that
|
|
205
|
+
* stopped half way is finished by running it again. A different request
|
|
206
|
+
* under an id already used is refused.
|
|
207
|
+
*/
|
|
208
|
+
provisionId: string;
|
|
209
|
+
name: string;
|
|
210
|
+
/** The organisation this one is made beneath. Left out, it is made at the top of the project. */
|
|
211
|
+
parentId?: string;
|
|
212
|
+
/**
|
|
213
|
+
* The organisation's settings record, written with it: a record of the
|
|
214
|
+
* model the project keeps an organisation's settings in (a singleton on the
|
|
215
|
+
* organisation owner plane). The operation declares that model's write,
|
|
216
|
+
* `records:write:<modelKey>`, beside `organizations:create`. It is the one
|
|
217
|
+
* record of an organisation an operation writes without running in it.
|
|
218
|
+
*/
|
|
219
|
+
settings?: {
|
|
220
|
+
modelKey: string;
|
|
221
|
+
data: Record<string, unknown>;
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* The customer who becomes its first member. They are given the role the
|
|
225
|
+
* project names for an organisation's founder, in its settings: the
|
|
226
|
+
* operation chooses who, and the project decides what they hold.
|
|
227
|
+
*/
|
|
228
|
+
firstMember?: {
|
|
229
|
+
customerId: string;
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Who a consent change is for: a customer whose id the app holds, or an
|
|
234
|
+
* address.
|
|
235
|
+
*
|
|
236
|
+
* By `email`, the customer holding the address at the operation's level is
|
|
237
|
+
* found, or made, together with the change, so a change the platform refuses
|
|
238
|
+
* leaves no customer behind. It is for an opt-in from somebody who may have no
|
|
239
|
+
* customer yet (a sign-up form), and the operation must declare
|
|
240
|
+
* `users:create` as well as `notifications:consent`.
|
|
241
|
+
*/
|
|
242
|
+
export type AppConsentSubject = {
|
|
190
243
|
customerId: string;
|
|
244
|
+
email?: undefined;
|
|
245
|
+
} | {
|
|
246
|
+
email: string;
|
|
247
|
+
customerId?: undefined;
|
|
248
|
+
};
|
|
249
|
+
export type AppConsentChange = AppConsentSubject & {
|
|
191
250
|
/** A notification category the project makes opt-in. */
|
|
192
251
|
category: string;
|
|
193
252
|
/** The channel the consent is for, as the project's preferences name it (for example `email`). */
|
|
@@ -215,17 +274,18 @@ export interface AppConsentChange {
|
|
|
215
274
|
* email is written in it when the customer has stated none of their own.
|
|
216
275
|
*/
|
|
217
276
|
locale?: string;
|
|
218
|
-
}
|
|
277
|
+
};
|
|
219
278
|
/** What the calling token is, and what it is served. Read from the platform, never off the token. */
|
|
220
279
|
export interface AppWhoami {
|
|
221
280
|
/** The app the token acts for, when it acts for one. */
|
|
222
281
|
app: string | null;
|
|
223
|
-
/**
|
|
224
|
-
|
|
225
|
-
/** The models its record
|
|
282
|
+
/** What the token carries, as grants in the permission catalogue's names. */
|
|
283
|
+
grants: string[];
|
|
284
|
+
/** The models its record grants name, by permission. Changing a record and making one are two. */
|
|
226
285
|
models: {
|
|
227
286
|
read: string[];
|
|
228
287
|
write: string[];
|
|
288
|
+
create: string[];
|
|
229
289
|
delete: string[];
|
|
230
290
|
publish: string[];
|
|
231
291
|
};
|
|
@@ -248,17 +308,17 @@ export interface AppClient {
|
|
|
248
308
|
whoami(): Promise<AppWhoami>;
|
|
249
309
|
/** The install this token acts for: its configuration, its credential, its status. */
|
|
250
310
|
install: {
|
|
251
|
-
/** The operator's settings and mappings. Needs `
|
|
311
|
+
/** The operator's settings and mappings. Needs `appconfig:read`. */
|
|
252
312
|
config(): Promise<AppInstallConfig>;
|
|
253
313
|
/**
|
|
254
314
|
* The install's credential, by the manifest's credential field names.
|
|
255
|
-
* Values are always strings. Needs `
|
|
315
|
+
* Values are always strings. Needs `appsecrets:read`.
|
|
256
316
|
*/
|
|
257
317
|
credentials(): Promise<Record<string, string>>;
|
|
258
318
|
/**
|
|
259
319
|
* Write or rotate credential fields. An empty value is skipped, not
|
|
260
320
|
* cleared, and a call naming no non-empty field is refused. Needs
|
|
261
|
-
* `
|
|
321
|
+
* `appsecrets:put`.
|
|
262
322
|
*/
|
|
263
323
|
writeCredentials(values: Record<string, string>): Promise<void>;
|
|
264
324
|
/** Report the install's health. Needs `status:write`. See {@link AppStatusReport}. */
|
|
@@ -268,7 +328,9 @@ export interface AppClient {
|
|
|
268
328
|
records: {
|
|
269
329
|
/**
|
|
270
330
|
* Write the record at a natural key, making it when there is none.
|
|
271
|
-
*
|
|
331
|
+
* Changing the record there needs `records:write:<model>` and making one
|
|
332
|
+
* needs `records:create:<model>`, so an operation that does both declares
|
|
333
|
+
* both. `ownerId` names the customer it belongs to (needs
|
|
272
334
|
* `records:attribute:<model>`); `sourceModifiedAt` is when the object this
|
|
273
335
|
* mirrors last changed at its source, so an older copy arriving late is
|
|
274
336
|
* not written.
|
|
@@ -383,7 +445,9 @@ export interface AppClient {
|
|
|
383
445
|
/**
|
|
384
446
|
* Erase what the app holds for the customer, as a privacy request asks:
|
|
385
447
|
* their record and the identity's link, and the customer when nothing
|
|
386
|
-
* else is attached to them.
|
|
448
|
+
* else is attached to them. It removes a record, so it needs
|
|
449
|
+
* `records:delete` and `records:read` for the model, and
|
|
450
|
+
* `identities:delete`. The grant to save the record is not enough.
|
|
387
451
|
*/
|
|
388
452
|
erase(model: string, identity: string): Promise<AppCustomerEraseResult>;
|
|
389
453
|
};
|
|
@@ -392,7 +456,7 @@ export interface AppClient {
|
|
|
392
456
|
/**
|
|
393
457
|
* The customer who has this email at the install's level, made when there
|
|
394
458
|
* is none. The same customer every time for one email. Needs
|
|
395
|
-
* `
|
|
459
|
+
* `users:create`.
|
|
396
460
|
*
|
|
397
461
|
* `verified` says whether that address is one the customer has proved is
|
|
398
462
|
* theirs. A customer this call makes has not, and neither has one an
|
|
@@ -407,14 +471,14 @@ export interface AppClient {
|
|
|
407
471
|
}>;
|
|
408
472
|
/**
|
|
409
473
|
* One customer by id, or null when there is none at the install's level.
|
|
410
|
-
* Needs `
|
|
474
|
+
* Needs `users:read.named`, or `users:read.all`.
|
|
411
475
|
*/
|
|
412
476
|
get(id: string): Promise<AppCustomer | null>;
|
|
413
477
|
/**
|
|
414
478
|
* The customer who has this email at the install's level, or null. The
|
|
415
479
|
* exact address, as `ensure` matches it, and nothing wider: it finds one
|
|
416
|
-
* customer and never searches. Needs `
|
|
417
|
-
* `
|
|
480
|
+
* customer and never searches. Needs `users:read.named`, or
|
|
481
|
+
* `users:read.all`.
|
|
418
482
|
*/
|
|
419
483
|
byEmail(email: string): Promise<AppCustomer | null>;
|
|
420
484
|
/**
|
|
@@ -423,7 +487,8 @@ export interface AppClient {
|
|
|
423
487
|
* of its customers. The identity is the subject of the customer's sign-in
|
|
424
488
|
* with that system, and it is resolved through the customer sign-in the
|
|
425
489
|
* install pairs (`install.config().customerSignIn`) and no other. Never
|
|
426
|
-
* matched by email. Needs `
|
|
490
|
+
* matched by email. Needs `identities:read`, declared by name:
|
|
491
|
+
* `users:read.all` does not include it.
|
|
427
492
|
*/
|
|
428
493
|
byIdentity(identity: string): Promise<string | null>;
|
|
429
494
|
/**
|
|
@@ -431,13 +496,13 @@ export interface AppClient {
|
|
|
431
496
|
* holds at the app's own system, most recently seen first, through the
|
|
432
497
|
* same pairing. Empty when they have never signed in that way. For an app
|
|
433
498
|
* acting on an event that names the Foir customer. Needs
|
|
434
|
-
* `
|
|
499
|
+
* `identities:read`, declared by name.
|
|
435
500
|
*/
|
|
436
501
|
identities(customerId: string): Promise<string[]>;
|
|
437
502
|
/**
|
|
438
503
|
* The customers at the install's level, a page at a time, newest first.
|
|
439
|
-
* Needs `
|
|
440
|
-
* `
|
|
504
|
+
* Needs `users:read.all`: an operation declaring only
|
|
505
|
+
* `users:read.named` reads the customers it names and is refused the
|
|
441
506
|
* list.
|
|
442
507
|
*/
|
|
443
508
|
list(query?: AppCustomerList): Promise<{
|
|
@@ -446,8 +511,47 @@ export interface AppClient {
|
|
|
446
511
|
endCursor: string | null;
|
|
447
512
|
}>;
|
|
448
513
|
};
|
|
514
|
+
organizations: {
|
|
515
|
+
/**
|
|
516
|
+
* Make an organisation, with its settings record and a customer as its
|
|
517
|
+
* first member. Needs `organizations:create`, and for `settings` the write
|
|
518
|
+
* of the settings model. Sign-up is the use: one operation creates the
|
|
519
|
+
* customer (`customers.ensure`) and then the organisation that is theirs.
|
|
520
|
+
*
|
|
521
|
+
* The first member is given the role the project names for an
|
|
522
|
+
* organisation's founder. A project that names none refuses it, saying
|
|
523
|
+
* which setting to fill in.
|
|
524
|
+
*
|
|
525
|
+
* Safe to run again: the same `provisionId` with the same request answers
|
|
526
|
+
* the same organisation and the same settings record.
|
|
527
|
+
*/
|
|
528
|
+
provision(organization: AppOrganizationProvision): Promise<{
|
|
529
|
+
organizationId: string;
|
|
530
|
+
settingsRecordId?: string;
|
|
531
|
+
}>;
|
|
532
|
+
/**
|
|
533
|
+
* Schedule the deletion of the organisation this run acts in, with every
|
|
534
|
+
* organisation beneath it. Needs `organizations:delete`. It takes no
|
|
535
|
+
* organisation: the one whoever triggered the run stands in is the only
|
|
536
|
+
* one it reaches, and a run that acts in none is refused. So is a run
|
|
537
|
+
* that acts for nobody who is a member of that organisation.
|
|
538
|
+
*
|
|
539
|
+
* The organisation stops being active at once, so nothing that stands in
|
|
540
|
+
* it is served after this answers. Nothing is removed until
|
|
541
|
+
* `deletionScheduledAt`, and the project can resume it until then. This
|
|
542
|
+
* call cannot.
|
|
543
|
+
*/
|
|
544
|
+
scheduleDeletion(): Promise<{
|
|
545
|
+
organizationId: string;
|
|
546
|
+
deletionScheduledAt: string | null;
|
|
547
|
+
organizations: {
|
|
548
|
+
id: string;
|
|
549
|
+
name: string;
|
|
550
|
+
}[];
|
|
551
|
+
}>;
|
|
552
|
+
};
|
|
449
553
|
notifications: {
|
|
450
|
-
/** Tell a customer something. Needs `notifications:write`. */
|
|
554
|
+
/** Tell a customer something. Needs `notifications:write.named`. */
|
|
451
555
|
send(notification: AppNotification): Promise<void>;
|
|
452
556
|
/**
|
|
453
557
|
* Set a named customer's consent in an opt-in category. Needs
|
|
@@ -458,6 +562,10 @@ export interface AppClient {
|
|
|
458
562
|
* been sent a link, or was sent one recently, and the preference turns on
|
|
459
563
|
* when they follow it. The answer is the same whether or not the address
|
|
460
564
|
* already had a customer.
|
|
565
|
+
*
|
|
566
|
+
* Name the customer by `customerId`, or by `email` for somebody who may
|
|
567
|
+
* have no customer yet: the platform finds or makes them with the change,
|
|
568
|
+
* and that also needs `users:create`.
|
|
461
569
|
*/
|
|
462
570
|
setConsent(change: AppConsentChange): Promise<{
|
|
463
571
|
stale: boolean;
|
|
@@ -477,7 +585,7 @@ export interface AppClient {
|
|
|
477
585
|
*
|
|
478
586
|
* Only the token a delivery arrived with may call it, for an operation
|
|
479
587
|
* that is active at the install. An operation's own token is refused: it
|
|
480
|
-
* carries that operation's
|
|
588
|
+
* carries that operation's grants and no other's.
|
|
481
589
|
*/
|
|
482
590
|
start(operationKey: string, options?: AppOperationStart): Promise<AppStartedOperation>;
|
|
483
591
|
};
|