@argentic/chest-sdk 0.4.0 → 0.5.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/README.md +508 -80
- package/client/index.ts +11 -7
- package/client/src/api.ts +19 -18
- package/client/src/database.ts +6 -1
- package/client/src/errors.ts +50 -0
- package/client/src/eventrules.ts +117 -0
- package/client/src/events.ts +181 -39
- package/client/src/files.ts +37 -7
- package/client/src/member.ts +15 -9
- package/client/src/members.ts +34 -16
- package/client/src/notifications.ts +84 -27
- package/client/src/realtime-client.ts +516 -0
- package/client/src/realtime.ts +118 -0
- package/client/src/sealed.ts +158 -0
- package/client/src/signed.ts +8 -4
- package/client/src/testing-realtime.ts +361 -0
- package/client/src/testing.ts +391 -94
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/src/api.d.ts +2 -1
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +18 -16
- package/dist/src/api.js.map +1 -1
- package/dist/src/database.d.ts.map +1 -1
- package/dist/src/database.js +5 -1
- package/dist/src/database.js.map +1 -1
- package/dist/src/errors.d.ts +18 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +44 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/eventrules.d.ts +23 -0
- package/dist/src/eventrules.d.ts.map +1 -0
- package/dist/src/eventrules.js +107 -0
- package/dist/src/eventrules.js.map +1 -0
- package/dist/src/events.d.ts +31 -6
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +130 -26
- package/dist/src/events.js.map +1 -1
- package/dist/src/files.d.ts +1 -0
- package/dist/src/files.d.ts.map +1 -1
- package/dist/src/files.js +34 -3
- package/dist/src/files.js.map +1 -1
- package/dist/src/member.d.ts +1 -1
- package/dist/src/member.d.ts.map +1 -1
- package/dist/src/member.js +9 -6
- package/dist/src/member.js.map +1 -1
- package/dist/src/members.d.ts +9 -2
- package/dist/src/members.d.ts.map +1 -1
- package/dist/src/members.js +29 -13
- package/dist/src/members.js.map +1 -1
- package/dist/src/notifications.d.ts +12 -1
- package/dist/src/notifications.d.ts.map +1 -1
- package/dist/src/notifications.js +60 -19
- package/dist/src/notifications.js.map +1 -1
- package/dist/src/realtime-client.d.ts +45 -0
- package/dist/src/realtime-client.d.ts.map +1 -0
- package/dist/src/realtime-client.js +453 -0
- package/dist/src/realtime-client.js.map +1 -0
- package/dist/src/realtime.d.ts +22 -0
- package/dist/src/realtime.d.ts.map +1 -0
- package/dist/src/realtime.js +99 -0
- package/dist/src/realtime.js.map +1 -0
- package/dist/src/sealed.d.ts +20 -0
- package/dist/src/sealed.d.ts.map +1 -0
- package/dist/src/sealed.js +121 -0
- package/dist/src/sealed.js.map +1 -0
- package/dist/src/signed.d.ts.map +1 -1
- package/dist/src/signed.js +6 -2
- package/dist/src/signed.js.map +1 -1
- package/dist/src/testing-realtime.d.ts +54 -0
- package/dist/src/testing-realtime.d.ts.map +1 -0
- package/dist/src/testing-realtime.js +376 -0
- package/dist/src/testing-realtime.js.map +1 -0
- package/dist/src/testing.d.ts +40 -3
- package/dist/src/testing.d.ts.map +1 -1
- package/dist/src/testing.js +368 -81
- package/dist/src/testing.js.map +1 -1
- package/package.json +23 -4
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# Chest SDK
|
|
2
2
|
|
|
3
|
-
`@argentic/chest-sdk` is what a server tool (tool contract 0.
|
|
3
|
+
`@argentic/chest-sdk` is what a server tool (tool contract 0.5) embeds to talk
|
|
4
4
|
with its Chest: the member the Chest asserts on a request, the Chest itself
|
|
5
5
|
(its organization, time zone, language and currency, and where the tool is
|
|
6
6
|
reached), the other members who have the tool, the address of the tool's own
|
|
7
7
|
database, its private files, the badges and notifications it shows members
|
|
8
|
-
inside the Chest, the events of its members' lifecycle
|
|
9
|
-
|
|
8
|
+
inside the Chest, the events of its members' lifecycle and between tools, AI
|
|
9
|
+
models through the Chest, live updates of its members' pages (with their
|
|
10
|
+
browser client) — and, for the tool's tests, a fake Chest. It also publishes the tool
|
|
10
11
|
contract ([`contract/`](contract/README.md): what `chest.json` may say, what
|
|
11
12
|
the Chest builds, what migrations may do, the policies it adds) and `chest
|
|
12
13
|
check`, the Chest's own validator. The SDK has no dependency: it only
|
|
@@ -21,24 +22,28 @@ Node 22 or later. ESM only, compiled JavaScript with its type declarations.
|
|
|
21
22
|
## Imports
|
|
22
23
|
|
|
23
24
|
Each module is its own subpath and pulls in nothing else; the root gives them
|
|
24
|
-
all, with the files, members, notifications, events, schedules
|
|
25
|
-
as the namespaces `
|
|
26
|
-
|
|
25
|
+
all, with the sealed, files, members, notifications, events, schedules, ai
|
|
26
|
+
and realtime APIs as the namespaces `sealed`, `files`, `members`,
|
|
27
|
+
`notifications`, `events`, `schedules`, `ai` and `realtime` (the browser
|
|
28
|
+
client and the testing module are not in the root).
|
|
27
29
|
|
|
28
30
|
| Import | Gives |
|
|
29
31
|
|---|---|
|
|
30
32
|
| `@argentic/chest-sdk/member` | `member(request)`, type `Member`: the member of a request on the team host of a server tool, with the language the Chest speaks to them and the zone they work in, read from the `Chest-Member` assertion and verified; `null` without a valid assertion. `memberIdPattern`, `groupIdPattern`, `languagePattern`, `timeZonePattern`: the grammars of the identifiers (`mbr_…`, `grp_…`), of a language and of a zone |
|
|
31
33
|
| `@argentic/chest-sdk/chest` | `chest`, type `Chest`: the Chest the tool runs in — `chest.organization.name`, `chest.timeZone`, `chest.language`, `chest.currency`, `chest.today()` — and where the tool is reached — `chest.tool.teamUrl`, `chest.tool.publicUrl` —, the same for every member, on a request or outside one |
|
|
32
|
-
| `@argentic/chest-sdk/members` | `list`, `get`, `lookup`, `groups.list`, `forget`, types `MemberPage`, `Lookup`, `FormerMember`, `Group`: the members who have the tool (capability `members`, their addresses with `members.email`) |
|
|
33
|
-
| `@argentic/chest-sdk/notifications` | `notify`, `withdraw`, `badge.set`, `badge.setMany`, types `Notice`, `Delivery`, `BadgeCount`, `BadgeWrite`: counters on the tool's tile and items in members' inboxes, inside the Chest (capability `notifications`) |
|
|
34
|
-
| `@argentic/chest-sdk/events` | `handle`, `verify`, `acknowledgeErasure`, `memorySeen`, `erasureIdPattern`, types `ChestEvent`, `MemberUpdated`, `AccessRevoked`, `MemberRemoved`, `MemberErased`, `MemberChange`, `Handlers`, `Seen`: the events of the members' lifecycle
|
|
34
|
+
| `@argentic/chest-sdk/members` | `list`, `get`, `lookup`, `groups.list`, `forget`, types `MemberPage`, `Lookup`, `FormerMember`, `Group`: the members who have the tool (capability `members`, their addresses with `members.email`, every group of the Chest with `members.groups`) |
|
|
35
|
+
| `@argentic/chest-sdk/notifications` | `notify`, `broadcast`, `withdraw`, `badge.set`, `badge.setMany`, types `Notice`, `Words`, `Audience`, `Delivery`, `BadgeCount`, `BadgeWrite`: counters on the tool's tile and items in members' inboxes, inside the Chest (capability `notifications`) |
|
|
36
|
+
| `@argentic/chest-sdk/events` | `handle`, `verify`, `emit`, `acknowledgeErasure`, `memorySeen`, `erasureIdPattern`, types `ChestEvent`, `ChestEventType`, `MemberUpdated`, `AccessRevoked`, `MemberRemoved`, `MemberErased`, `MemberChange`, `ToolEvent`, `ToolEventType`, `ReceivedEvent`, `Handler`, `Handlers`, `EmitOptions`, `EmitAudience`, `Emitted`, `Seen`: the events of the members' lifecycle (`"receives": ["member.*"]`) and of other tools (`"receives": ["quote.accepted"]`) the Chest posts to the tool's `/chest-events`, verified, deduplicated by id; the events the tool tells the others (`"emits"`); and the acknowledgment of an erasure |
|
|
35
37
|
| `@argentic/chest-sdk/schedules` | `handle`, `verify`, types `Run`, `Handlers`, `Seen`: the runs of the tool's schedules (`"schedules"` in `chest.json`) the Chest posts to its `/chest-schedules` at their times, verified, deduplicated by id |
|
|
36
38
|
| `@argentic/chest-sdk/ai` | `chat`, `embed`, `models`, `usage`, types `Alias`, `Provider`, `ChatMessage`, `ChatTool`, `ToolChoice`, `ResponseFormat`, `ChatOptions`, `ChatResult`, `ChatChunk`, `ToolCall`, `ToolCallDelta`, `Usage`, `EmbedOptions`, `Embeddings`, `AiModel`, `AiUsage`: AI models through the Chest, on the owner's connectors, metered against the tool's monthly cap (capability `ai`) |
|
|
39
|
+
| `@argentic/chest-sdk/realtime` | `publish`, `send`, `online`, `presence`, `channelPattern`, `eventPattern`, type `Present`: live updates of the members' pages, the Chest holding their connections (capability `realtime`, the key `realtime` of `chest.json`) |
|
|
40
|
+
| `@argentic/chest-sdk/realtime/client` | `connect`, `peerEventPattern`, types `Live`, `Channel`, `Listener`, `EventInfo`, `PeerListener`, `RefusedListener`, `Present`, `ClosedReason`: **the browser's side**, the one module that runs in a page — it joins the tool's channels on the Chest and hears its feeds' rows and its events, apart from the other members' messages, and who is present; it tells the Chest which channel the member has on screen |
|
|
37
41
|
| `@argentic/chest-sdk/database` | `databaseUrl()`: the address of the tool's own PostgreSQL database (capability `database`) |
|
|
38
|
-
| `@argentic/chest-sdk/
|
|
39
|
-
| `@argentic/chest-sdk/
|
|
40
|
-
| `@argentic/chest-sdk/
|
|
41
|
-
| `@argentic/chest-sdk` |
|
|
42
|
+
| `@argentic/chest-sdk/sealed` | `seal`, `sealMany`, `open`, `openMany`, `isSealed`, types `SealOptions`, `SealItem`, `OpenItem`: sensitive values the Chest seals under a key of the tool and opens again only for the member of a request — and only one holding a role the value was sealed for (capability `sealed`) |
|
|
43
|
+
| `@argentic/chest-sdk/files` | `put`, `get`, `stat`, `list`, `move`, `delete`, `url`, `uploadUrl`, types `FileObject`, `FileData`, `FilePage`: the tool's private files (capability `files`), kept by the Chest, a 15-minute signed link to one (or to its thumbnail), and uploads straight from a member's or a visitor's browser |
|
|
44
|
+
| `@argentic/chest-sdk/errors` | `ChestError` (`code`, `status`), `CapabilityNotGranted` (403), `TooLarge` (413), `QuotaExceeded` (429), `RateLimited` (429), `StorageFull` (507), `Unavailable` (503), for sealed values `MemberRequired` (401), `NotAllowed` (403), `SealedInvalid` (400), `SealedLocked` (503), `SealedLost` (503), and for AI `AiCapReached` (402), `AiModelNotAllowed` (403), `AiRefused` (422), `AiUnavailable` (502, 503), type `AiUnavailableReason`: what the SDK throws when the Chest does not give what a tool asks |
|
|
45
|
+
| `@argentic/chest-sdk/testing` | `signAssertion`, `withMember`, `fakeChest`, types `AssertionOptions`, `FakeChest`, `FakeChestOptions`, `FakeGroup`, `FakeFile`, `FakeFormer`, `FakeNotification`, `FakeEvent`, `FakeEmits`, `FakeEmitted`, `FakeRun`, `FakeAi`, `FakeAiModel`, `FakeAiReply`, `FakeAiCall`, `FakeOpen`, `FakeRealtime`, `FakeRealtimeOptions`, `FakeChannelRule`, `FakeFeed`, `FakePublished`, `FakeSent`: for the tool's own tests only |
|
|
46
|
+
| `@argentic/chest-sdk` | all of the above but `realtime/client` and `testing`; `sealed`, `files`, `members`, `notifications`, `events`, `schedules`, `ai` and `realtime` as namespaces |
|
|
42
47
|
|
|
43
48
|
```ts
|
|
44
49
|
import { member } from "@argentic/chest-sdk/member";
|
|
@@ -66,7 +71,8 @@ Types refer to `node:http` (`IncomingMessage`): a TypeScript project needs
|
|
|
66
71
|
The SDK runs on the server only — it reads the tool's environment
|
|
67
72
|
(`CHEST_TOKEN`, `DATABASE_URL`, `CHEST_API`, `CHEST_TIME_ZONE`…) and uses Node built-ins. Import it
|
|
68
73
|
in route handlers, server components or server actions, never in a
|
|
69
|
-
`"use client"` module
|
|
74
|
+
`"use client"` module — but `@argentic/chest-sdk/realtime/client`, the
|
|
75
|
+
browser's side of live updates, which is the one module made for one. Webpack and Turbopack resolve the
|
|
70
76
|
compiled package with no configuration (no `transpilePackages`):
|
|
71
77
|
|
|
72
78
|
```ts
|
|
@@ -84,7 +90,7 @@ export function GET(request: Request) {
|
|
|
84
90
|
A tool is an ordinary web server in a container without network, run by
|
|
85
91
|
its Chest. The Chest's front is the only one to reach it; the tool reaches only
|
|
86
92
|
what its launcher gives it on `127.0.0.1` (its database, the Chest's API for
|
|
87
|
-
its files, its members, its notifications and AI), and the Chest posts it the
|
|
93
|
+
its files, its members, its notifications, its events and AI), and the Chest posts it the
|
|
88
94
|
events it receives on `/chest-events` and the runs of its schedules on
|
|
89
95
|
`/chest-schedules`, through the same launcher. Rights come
|
|
90
96
|
from the Chest — the signed member, the capabilities approved for the
|
|
@@ -121,7 +127,7 @@ the archive it is given, and needs no network and no Chest. Details:
|
|
|
121
127
|
[`contract/README.md`](contract/README.md#check-a-repository).
|
|
122
128
|
|
|
123
129
|
`chest.json` names the version of the contract the tool is written for,
|
|
124
|
-
`"chest": "0.
|
|
130
|
+
`"chest": "0.5"` — the MAJOR.MINOR of this SDK. A Chest older than that
|
|
125
131
|
refuses the tool with “This tool needs a newer version of your Chest”;
|
|
126
132
|
up to its own version, a key it does not know is refused, never ignored.
|
|
127
133
|
|
|
@@ -143,7 +149,9 @@ type Member = {
|
|
|
143
149
|
role: string | null; // one of the roles chest.json declares; null if it declares none
|
|
144
150
|
isAdmin: boolean; // owner or admin of the Chest
|
|
145
151
|
isBuilder: boolean; // builder of this tool
|
|
146
|
-
groups: string[];
|
|
152
|
+
groups: string[] | null; // "grp_…": the groups that give the member this tool —
|
|
153
|
+
// all of the member's groups with "members.groups";
|
|
154
|
+
// null when more than travel with a request (below)
|
|
147
155
|
language: string; // "en", "fr"…: the language the Chest speaks to this member
|
|
148
156
|
timeZone: string; // "America/New_York": the zone the member works in
|
|
149
157
|
email?: string; // only with the capability "members.email"
|
|
@@ -159,7 +167,11 @@ the Chest's derivation; the label changes when a claim changes meaning or
|
|
|
159
167
|
goes, so an assertion of another shape is refused rather than misread, and
|
|
160
168
|
stays when a claim is added —, `aud` equal to
|
|
161
169
|
`CHEST_TOOL`, `iat` and `exp` within 5 s, the shape of each claim (`sub` an
|
|
162
|
-
`mbr_` identifier, `groups` `grp_` identifiers, `
|
|
170
|
+
`mbr_` identifier, `groups` `grp_` identifiers — or, instead, `groups_overage`
|
|
171
|
+
for a member in more groups than an assertion carries: about 150, the
|
|
172
|
+
assertion keeping within half of a Node server's 16 KiB of request headers;
|
|
173
|
+
`member.groups` is then `null` and the tool reads them with
|
|
174
|
+
`members.get(member.id)`, as Microsoft's tokens send a groups overage —, `language` a primary tag of
|
|
163
175
|
2 or 3 lowercase letters, `time_zone` a zone of `timeZonePattern`; an
|
|
164
176
|
unknown claim is ignored). Without
|
|
165
177
|
`CHEST_TOKEN` or `CHEST_TOOL`, nobody is a member. The function never throws
|
|
@@ -271,14 +283,18 @@ permission: “Sees the name, photo, role and groups of the members who have
|
|
|
271
283
|
access to it.”) reads the members who have it, through the Chest's API
|
|
272
284
|
(`CHEST_API`, as for files). `"members.email"`, a permission of its own that
|
|
273
285
|
requires `members`, adds their addresses — to these answers and to
|
|
274
|
-
`member(request)`.
|
|
286
|
+
`member(request)`. `"members.groups"`, another permission that requires
|
|
287
|
+
`members` (“Sees all the Chest's groups, and which of the members who have
|
|
288
|
+
access to it are in each.”), widens the groups the tool sees from those that
|
|
289
|
+
give it to every group of the Chest — in `groups.list()`, in `Member.groups`
|
|
290
|
+
here and in `member(request)`.
|
|
275
291
|
|
|
276
292
|
```ts
|
|
277
293
|
import * as members from "@argentic/chest-sdk/members";
|
|
278
294
|
const { members: page, next } = await members.list({ q: "cam", limit: 50 }); // by name, then id
|
|
279
295
|
const camille = await members.get("mbr_k2qhx4mzc7v3b6nfp5r2t7w4ya"); // Member, or null
|
|
280
296
|
const { members: found, former, unknown } = await members.lookup(ids); // any number of ids
|
|
281
|
-
const teams = await members.groups.list();
|
|
297
|
+
const { groups: teams, next: more } = await members.groups.list(); // [{id, name, size}], a page
|
|
282
298
|
```
|
|
283
299
|
|
|
284
300
|
- **Who**: exactly the members who have the tool now — by a grant, a group,
|
|
@@ -304,8 +320,19 @@ const teams = await members.groups.list(); // [
|
|
|
304
320
|
keeps each answer a minute in the process (5,000 at most); `forget()`
|
|
305
321
|
empties it, and so does every event of the members' lifecycle
|
|
306
322
|
(`events.handle`).
|
|
307
|
-
- **`groups.list()`**: the groups
|
|
308
|
-
|
|
323
|
+
- **`groups.list({after, limit})`**: the groups the tool sees — those that
|
|
324
|
+
give it, or every group of the Chest with `members.groups` —, a page at a
|
|
325
|
+
time like `list` (by name then identifier, 100 by default, 500 at most,
|
|
326
|
+
`next` the cursor of the next page), each `{id, name, size}`: `size` is
|
|
327
|
+
how many of its members have the tool, and `list({group: id})` pages
|
|
328
|
+
them (never anyone without access). No count bounds a team: a Chest
|
|
329
|
+
holds as many members and groups as its capacity, and every list is
|
|
330
|
+
read page after page. A tool open to everyone (news, polls, a wiki)
|
|
331
|
+
declares `members.groups` to offer “the Sales team”; `member.groups` then
|
|
332
|
+
answers “is she in Sales?” without a call. Store group identifiers and
|
|
333
|
+
resolve names when rendering, as for members: a renamed group needs
|
|
334
|
+
nothing, and someone who joins or leaves a group the tool sees — a group
|
|
335
|
+
deleted included — is `member.updated` naming `groups`.
|
|
309
336
|
- Errors: `CapabilityNotGranted` (403), `RateLimited` (429: 600 calls a minute
|
|
310
337
|
per instance), `Unavailable` (503), `ChestError` for the rest (`invalid_id`,
|
|
311
338
|
`invalid_query`).
|
|
@@ -336,7 +363,10 @@ To search tasks by assignee name: `members.list({ q })` first, then
|
|
|
336
363
|
A tool that declares `"capabilities": ["notifications"]` (approved like a
|
|
337
364
|
permission: “Shows counters and sends notifications, inside the Chest, to the
|
|
338
365
|
members who have access to it.”) tells its members what needs their
|
|
339
|
-
attention, inside the Chest
|
|
366
|
+
attention, inside the Chest — no push to a phone. Members may also get mails
|
|
367
|
+
of their notifications, by their own choice in their profile (each one,
|
|
368
|
+
once or twice a day, or none): that is the Chest's, and the tool does
|
|
369
|
+
nothing for it — no call, no permission, no member's address. Two
|
|
340
370
|
primitives:
|
|
341
371
|
|
|
342
372
|
- a **badge** is a count on the tool's tile in the Chest home and on its row
|
|
@@ -354,7 +384,12 @@ const { delivered, skipped } = await notifications.notify([assignee], {
|
|
|
354
384
|
body: "Before Friday.\nKeys at the desk.", // 280 characters at most; optional
|
|
355
385
|
path: "/chest/tasks/42", // under /chest; /chest when not said
|
|
356
386
|
key: "task:42", // optional: replace, then withdraw
|
|
387
|
+
translations: { fr: { title: "Nouvelle tâche : réparer la porte" } }, // optional
|
|
357
388
|
});
|
|
389
|
+
await notifications.broadcast( // everyone who has the tool, but the author
|
|
390
|
+
{ title: "New poll: the summer party", path: "/chest/polls/7", key: "poll:7" },
|
|
391
|
+
{ to: { groups: [sales] }, except: [author] }, // or roles: ["editor"]; leave to out for everyone
|
|
392
|
+
);
|
|
358
393
|
await notifications.withdraw("task:42"); // done: its items go, for everyone
|
|
359
394
|
await notifications.withdraw("task:42", [assignee]); // only for those
|
|
360
395
|
const shown = await notifications.badge.set(assignee, 3); // false: no access
|
|
@@ -369,18 +404,37 @@ const { set, skipped: noAccess } = await notifications.badge.setMany([
|
|
|
369
404
|
given; `skipped` holds identifiers the Chest does not know and members
|
|
370
405
|
without access (as for `members`, the two are indistinguishable). `badge.set`
|
|
371
406
|
answers `false` for such a member, `setMany` puts them in `skipped`.
|
|
372
|
-
- **`notify(memberIds, {title, body?, path?, key?})`**:
|
|
373
|
-
(a duplicate counts once), one inbox item
|
|
407
|
+
- **`notify(memberIds, {title, body?, path?, key?, translations?})`**: the
|
|
408
|
+
identifiers given, one at least (a duplicate counts once), one inbox item
|
|
409
|
+
per recipient; no count bounds them but the team's size — the Chest takes
|
|
410
|
+
a body that names each member once, and refuses one beyond
|
|
411
|
+
(`invalid_body`). `title` is 1 to 80
|
|
374
412
|
characters (Unicode code points), `body` 280 at most (an empty body is
|
|
375
413
|
none). `path` is a page of the tool's private part: `/chest`, or `/chest`
|
|
376
414
|
followed by `/`, `?` or `#`; printable ASCII without spaces or `\`, 512
|
|
377
415
|
characters at most, never `//`, no `.` or `..` segment; the Chest builds the
|
|
378
416
|
link on the tool's team host, so it cannot point anywhere else. `key` is
|
|
379
417
|
1 to 64 of `a-z 0-9 . _ : -`.
|
|
418
|
+
- **`broadcast(notice, {to?, except?})`**: one item, in each member's
|
|
419
|
+
language, for every member who has the tool now — resolved by the Chest:
|
|
420
|
+
the tool needs not see its members —, or with `to` those in any of its
|
|
421
|
+
`groups` **or** holding any of its `roles` (the 16 it declares at most);
|
|
422
|
+
`except` leaves members out — the author, those who already answered. A
|
|
423
|
+
group the tool does not see (one that does not give it, without
|
|
424
|
+
`members.groups`, or deleted) and a role it does not declare reach no one,
|
|
425
|
+
and the call answers nothing — not even how many received it: a tool that
|
|
426
|
+
wants to say “sent to 42 people” counts with `members`. A key and
|
|
427
|
+
`withdraw` work as for `notify`. Who may trigger a broadcast in the tool is
|
|
428
|
+
the tool's rule (its roles).
|
|
429
|
+
- **Languages.** `translations` gives the same `title` and `body` in other
|
|
430
|
+
languages, by language tag (`fr`, 2 or 3 lowercase letters), each bounded
|
|
431
|
+
as the original: each recipient reads the one of `member.language`, the
|
|
432
|
+
tool's own words otherwise — for `notify` and `broadcast` alike, so a tool
|
|
433
|
+
never groups its recipients by language.
|
|
380
434
|
- **Replace and withdraw.** A notification with the key of an earlier one, for
|
|
381
435
|
the same member, replaces it: new text, new time, first in the inbox and
|
|
382
436
|
unread again — never a duplicate. `withdraw(key, memberIds?)` removes the
|
|
383
|
-
items of that key, from every member or from those named
|
|
437
|
+
items of that key, from every member or from those named, once
|
|
384
438
|
the thing they were about is done. It never says what existed.
|
|
385
439
|
- **Plain text.** The Chest removes control characters (a tab or a line break
|
|
386
440
|
in a title becomes a space; `body` keeps its line breaks) and the characters
|
|
@@ -390,32 +444,50 @@ const { set, skipped: noAccess } = await notifications.badge.setMany([
|
|
|
390
444
|
- **Muting is invisible.** A member may mute the tool in their profile: their
|
|
391
445
|
new items are then dropped, but they still count as `delivered`, and their
|
|
392
446
|
badges stay. The tool never learns who muted it.
|
|
393
|
-
- **Badges** go from 0 to 9,999, 0 clears one. `setMany` takes
|
|
394
|
-
member at most once.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
447
|
+
- **Badges** go from 0 to 9,999, 0 clears one. `setMany` takes one at least,
|
|
448
|
+
a member at most once. A badge is a state: the last write wins, never
|
|
449
|
+
refused.
|
|
450
|
+
- **Pace, never a refusal.** A tool's notices to a member go at a normal
|
|
451
|
+
pace — ten at once, then one every six minutes, per tool and member;
|
|
452
|
+
beyond, of `notify` and `broadcast` alike, the Chest folds them into the
|
|
453
|
+
tool's one grouped item in that member's inbox: “37 new notifications”
|
|
454
|
+
above the latest's title, opening the latest's path, unread again — and
|
|
455
|
+
one line of the member's next mail, if they get mails. The call answers as
|
|
456
|
+
ever, nothing is lost, and a burst however fast takes one item. A notice
|
|
457
|
+
whose `key` names an item replaces it, never folded; a folded notice keeps
|
|
458
|
+
no key (`withdraw` cannot reach it). Only a malformed or oversized call is
|
|
459
|
+
refused.
|
|
400
460
|
- **Lifecycle**: a member who loses access loses the tool's items and badge;
|
|
401
461
|
removing the tool removes them all. A member's inbox keeps 500 items for 90
|
|
402
462
|
days.
|
|
403
|
-
- Errors: `CapabilityNotGranted` (403), `
|
|
463
|
+
- Errors: `CapabilityNotGranted` (403), `Unavailable`
|
|
404
464
|
(503, the Chest not reached, or an answer that is not its own: the call may
|
|
405
465
|
or may not have happened), `ChestError` for the rest (`invalid_id`,
|
|
406
|
-
`invalid_title`, `invalid_text`, `invalid_path`,
|
|
407
|
-
`invalid_count`, `invalid_body`) — the
|
|
408
|
-
anything.
|
|
466
|
+
`invalid_role`, `invalid_title`, `invalid_text`, `invalid_path`,
|
|
467
|
+
`invalid_key`, `invalid_language`, `invalid_count`, `invalid_body`) — the
|
|
468
|
+
SDK refuses these before sending anything.
|
|
409
469
|
|
|
410
470
|
A badge suits a count that goes up and down (tasks assigned, messages
|
|
411
471
|
unread); a notification, an event worth a look — with a key, so that it goes
|
|
412
472
|
away by itself once handled.
|
|
413
473
|
|
|
414
|
-
## `events` — the members' lifecycle
|
|
474
|
+
## `events` — the members' lifecycle, and events between tools
|
|
475
|
+
|
|
476
|
+
The Chest posts two kinds of events to the tool's own `POST /chest-events`,
|
|
477
|
+
signed for it, on one route and through one `handle`:
|
|
478
|
+
|
|
479
|
+
- **the members' lifecycle**, for a tool that holds `members` and declares
|
|
480
|
+
`"receives": ["member.*"]`;
|
|
481
|
+
- **events between tools**: what another tool of the Chest tells
|
|
482
|
+
(`quote.accepted` from a quotes tool), for a tool whose `"receives"`
|
|
483
|
+
names the type. And a tool tells the others with `emit`, for the types
|
|
484
|
+
its `"emits"` declares.
|
|
485
|
+
|
|
486
|
+
### The members' lifecycle
|
|
415
487
|
|
|
416
488
|
A tool that holds `members` and declares `"receives": ["member.*"]` in its
|
|
417
489
|
`chest.json` (approved like a permission: “Is told when the members who have
|
|
418
|
-
access to it change or leave.”) is told
|
|
490
|
+
access to it change or leave.”) is told:
|
|
419
491
|
|
|
420
492
|
| Event | `data` | When |
|
|
421
493
|
|---|---|---|
|
|
@@ -424,7 +496,8 @@ access to it change or leave.”) is told, on its own `POST /chest-events`:
|
|
|
424
496
|
| `member.removed` | `{id}` | The member left the Chest: `lookup` now reads them `former` |
|
|
425
497
|
| `member.erased` | `{id, erasure, deadline}` | The owner asked for this person's data to be erased: delete or anonymise what the tool keeps of them before `deadline` (30 days), then `acknowledgeErasure(erasure)` |
|
|
426
498
|
|
|
427
|
-
A member who gets the tool is no event: the next `list` has them.
|
|
499
|
+
A member who gets the tool is no event: the next `list` has them. Every
|
|
500
|
+
member event empties what `members.lookup` keeps.
|
|
428
501
|
|
|
429
502
|
```jsonc
|
|
430
503
|
// chest.json
|
|
@@ -453,35 +526,140 @@ export async function POST(request: Request) {
|
|
|
453
526
|
}
|
|
454
527
|
```
|
|
455
528
|
|
|
456
|
-
- **Delivery**: at least once, in no guaranteed order. An event is an
|
|
457
|
-
envelope `{id: "evt_…", type, occurredAt, data}`, signed for this tool
|
|
458
|
-
(`Chest-Event` header, HS256 under a key derived from `CHEST_TOKEN` with
|
|
459
|
-
the label `Chest-Event v1`, naming the event and the SHA-256 of the body,
|
|
460
|
-
60 seconds). Any answer but a 2xx is delivered again, the same event with
|
|
461
|
-
the same id, after 5 s, 15 s, 30 s, 1 min, 2 min, 5 min, 10 min, 30 min, then
|
|
462
|
-
every hour, for 72 hours — a restart of the node included. Given up, the
|
|
463
|
-
tool is marked “out of sync” on its page until its next start.
|
|
464
529
|
- **`members.list` is the truth.** Reconcile by listing at start (and so after
|
|
465
530
|
being out of sync): events keep a tool current between starts, they do not
|
|
466
531
|
replace reading who has it.
|
|
467
|
-
- **`handle(request, handlers, {seen?})`** answers the status to give the
|
|
468
|
-
Chest: 401 for what is not a delivery of the Chest for this tool, 204 for an
|
|
469
|
-
event handled, one already in `seen`, a type without a handler, or one of a
|
|
470
|
-
later Chest (signed, ignored). It reads the body (64 KiB at most): mount it
|
|
471
|
-
before any body parser. A handler that throws leaves the event unseen and
|
|
472
|
-
`handle` throws: answer 500, it comes again. `seen` is the store of the
|
|
473
|
-
handled ids — `memorySeen()` (the default: 10,000 ids in the process, lost
|
|
474
|
-
at a restart) or a table of the tool's own, as above. Make handlers
|
|
475
|
-
idempotent anyway: an event handled but not yet added to `seen` when the
|
|
476
|
-
tool stops comes again.
|
|
477
|
-
- **`verify(request)`** is the event of a delivery, typed, or `null`; for a
|
|
478
|
-
tool that routes events itself.
|
|
479
532
|
- **`acknowledgeErasure(erasure)`**: `POST /erasures/{erasure}/done` on the
|
|
480
533
|
Chest's API; the owner then sees the tool's part done (“Erased on 3 Oct.”),
|
|
481
534
|
“Overdue” past the deadline otherwise. Again is harmless. Errors:
|
|
482
535
|
`ChestError` `erasure_not_found` (404: an erasure this tool was not told of)
|
|
483
536
|
or `invalid_id` (400), `CapabilityNotGranted` (403), `Unavailable`.
|
|
484
537
|
|
|
538
|
+
### Events between tools
|
|
539
|
+
|
|
540
|
+
One tool reacts to what happens in another: a quote accepted in Quotes
|
|
541
|
+
creates a project in Tasks. The publisher says what it tells, each type in
|
|
542
|
+
a sentence with the fields of its data; the receiver says which types it
|
|
543
|
+
wants, from whichever tool tells them. The owner approves each link when
|
|
544
|
+
the second of the two tools is installed (“Quotes → Tasks: A quote is
|
|
545
|
+
accepted — quote, client, total”); an admin can switch a link off.
|
|
546
|
+
|
|
547
|
+
```jsonc
|
|
548
|
+
// chest.json of Quotes
|
|
549
|
+
{ "emits": { "quote.accepted": { "description": "A quote is accepted",
|
|
550
|
+
"data": { "quote": "id", "client": "text", "total": "number", "acceptedBy": "member", "note": "text?" } } } }
|
|
551
|
+
|
|
552
|
+
// chest.json of Tasks: types, never tools
|
|
553
|
+
{ "receives": ["member.*", "quote.accepted"] }
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
- **A type** is two to four dotted segments of lowercase letters, digits
|
|
557
|
+
and dashes, each starting with a letter (`quote.accepted`, `hire.made`),
|
|
558
|
+
64 characters at most; `member.*` and `access.*` are the Chest's. A new
|
|
559
|
+
shape of data is a new type (`quote.accepted.v2`): a type's data only
|
|
560
|
+
grows.
|
|
561
|
+
- **Fields** are camelCase names of a kind: `id` (an identifier of the
|
|
562
|
+
tool's own: 1 to 128 letters, digits, `.`, `_`, `:`, `-`), `text` (no
|
|
563
|
+
control character but line feeds and tabs, no format character), `number`,
|
|
564
|
+
`boolean`, `time` (an RFC 3339 instant), `date` (`YYYY-MM-DD`), `member` (a
|
|
565
|
+
member id), `members` (a list of distinct member ids); `?` for an
|
|
566
|
+
optional one (left out or `null`). People are member ids, never names nor
|
|
567
|
+
addresses: the receiver resolves them under its own permissions. The
|
|
568
|
+
Chest refuses a field not declared, missing or of another kind, a member
|
|
569
|
+
the tool never had, and data beyond 16 KiB of JSON.
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
// Quotes: tell, once the quote is accepted in the database
|
|
573
|
+
import * as events from "@argentic/chest-sdk/events";
|
|
574
|
+
|
|
575
|
+
const { id, receivers } = await events.emit("quote.accepted",
|
|
576
|
+
{ quote: q.id, client: q.client, total: q.total, acceptedBy: who.id },
|
|
577
|
+
{ subject: q.id, key: `accepted:${q.id}`, audience: { groups: q.groups } });
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
```ts
|
|
581
|
+
// Tasks: app/chest-events/route.ts — the same route as the members' events
|
|
582
|
+
export async function POST(request: Request) {
|
|
583
|
+
return new Response(null, { status: await events.handle(request, {
|
|
584
|
+
"access.revoked": e => unassign(e.data.id),
|
|
585
|
+
"quote.accepted": async e => {
|
|
586
|
+
// e: {id, type, source: "quotes", occurredAt, subject?, audience, data}
|
|
587
|
+
await createProject({ quote: String(e.data["quote"]), visibleTo: e.audience === "all" ? null : e.audience });
|
|
588
|
+
},
|
|
589
|
+
}, { seen }) });
|
|
590
|
+
}
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
- **`emit(type, data, {subject?, key?, occurredAt?, audience?})`** →
|
|
594
|
+
`{id, receivers}`: the event's id and how many tools it was written for
|
|
595
|
+
(0 is no error: nobody listens, or none of their members may see it). The
|
|
596
|
+
Chest writes it for each linked receiver before answering. `subject` is
|
|
597
|
+
the thing it is about (an `id`): a receiver gets a subject's events in
|
|
598
|
+
the order emitted. `key` makes it idempotent: the same key within 72 hours
|
|
599
|
+
answers the same event and tells nobody again (after an `Unavailable`,
|
|
600
|
+
emit again with the same key); the same key with other content is
|
|
601
|
+
`key_reused`. `occurredAt` (a `Date` or an RFC 3339 instant within the
|
|
602
|
+
last 72 hours) is when it happened, the Chest's time by default.
|
|
603
|
+
`audience` is who in the tool may see the item: `{members?, groups?,
|
|
604
|
+
roles?}` (the tool's roles), each listed once — leave it out when
|
|
605
|
+
everyone who has the tool may. The SDK refuses what is no type, no
|
|
606
|
+
identifier or no audience before sending anything; the Chest checks the
|
|
607
|
+
data. Errors: `ChestError` `invalid_type` (400: not in `"emits"`),
|
|
608
|
+
`invalid_data` (400), `invalid_audience` (400), `invalid_event` (400:
|
|
609
|
+
`subject`, `key`, `occurredAt`), `key_reused` (409); `TooLarge`,
|
|
610
|
+
`CapabilityNotGranted` (the version emits nothing), `Unavailable`.
|
|
611
|
+
- **The audience is who may see it, and the receiver honours it.** The
|
|
612
|
+
Chest intersects it with the members who have the receiver: none in
|
|
613
|
+
common, the event is not delivered there; all of them, `audience` is
|
|
614
|
+
`"all"`; otherwise it is the list of the receiver's members who may see
|
|
615
|
+
the item. Show it to them only — the Chest cannot look inside the
|
|
616
|
+
receiver's database: honouring the list is the receiver's rule.
|
|
617
|
+
- **Read data defensively.** `data` holds the fields the publisher's
|
|
618
|
+
version declares, and a later one may add some: `handle` never refuses an
|
|
619
|
+
event for a field it does not know. `source` is the tool that told it,
|
|
620
|
+
stamped by the Chest.
|
|
621
|
+
- **Loops are cut by the Chest.** An `emit` made inside the handler of a
|
|
622
|
+
tool event carries that event as its `cause`, by itself (through the
|
|
623
|
+
handler's async context): a chain that would pass twice through the same
|
|
624
|
+
tool and type is accepted and told to nobody. Work deferred elsewhere (a
|
|
625
|
+
schedule) starts a new chain.
|
|
626
|
+
- A draft's own emits reach no tool; the preview's log says what it emitted.
|
|
627
|
+
|
|
628
|
+
### Delivery, for both
|
|
629
|
+
|
|
630
|
+
- **At least once.** An event is an envelope `{id: "evt_…", type,
|
|
631
|
+
occurredAt, data}` — for a tool event also `source`, `audience` and, when
|
|
632
|
+
the publisher gave one, `subject` — signed for this tool (`Chest-Event`
|
|
633
|
+
header, HS256 under a key derived from `CHEST_TOKEN` with the label
|
|
634
|
+
`Chest-Event v1`, naming the event and the SHA-256 of the body, 60
|
|
635
|
+
seconds). Any answer but a 2xx is delivered again, the same event with the
|
|
636
|
+
same id, after 5 s, 15 s, 30 s, 1 min, 2 min, 5 min, 10 min, 30 min, then
|
|
637
|
+
every hour, for 72 hours — a restart of the node included. Given up, a
|
|
638
|
+
member event leaves the tool “out of sync” on its page until its next
|
|
639
|
+
start; a tool event is a failed delivery on the receiver's **Events**
|
|
640
|
+
page, kept 30 days, that an admin may send again (the same id). Tool
|
|
641
|
+
events of one subject come in the order emitted; nothing else is ordered.
|
|
642
|
+
A sleeping tool is woken by a delivery, like by a visit.
|
|
643
|
+
- **`handle(request, handlers, {seen?})`** answers the status to give the
|
|
644
|
+
Chest: 401 for what is not a delivery of the Chest for this tool, 204 for
|
|
645
|
+
an event handled, one already in `seen`, a type without a handler, or a
|
|
646
|
+
member type of a later Chest (signed, ignored). Handlers are keyed by
|
|
647
|
+
type: a member type's handler gets its own event (`MemberErased`…), any
|
|
648
|
+
other type's a `ToolEvent` (annotate a handlers object apart with the
|
|
649
|
+
types it holds: `Handlers<"member.erased" | "quote.accepted">`). It reads
|
|
650
|
+
the body — up to 32 MiB, as a tool event may name a whole team in its
|
|
651
|
+
audience, and only once the signature holds: mount it before any body
|
|
652
|
+
parser. A handler that throws leaves the event unseen and `handle`
|
|
653
|
+
throws: answer 500, it comes again. `seen` is the store of the handled
|
|
654
|
+
ids — `memorySeen()` (the default: 10,000 ids in the process, lost at a
|
|
655
|
+
restart) or a table of the tool's own, as above. Make handlers idempotent
|
|
656
|
+
anyway: an event handled but not yet added to `seen` when the tool stops
|
|
657
|
+
comes again.
|
|
658
|
+
- **`verify(request)`** is the event of a delivery, typed (`ChestEvent` or
|
|
659
|
+
`ToolEvent`), or `null`; for a tool that routes events itself (its emits
|
|
660
|
+
then carry no cause).
|
|
661
|
+
- Never put the route behind your own session or under `/chest`.
|
|
662
|
+
|
|
485
663
|
## `ai` — AI models through the Chest
|
|
486
664
|
|
|
487
665
|
A tool that declares the `ai` capability calls AI models through its
|
|
@@ -599,7 +777,8 @@ like a permission, in the approval screen). The container has no network: its
|
|
|
599
777
|
launcher listens on `127.0.0.1` and relays each connection to the Chest. The
|
|
600
778
|
launcher sets `DATABASE_URL` —
|
|
601
779
|
`postgres://<user>:<password>@127.0.0.1:<port>/<database>?sslmode=disable`,
|
|
602
|
-
the user and the database both named `t_<tool
|
|
780
|
+
the user and the database both named `t_<tool>`, or `pb_<project>` in the
|
|
781
|
+
preview of a draft Perseus Code builds — and `PGHOST`, `PGPORT`,
|
|
603
782
|
`PGUSER`, `PGPASSWORD`, `PGDATABASE`, which take precedence over a variable of
|
|
604
783
|
the tool with the same name. `databaseUrl()` returns `DATABASE_URL` when it has
|
|
605
784
|
exactly this shape, and throws `CapabilityNotGranted` otherwise (a version
|
|
@@ -627,6 +806,62 @@ service. The Chest keeps the list of files run (table `chest_migrations`): a
|
|
|
627
806
|
version that loses one or changes one is refused. A migration must leave the
|
|
628
807
|
previous version working — going back to the previous version undoes nothing.
|
|
629
808
|
|
|
809
|
+
## `sealed` — sensitive values only members open
|
|
810
|
+
|
|
811
|
+
A tool that declares `"capabilities": ["sealed"]` (approved like a
|
|
812
|
+
permission: “Seals sensitive values, and opens them only for the members who
|
|
813
|
+
have access to it”) seals an IBAN, a salary, a medical note, a confidential
|
|
814
|
+
message through its Chest, and stores the sealed text in its own database,
|
|
815
|
+
in any text column. The Chest seals it with a key of the tool that never
|
|
816
|
+
leaves the Chest; only the tool opens it again, through its Chest, **on the
|
|
817
|
+
request of a member who has the tool** — the Chest's front gives each
|
|
818
|
+
member's request a ticket (`Chest-Opener`), which the SDK passes back and
|
|
819
|
+
the tool cannot make. A value sealed for roles opens only for a member who
|
|
820
|
+
holds one of them: their role is set by the owner or an admin, never by the
|
|
821
|
+
tool. The Data tab, the agents' SQL and data APIs, builders, the owner, the
|
|
822
|
+
logs and the backups see `chest:sealed:1:…`, shown as **Sealed**.
|
|
823
|
+
|
|
824
|
+
```ts
|
|
825
|
+
import { seal, open, openMany } from "@argentic/chest-sdk/sealed";
|
|
826
|
+
|
|
827
|
+
// "capabilities": ["database", "sealed"], "roles": ["hr", "member"]
|
|
828
|
+
const iban = await seal(form.iban, { context: `employee:${id}`, roles: ["hr"] });
|
|
829
|
+
await sql`update employees set iban = ${iban} where id = ${id}`;
|
|
830
|
+
|
|
831
|
+
// In a /chest route, on the member's request:
|
|
832
|
+
const shown = await openMany(request, rows.map(r => ({ sealed: r.iban, context: `employee:${r.id}` })));
|
|
833
|
+
// → the text of each, or null for a value this member may not open
|
|
834
|
+
const one = await open(request, row.iban, { context: `employee:${row.id}` });
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
- **`context`** binds a value to where it belongs, as AWS KMS's encryption
|
|
838
|
+
context does: a value sealed for `employee:42` does not open as
|
|
839
|
+
`employee:43`, so a sealed salary copied into another row opens nowhere.
|
|
840
|
+
Use the row's kind and key; 256 bytes at most.
|
|
841
|
+
- **`roles`** (1 to 16 the tool declares): the Chest checks the member's
|
|
842
|
+
role at every open; `open` throws `NotAllowed`, `openMany` answers `null`.
|
|
843
|
+
- **Sealing needs no member**: a public form, a schedule, an import seal.
|
|
844
|
+
**Opening needs one**: a public page, a schedule or an event cannot open
|
|
845
|
+
(`MemberRequired`). A request's ticket lasts 60 seconds, as its assertion.
|
|
846
|
+
- **Seal what nobody searches.** A sealed value is never searchable,
|
|
847
|
+
sortable or filterable on the server: keep in clear what lists and filters
|
|
848
|
+
need (a name, a date, a status). Open one page and filter it in memory.
|
|
849
|
+
- **Every open is journaled** — the member, how many values, when; never a
|
|
850
|
+
value — and the owner sees the totals per member. Open what the page
|
|
851
|
+
shows, not the whole table.
|
|
852
|
+
- **Every new version of the tool waits for the owner or an admin**,
|
|
853
|
+
whoever wrote it: its code can read sealed data. Never log, never send to
|
|
854
|
+
an AI or a mail what you opened unless the member asked for it.
|
|
855
|
+
- A value is text, 512 KiB at most; a call carries a page of them (4 MiB).
|
|
856
|
+
Sealing or opening one costs microseconds in the Chest; batch with
|
|
857
|
+
`sealMany` and `openMany`.
|
|
858
|
+
- `SealedLocked`: the Chest was restored and waits for its owner's recovery
|
|
859
|
+
code (Settings); show “Sealed data is locked” and keep the rest working.
|
|
860
|
+
`SealedLost`: the key is gone for good. `SealedInvalid`: altered, another
|
|
861
|
+
tool's, or another context.
|
|
862
|
+
- A Perseus Code draft seals under a key of its own: its values never open
|
|
863
|
+
in the tool, nor the tool's in it.
|
|
864
|
+
|
|
630
865
|
## `files` — files of a server tool
|
|
631
866
|
|
|
632
867
|
A tool that declares `"capabilities": ["files"]` (approved like a permission)
|
|
@@ -708,6 +943,59 @@ type sent; nothing of a refused upload remains. There is no antivirus scan.
|
|
|
708
943
|
`uploadUrl` answers `Unavailable` while the Chest does not know the tool's
|
|
709
944
|
team host yet.
|
|
710
945
|
|
|
946
|
+
### Uploads from a visitor of the public part
|
|
947
|
+
|
|
948
|
+
A tool with a public part (`"public": true`) lets its visitors send files —
|
|
949
|
+
an application form with a CV, a support ticket with a screenshot — the same
|
|
950
|
+
way, with `public: true`, from a public route:
|
|
951
|
+
|
|
952
|
+
```ts
|
|
953
|
+
// Server side: a public route, e.g. app/api/apply/upload/route.ts
|
|
954
|
+
const up = await files.uploadUrl("applications/", {
|
|
955
|
+
public: true,
|
|
956
|
+
types: ["application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
|
957
|
+
maxSize: 10 << 20,
|
|
958
|
+
});
|
|
959
|
+
// → { url: "/_chest/files/upload/<token>", method: "PUT", expiresIn }
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
```ts
|
|
963
|
+
// Browser side, on the public page: to its own address
|
|
964
|
+
const response = await fetch(up.url, { method: "PUT", body: file });
|
|
965
|
+
// 201 {name, type, size}; 415 type_refused (not of a type granted, whatever
|
|
966
|
+
// it is called), 429 slow_down (Retry-After), 411 length_required, 413, 429
|
|
967
|
+
// quota_exceeded, 507 storage_full
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
What differs from a member's upload, because the visitor is anonymous:
|
|
971
|
+
|
|
972
|
+
- **Into a folder only** (a name ending in `/`): the Chest names the file, so
|
|
973
|
+
a visitor never replaces one. Keep visitors' files in folders of their own.
|
|
974
|
+
- **Declared types, recognised by their content.** `types` is required, each
|
|
975
|
+
a type the Chest recognises by its bytes — images (`image/jpeg`, `png`,
|
|
976
|
+
`gif`, `webp`, `avif`, `heic`, `bmp`, `tiff`, or `image/*`),
|
|
977
|
+
`application/pdf`, Word, Excel and PowerPoint documents (`.docx`, `.xlsx`,
|
|
978
|
+
`.pptx`), OpenDocument ones (`.odt`, `.ods`, `.odp`), and archives
|
|
979
|
+
(`zip`, `gzip`, `7z`, `rar`, `tar`, `bzip2`, `xz`, `cab`) — `invalid_type`
|
|
980
|
+
for another (plain text and CSV cannot be told by their bytes). The file is
|
|
981
|
+
of the type its content is, whatever its name or the type the browser
|
|
982
|
+
says; anything else is refused.
|
|
983
|
+
- **Private, never public.** The file joins the tool's private files: only
|
|
984
|
+
its members see it, through the tool (`url` on the team host). The Chest
|
|
985
|
+
never serves it on the public part.
|
|
986
|
+
- **Paced per visitor**, by client address: 10 uploads a minute, 2 at once,
|
|
987
|
+
and in an hour a twentieth of the tool's quota (never less than its
|
|
988
|
+
largest object); beyond, `429 slow_down` with `Retry-After`. Nothing caps
|
|
989
|
+
all the visitors together but the tool's quota and the server's disk
|
|
990
|
+
(`507 storage_full` when the disk is full).
|
|
991
|
+
- **On whichever address the page is**: `url` is a path, so the same page
|
|
992
|
+
works on the tool's public address, on its custom domain, and framed by the
|
|
993
|
+
company's website.
|
|
994
|
+
|
|
995
|
+
The browser gives the name it was answered back to the tool with the rest of
|
|
996
|
+
the form; `stat` it before recording it — a name the tool did not see come
|
|
997
|
+
is not a proof of anything.
|
|
998
|
+
|
|
711
999
|
### Links and thumbnails
|
|
712
1000
|
|
|
713
1001
|
`url(name, {thumbnail?, download?})` signs a link to the file as it is, on the
|
|
@@ -726,18 +1014,20 @@ Every file the Chest answers (`put`, `stat`, `list`, `move`) carries
|
|
|
726
1014
|
it, or detect a duplicate receipt, without reading the file again.
|
|
727
1015
|
|
|
728
1016
|
The SDK takes a link or an upload address from the Chest only in `https`
|
|
729
|
-
on the team host
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1017
|
+
on the team host, or on the origin of the Chest's API itself (`CHEST_API`,
|
|
1018
|
+
`http://127.0.0.1:<port>`): the address the tool already sends every call
|
|
1019
|
+
to, where only a fake Chest (`@argentic/chest-sdk/testing`) serves its
|
|
1020
|
+
links. A real Chest never answers one there, and no other local address is
|
|
1021
|
+
ever taken — so a tool a test starts in its own process (`next start` with
|
|
1022
|
+
the fake's environment) takes the fake's links as the test itself does.
|
|
735
1023
|
|
|
736
1024
|
Errors: `CapabilityNotGranted` (a version without the capability, or no
|
|
737
|
-
`CHEST_API`), `TooLarge` (413), `QuotaExceeded` (429), `
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
`
|
|
1025
|
+
`CHEST_API`), `TooLarge` (413), `QuotaExceeded` (429), `StorageFull` (507: the
|
|
1026
|
+
server's disk is full, whatever the tool's quota), `Unavailable` (the Chest
|
|
1027
|
+
not reached, or an answer that is not its own: a write may or may not have
|
|
1028
|
+
happened), `ChestError` for the rest (`invalid_type`, `no_thumbnail`,
|
|
1029
|
+
`not_found` for `url` and `move`, `no_public_part` for a visitor's upload of
|
|
1030
|
+
a tool without one…). Removing the tool removes its files; a new
|
|
741
1031
|
version keeps them.
|
|
742
1032
|
|
|
743
1033
|
## `schedules` — work the tool does by itself
|
|
@@ -813,6 +1103,137 @@ export async function POST(request: Request) {
|
|
|
813
1103
|
agent reads `GET /api/v1/tools/<tool>/schedules` and runs one with
|
|
814
1104
|
`POST /api/v1/tools/<tool>/schedules/run {name}` (a token that writes).
|
|
815
1105
|
|
|
1106
|
+
## `realtime` — live updates of the members' pages
|
|
1107
|
+
|
|
1108
|
+
The Chest holds every connection of the tool's pages: the tool writes no
|
|
1109
|
+
socket code and **sleeps while pages stay open**; it wakes only when someone
|
|
1110
|
+
writes. A page connects to the Chest on its own team host — the member's
|
|
1111
|
+
session is the identity, no token —, joins the channels the tool declares,
|
|
1112
|
+
and hears:
|
|
1113
|
+
|
|
1114
|
+
- the **rows of its feeds**, at each commit: a table named in `"feeds"` turns
|
|
1115
|
+
every insert, update and delete into `<table>.insert`, `.update`,
|
|
1116
|
+
`.delete` on its channel, carrying the declared columns (the Chest
|
|
1117
|
+
installs the triggers after the migrations: no SQL to write);
|
|
1118
|
+
- what the tool **publishes** (`realtime.publish`) — what is not a row;
|
|
1119
|
+
- the other members' **ephemeral messages** (typing, cursors), with their
|
|
1120
|
+
sender set by the Chest — apart: a member's message never arrives as the
|
|
1121
|
+
Chest's event, and its name has no dot (dotted names are the feeds' and
|
|
1122
|
+
the tool's), so no member can pass for a row or for the tool;
|
|
1123
|
+
- who is **present**, merged across each member's pages.
|
|
1124
|
+
|
|
1125
|
+
```jsonc
|
|
1126
|
+
// chest.json
|
|
1127
|
+
{
|
|
1128
|
+
"capabilities": ["database", "realtime"],
|
|
1129
|
+
"realtime": {
|
|
1130
|
+
"channels": [
|
|
1131
|
+
{ "name": "everyone", "presence": true },
|
|
1132
|
+
{ "name": "room:{id}", "join": { "table": "room_members", "key": "room_id", "member": "member_id" }, "send": true, "presence": true },
|
|
1133
|
+
{ "name": "inbox:{member}" },
|
|
1134
|
+
{ "name": "desk", "join": ["manager"] }
|
|
1135
|
+
],
|
|
1136
|
+
"feeds": [{ "table": "messages", "channel": "room:{room_id}", "columns": ["id", "room_id", "author", "text", "created_at"] }]
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
| A channel's key | Says |
|
|
1142
|
+
|---|---|
|
|
1143
|
+
| `name` | `everyone` (that name), `room:{id}` (one segment, decided by a membership table), `inbox:{member}` (the joining member's own id only), `board:*` (any one segment); segments of `a-z 0-9 _ -`, 128 characters at most |
|
|
1144
|
+
| `join` | Absent: every member who has the tool; `["manager"]`: those roles; `{table, key, member}`: a member joins `room:42` when a row of `room_members` has `room_id = 42` and `member_id` their id — checked by the Chest on the tool's database at each join, **the row deleted takes them out at once** |
|
|
1145
|
+
| `send` | `true`: who joins may send ephemeral messages on it, checked at each message |
|
|
1146
|
+
| `presence` | `true`: who joins may appear in its presence |
|
|
1147
|
+
|
|
1148
|
+
In the browser (a page of `/chest`, bundled with the page's own script):
|
|
1149
|
+
|
|
1150
|
+
```ts
|
|
1151
|
+
import { connect } from "@argentic/chest-sdk/realtime/client";
|
|
1152
|
+
|
|
1153
|
+
const live = connect();
|
|
1154
|
+
const room = live.channel("room:42");
|
|
1155
|
+
room.onJoined(({ replayed }) => replayed || refetchAfter(lastId)); // joined (again): fetch, unless replayed
|
|
1156
|
+
room.on("messages.insert", row => show(row)); // a row, as committed
|
|
1157
|
+
room.on("rooms.changed", payload => …); // realtime.publish
|
|
1158
|
+
room.onResync(() => refetchAfter(lastId)); // what was missed is not all kept
|
|
1159
|
+
room.onKicked(() => leaveRoom());
|
|
1160
|
+
room.onRefused(code => …); // "forbidden", "invalid_channel", "unavailable"
|
|
1161
|
+
room.peers.on("typing", (_, from) => showTyping(from)); // another member's message
|
|
1162
|
+
room.peers.send("typing");
|
|
1163
|
+
room.presence.track({ active: true });
|
|
1164
|
+
room.presence.on(list => showOnline(list));
|
|
1165
|
+
live.focus("room:42"); // the conversation on screen (null: none)
|
|
1166
|
+
live.on("direct", (event, payload) => …); // realtime.send
|
|
1167
|
+
live.on("status", connected => showOffline(!connected)); // false only after 3 s away
|
|
1168
|
+
live.on("closed", reason => reason === "access_removed" ? showAccessRemoved() : location.reload());
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
On the server:
|
|
1172
|
+
|
|
1173
|
+
```ts
|
|
1174
|
+
import * as realtime from "@argentic/chest-sdk/realtime";
|
|
1175
|
+
|
|
1176
|
+
await realtime.publish("inbox:" + memberId, "rooms.changed", { id: 42 });
|
|
1177
|
+
const { online, watching } = await realtime.online(roomMemberIds, { channel: "room:42" }); // notify those not watching
|
|
1178
|
+
await realtime.send([memberId], "unread", { room: 42, count: 3 });
|
|
1179
|
+
const { members } = await realtime.presence("everyone");
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
| Function | Does |
|
|
1183
|
+
|---|---|
|
|
1184
|
+
| `publish(channel, event, payload?)` | To every page joined to a channel the tool declares: `{seq}`, its number. Kept 2 minutes for pages that reconnect |
|
|
1185
|
+
| `send(memberIds, event, payload?)` | To every page of these members, outside any channel: `{reached}`, those who had one |
|
|
1186
|
+
| `online(memberIds, {channel?})` | `{online, watching}`: those with a page of the tool open now, and those of them watching `channel` — a page focused on it (`live.focus`) and in the foreground; `[]` without a channel. A chat notifies the members online but not watching the conversation, and every member not online (`notify`): those watching see the message already |
|
|
1187
|
+
| `presence(channel)` | `{members: [{id, state}]}` |
|
|
1188
|
+
|
|
1189
|
+
In the page, a channel gives:
|
|
1190
|
+
|
|
1191
|
+
| Method | Does |
|
|
1192
|
+
|---|---|
|
|
1193
|
+
| `on(event, (payload, {pos?, partial?}) => …)` | The Chest's events only: a feed's row (`<table>.insert`…, its position in the change log; `partial` when too long to be carried whole: its first column only) or what the tool publishes |
|
|
1194
|
+
| `onJoined(({replayed}) => …)`, `onResync`, `onKicked`, `onRefused(code => …)` | The channel's life: joined (again), what was missed not all kept, the member taken out, the join refused — never an event name: a tool event named `joined` or `resync` is heard by `on` |
|
|
1195
|
+
| `peers.on(event, (payload, from) => …)`, `peers.send(event, payload?)` | The other members' messages, on a channel whose rule says `send`: 1 to 64 of `a-z 0-9 _ -`, no dot (`peerEventPattern`; another name throws a `TypeError` `invalid_event`), 4 KiB of JSON |
|
|
1196
|
+
| `presence.track(state)`, `presence.list()`, `presence.on(list => …)` | Who is present, the member's own state kept across reconnects |
|
|
1197
|
+
| `leave()` | Leaves the channel |
|
|
1198
|
+
|
|
1199
|
+
`live.focus(name | null)` says which joined channel the member has on screen;
|
|
1200
|
+
the Chest keeps it for the tool alone (`watching`), never shows it to other
|
|
1201
|
+
members, and forgets it when the channel is left; the client sends none
|
|
1202
|
+
while the page is hidden and sends it again when it is shown and after each
|
|
1203
|
+
reconnect.
|
|
1204
|
+
|
|
1205
|
+
- **Nothing missed, however long away.** The database is the truth; an
|
|
1206
|
+
event says something changed. A page that reconnects (a phone back from
|
|
1207
|
+
sleep, a network that returns, a laptop opened the next morning) is given
|
|
1208
|
+
what it missed: the feeds' rows from the Chest's change log of the tool
|
|
1209
|
+
(7 days), each once and in commit order, the tool's events from its memory
|
|
1210
|
+
(2 minutes) — or told `resync` beyond: fetch from the tool what came after
|
|
1211
|
+
the last id. No reload, no polling.
|
|
1212
|
+
- **Joined, then fetch.** Fetch a channel's data on `joined` — but when its
|
|
1213
|
+
payload says `replayed` (what was missed came again, the tool may stay
|
|
1214
|
+
asleep): an event after it is never missed. Deduplicate by id: the
|
|
1215
|
+
author's own row comes back too.
|
|
1216
|
+
- **Reconnection is unseen.** The client reconnects by itself — at once when
|
|
1217
|
+
the page comes back to the foreground, from the browser's cache or to the
|
|
1218
|
+
network, never while offline (going offline drops the connection at
|
|
1219
|
+
once), after a quiet wait when the Chest is full —
|
|
1220
|
+
and says `status` false only when it stays away 3 seconds: a server
|
|
1221
|
+
restart or a network switch shows nothing. A join the Chest's memory
|
|
1222
|
+
cannot hold now is tried again with the same backoff, unseen; a page
|
|
1223
|
+
joins as many channels as it needs.
|
|
1224
|
+
- **Never trust content as HTML.** Render payloads and rows as text.
|
|
1225
|
+
- **Revocation is the Chest's.** A member whose access is taken back is
|
|
1226
|
+
closed `access_removed` at once; `closed` says it. `signed_out`: the
|
|
1227
|
+
member signed out (an open page renews their session every 5 minutes, so
|
|
1228
|
+
it never ends under them): reload the page.
|
|
1229
|
+
- **Bounds are the server's.** Payloads 64 KiB (`TooLarge`), ephemeral sends
|
|
1230
|
+
4 KiB and 20 a second per page, presence 1 KiB; `RateLimited` when pages
|
|
1231
|
+
fall behind: wait a second. A page that falls too far behind is closed and
|
|
1232
|
+
comes back with backfill.
|
|
1233
|
+
- **Drafts work the same.** In Perseus Code's preview, the pages connect on
|
|
1234
|
+
the draft's host as the fake member viewed as; feeds come from the
|
|
1235
|
+
preview database.
|
|
1236
|
+
|
|
816
1237
|
## `testing` — a tool's own tests
|
|
817
1238
|
|
|
818
1239
|
`@argentic/chest-sdk/testing` is for tests, never imported by production code.
|
|
@@ -821,9 +1242,12 @@ export async function POST(request: Request) {
|
|
|
821
1242
|
import { fakeChest, signAssertion, withMember } from "@argentic/chest-sdk/testing";
|
|
822
1243
|
|
|
823
1244
|
const camille = { id: "mbr_k2qhx4mzc7v3b6nfp5r2t7w4ya", firstName: "Camille", lastName: "Martin", name: "Camille Martin", photo: null, role: "editor", isAdmin: false, isBuilder: false, groups: [], language: "fr", timeZone: "Europe/Paris" };
|
|
824
|
-
const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications", "ai"], ai: { reply: () => "Summary." }
|
|
1245
|
+
const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications", "ai"], ai: { reply: () => "Summary." },
|
|
1246
|
+
emits: { "task.done": { description: "A task is done", data: { task: "id", doneBy: "member" } } } });
|
|
825
1247
|
const response = await handler(withMember(new Request("http://tool.test/chest/tasks"), camille));
|
|
826
|
-
assert.equal(await chest.
|
|
1248
|
+
assert.equal(await chest.deliver({ type: "member.erased", data: { id: camille.id, erasure: "era_k2qhx4mzc7v3b6nfp5r2t7w4ya", deadline: "2026-10-28T10:00:00Z" } }, request => handler(request)), 204);
|
|
1249
|
+
assert.equal(await chest.deliver({ type: "quote.accepted", source: "quotes", subject: "q-1", audience: [camille.id], data: { quote: "q-1", total: 1250 } }, request => handler(request)), 204);
|
|
1250
|
+
assert.deepEqual(chest.emitted.map(e => [e.type, e.cause]), [["task.done", undefined]]);
|
|
827
1251
|
assert.deepEqual(chest.acknowledged, ["era_k2qhx4mzc7v3b6nfp5r2t7w4ya"]);
|
|
828
1252
|
assert.deepEqual((await members.list()).members.map(m => m.id), [camille.id]);
|
|
829
1253
|
assert.ok(chest.files.has("reports/2026.pdf"));
|
|
@@ -838,23 +1262,27 @@ await chest.close();
|
|
|
838
1262
|
|---|---|
|
|
839
1263
|
| `signAssertion(member, {token?, tool?, now?})` | A `Chest-Member` header value signed like the Chest's for that `Member` (the token and tool of the environment by default), signed as given, so a language or a zone the Chest never sends makes `member()` refuse it |
|
|
840
1264
|
| `withMember(request, member, options?)` | The request carrying that assertion (the options of `signAssertion`): a new Web `Request`, or the same Node request |
|
|
841
|
-
| `fakeChest({members?, former?, groups?, capabilities?, receives?, files?, ai?, chest?})` | An HTTP server on `127.0.0.1` that sets `CHEST_API`, `CHEST_TOKEN`, `CHEST_TOOL` (`tool` unless set), the Chest's `CHEST_ORGANIZATION`, `CHEST_TIME_ZONE`, `CHEST_LANGUAGE`, `CHEST_CURRENCY`, `CHEST_TEAM_URL`, `CHEST_PUBLIC_URL` (`chest: {organization, timeZone, language, currency, teamUrl, publicUrl}`: `"Test organization"`, `"UTC"`, `"en"`, `"EUR"`, `https://<tool>-chest.chest.test`, `https://<tool>.chest.test` by default; `publicUrl: null` for a tool without a public part) and answers members, groups, files, badges, notifications, AI
|
|
842
|
-
| Links and uploads | The fake serves the team host's part of the files on its own origin (`chest.api`): a link from `files.url` opens the content it was signed for (the image itself for a thumbnail — a fake does not reduce it; `no_thumbnail` for a file that is not a JPEG, PNG, GIF or WebP image), until it expires or the file changes; an address from `files.uploadUrl` takes one `PUT`, within its life, of the types and size it names and whose first bytes are those of its type (403 `invalid_token`, 415 `type_refused`, 400 `type_mismatch`, 413 `too_large`, as the Chest's), named by the Chest in a folder (20 hex characters and the type's ending), and answers `201 {name, type, size}`. It checks no session: a test's `fetch` is the
|
|
843
|
-
| `chest.
|
|
1265
|
+
| `fakeChest({members?, former?, groups?, capabilities?, roles?, receives?, emits?, files?, ai?, realtime?, chest?})` | An HTTP server on `127.0.0.1` that sets `CHEST_API`, `CHEST_TOKEN`, `CHEST_TOOL` (`tool` unless set), the Chest's `CHEST_ORGANIZATION`, `CHEST_TIME_ZONE`, `CHEST_LANGUAGE`, `CHEST_CURRENCY`, `CHEST_TEAM_URL`, `CHEST_PUBLIC_URL` (`chest: {organization, timeZone, language, currency, teamUrl, publicUrl}`: `"Test organization"`, `"UTC"`, `"en"`, `"EUR"`, `https://<tool>-chest.chest.test`, `https://<tool>.chest.test` by default; `publicUrl: null` for a tool without a public part) and answers members, groups, files, badges, notifications, AI, erasure acknowledgments and emits with a Chest's bounds, quotas and errors; a capability left out answers 403 (`members`, `files`, `notifications` and `ai` by default; `members.email` adds the addresses; `members.groups` shows the groups given `grants: false` — groups that do not give the tool —, in `groups.list()` (paged, each with its `size` among those who have the tool) and in each member's `groups`; a member whose `groups` is `null` is signed with the groups overage; `receives` is `["member.*"]` by default, `[]` refuses acknowledgments; `emits` is what the tool's `chest.json` declares, `{type: {description, data: {field: kind}}}`, refused as the Chest refuses the manifest — none by default, which answers an emit `CapabilityNotGranted`). `former: [{id, name?, status?}]` are those the tool had who no longer have it: `lookup` answers them `no_access`, `former` (by default) or `erased`. With `sealed`, it seals and opens values with a Chest's format and rules — the roles a value is sealed for among `roles` (any of the grammar without), its context, the member's role, a member it keeps — under a key of its own, and `withMember` carries the member's ticket while it runs |
|
|
1266
|
+
| Links and uploads | The fake serves the team host's part of the files on its own origin (`chest.api`): a link from `files.url` opens the content it was signed for (the image itself for a thumbnail — a fake does not reduce it; `no_thumbnail` for a file that is not a JPEG, PNG, GIF or WebP image), until it expires or the file changes; an address from `files.uploadUrl` takes one `PUT`, within its life, of the types and size it names and whose first bytes are those of its type (403 `invalid_token`, 415 `type_refused`, 400 `type_mismatch`, 413 `too_large`, as the Chest's), named by the Chest in a folder (20 hex characters and the type's ending), and answers `201 {name, type, size}`. A visitor's upload (`public: true`, 409 `no_public_part` with `chest.publicUrl: null`) is a path the test sends to the fake's origin (`fetch(new URL(up.url, chest.api), …)`), of the type its content is among those granted (415 `type_refused` otherwise; an office document told by the names of its parts). It checks no session and paces no visitor: a test's `fetch` is the browser |
|
|
1267
|
+
| `chest.deliver(event, to)` | Delivers an event signed as the Chest signs it, to `to` — the tool's address (`POST <to>/chest-events`) or a function of a Web `Request` — and says the status it answered. A member event is `{type, data, id?, occurredAt?}`; a tool event, told apart by its `source` (the publisher's name), `{type, source, data, audience?, subject?, id?, occurredAt?}` (`audience` `"all"` by default, or the members of this tool who may see it). A new id and now by default: name an id to deliver the same event twice. The envelope is signed as given, so one the Chest never sends makes `handle` refuse it. A `member.erased` makes its erasure one the tool may acknowledge |
|
|
1268
|
+
| `chest.emitted` | The events the tool emitted, `{id, type, data, subject?, key?, occurredAt, audience?, cause?}` in order, each checked as the Chest checks it: a type of `emits` (`invalid_type`), its declared fields of their kinds, members the tool has or had (`invalid_data`, 16 KiB at most: `too_large`), an audience of its members, former members, groups and any role (`invalid_audience`), `subject`, `key`, `occurredAt`, `cause` (`invalid_event`); a key used again answers its first event for the same content (kept once), `key_reused` for other content. `cause` is the event whose handler emitted it. Each answers `receivers: 0` |
|
|
844
1269
|
| `ai: {models?, reply?, cap?, unavailable?}` | The fake Chest's AI, deterministic and without any provider. `models`: the aliases the tool declared, `{alias, model, provider?, input?, output?}` (all four by default, `fake-default`…`fake-embedding`, provider `openrouter`, 1 and 2 USD per million tokens); another alias answers `model_not_allowed`. `reply(request)`: what a chat answers, given the wire request — a string, or `{text?, toolCalls?: {name, arguments, id?}[]}` (by default the last user message, echoed); streamed, it comes word by word, each tool call's arguments in two pieces, then the finish reason and the usage. Embeddings are unit vectors from a hash of each text (8 dimensions unless `dimensions`). Tokens count one per 4 characters; once the spending reaches `cap` (euros, 5 by default; 0 refuses at once) a call answers `cap_reached`. `unavailable` (`no_connector`, `provider_key_invalid`, `provider_unavailable`) makes chat and embeddings answer it. 60 requests a minute |
|
|
845
1270
|
| `chest.run(name, to, {id?, scheduledAt?, attempt?})` | Delivers a run of the schedule `name` (a new id, now and attempt 1 by default; name an id to deliver the same run twice) signed as the Chest signs it, to `to` — the tool's address (`POST <to>/chest-schedules`) or a function of a Web `Request` — and says the status it answered |
|
|
846
1271
|
| `chest.ai` | The tool's calls to AI, `{path, body}` in order (`body` null for a `GET`) |
|
|
847
1272
|
| `chest.acknowledged` | The erasures the tool acknowledged, each once |
|
|
1273
|
+
| `chest.opens` | The tool's opens of sealed values, `{member, opened, refused}` in order, as the Chest journals them |
|
|
848
1274
|
| `chest.members`, `chest.groups`, `chest.files` | What the fake Chest holds, to change or assert on; its `members` are those who have the tool |
|
|
849
|
-
| `chest.notifications`, `chest.badges` | What the tool sent: the items kept, `{member, title, body?, path, key?}` cleaned as the Chest cleans them
|
|
1275
|
+
| `chest.notifications`, `chest.badges` | What the tool sent: the items kept, `{member, title, body?, path, key?}` in the member's language (their translation, the tool's own words otherwise) cleaned as the Chest cleans them — one for each member a broadcast reached —, in the order sent (a replaced item removed, the new one last; `withdraw` removes; beyond the pace, a member's notices folded into one item with `grouped`, the latest's text, last), and each member's badge (`Map` member → count; 0 removes it) |
|
|
1276
|
+
| `realtime: {channels?, feeds?, membership?}` | The fake Chest's realtime, under the `realtime` key of the tool's `chest.json` (`channels`, `feeds`) and `membership(table, key, member)`, who is in a membership table (nobody by default); capability `realtime` in `capabilities`. Its API answers `publish`, `send`, `online`, `presence` with the Chest's errors; a page of a member connects with `connect({ url: chest.realtime.url(memberId) })`, the Chest's protocol and rules (a presence leaves at once) |
|
|
1277
|
+
| `chest.realtime` | `published` and `sent`, what the tool published and sent in order; `url(memberId)`; `commit(table, op, row)`, a row committed, whole as the database holds it, as the Chest's triggers tell it — for each feed of the table in the order declared, the next position of the change log, published to the feed's channel filled from the row's column it names (which need not be carried) with the feed's columns only; the positions given, none for a feed whose column is null or absent —; `removed(table, key, member)`, a membership row that went; `drop(memberId, code?, reason?)`, that member's pages cut as a network would, or closed with a code (1001, 1013, 1008 `session_ended`): they reconnect and are given what they missed; `revoke(memberId)`, closed as access removed; `signOut(memberId)`, their session ended: the next renewal answers 401; `full(seconds)`, no room for that long (503 with `Retry-After`), `full(seconds, "joins")`, joins answered `full` for that long; `advance(ms)`, the Chest's clock moved: its memory (2 minutes) and change log (7 days) age — a page's own timers are the test's (`mock.timers`); `renewals`, how many times pages renewed their session or asked before reconnecting |
|
|
850
1278
|
| `chest.close()` | Stops it and restores the environment |
|
|
851
1279
|
|
|
852
1280
|
## Version
|
|
853
1281
|
|
|
854
1282
|
The package version is `version` in `package.json` (semver), published by a
|
|
855
1283
|
tag `vX.Y.Z` (see `PUBLISHING.md`). Its MAJOR.MINOR is the version of the
|
|
856
|
-
tool contract it is written for (`"chest"` in `chest.json`): 0.
|
|
857
|
-
contract 0.
|
|
1284
|
+
tool contract it is written for (`"chest"` in `chest.json`): 0.5.x for the
|
|
1285
|
+
contract 0.5. A new contract version is a new MINOR of the SDK.
|
|
858
1286
|
|
|
859
1287
|
## The MCP server
|
|
860
1288
|
|
|
@@ -891,7 +1319,7 @@ the words around them are written here. `check/` is the workspace of
|
|
|
891
1319
|
`client/src` holds the modules, `client/index.ts` the package root,
|
|
892
1320
|
`client/test` the tests. `npm run build` compiles `client/index.ts`, the
|
|
893
1321
|
nine published modules (`errors`, `member`, `members`, `database`, `files`,
|
|
894
|
-
`notifications`, `events`, `ai`, `testing`) and
|
|
1322
|
+
`notifications`, `events`, `ai`, `testing`) and those they share (`api`, the Chest's API; `signed`, its signed deliveries; `eventrules`, the rules of events between tools) —
|
|
895
1323
|
TypeScript strict, ES2022, NodeNext — into `dist/`: ESM `.js`, `.d.ts` and
|
|
896
1324
|
their maps. `member.ts` imports nothing but `node:*`, so that a tool may copy
|
|
897
1325
|
it alone.
|