@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.
Files changed (80) hide show
  1. package/README.md +508 -80
  2. package/client/index.ts +11 -7
  3. package/client/src/api.ts +19 -18
  4. package/client/src/database.ts +6 -1
  5. package/client/src/errors.ts +50 -0
  6. package/client/src/eventrules.ts +117 -0
  7. package/client/src/events.ts +181 -39
  8. package/client/src/files.ts +37 -7
  9. package/client/src/member.ts +15 -9
  10. package/client/src/members.ts +34 -16
  11. package/client/src/notifications.ts +84 -27
  12. package/client/src/realtime-client.ts +516 -0
  13. package/client/src/realtime.ts +118 -0
  14. package/client/src/sealed.ts +158 -0
  15. package/client/src/signed.ts +8 -4
  16. package/client/src/testing-realtime.ts +361 -0
  17. package/client/src/testing.ts +391 -94
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +11 -7
  21. package/dist/index.js.map +1 -1
  22. package/dist/src/api.d.ts +2 -1
  23. package/dist/src/api.d.ts.map +1 -1
  24. package/dist/src/api.js +18 -16
  25. package/dist/src/api.js.map +1 -1
  26. package/dist/src/database.d.ts.map +1 -1
  27. package/dist/src/database.js +5 -1
  28. package/dist/src/database.js.map +1 -1
  29. package/dist/src/errors.d.ts +18 -0
  30. package/dist/src/errors.d.ts.map +1 -1
  31. package/dist/src/errors.js +44 -0
  32. package/dist/src/errors.js.map +1 -1
  33. package/dist/src/eventrules.d.ts +23 -0
  34. package/dist/src/eventrules.d.ts.map +1 -0
  35. package/dist/src/eventrules.js +107 -0
  36. package/dist/src/eventrules.js.map +1 -0
  37. package/dist/src/events.d.ts +31 -6
  38. package/dist/src/events.d.ts.map +1 -1
  39. package/dist/src/events.js +130 -26
  40. package/dist/src/events.js.map +1 -1
  41. package/dist/src/files.d.ts +1 -0
  42. package/dist/src/files.d.ts.map +1 -1
  43. package/dist/src/files.js +34 -3
  44. package/dist/src/files.js.map +1 -1
  45. package/dist/src/member.d.ts +1 -1
  46. package/dist/src/member.d.ts.map +1 -1
  47. package/dist/src/member.js +9 -6
  48. package/dist/src/member.js.map +1 -1
  49. package/dist/src/members.d.ts +9 -2
  50. package/dist/src/members.d.ts.map +1 -1
  51. package/dist/src/members.js +29 -13
  52. package/dist/src/members.js.map +1 -1
  53. package/dist/src/notifications.d.ts +12 -1
  54. package/dist/src/notifications.d.ts.map +1 -1
  55. package/dist/src/notifications.js +60 -19
  56. package/dist/src/notifications.js.map +1 -1
  57. package/dist/src/realtime-client.d.ts +45 -0
  58. package/dist/src/realtime-client.d.ts.map +1 -0
  59. package/dist/src/realtime-client.js +453 -0
  60. package/dist/src/realtime-client.js.map +1 -0
  61. package/dist/src/realtime.d.ts +22 -0
  62. package/dist/src/realtime.d.ts.map +1 -0
  63. package/dist/src/realtime.js +99 -0
  64. package/dist/src/realtime.js.map +1 -0
  65. package/dist/src/sealed.d.ts +20 -0
  66. package/dist/src/sealed.d.ts.map +1 -0
  67. package/dist/src/sealed.js +121 -0
  68. package/dist/src/sealed.js.map +1 -0
  69. package/dist/src/signed.d.ts.map +1 -1
  70. package/dist/src/signed.js +6 -2
  71. package/dist/src/signed.js.map +1 -1
  72. package/dist/src/testing-realtime.d.ts +54 -0
  73. package/dist/src/testing-realtime.d.ts.map +1 -0
  74. package/dist/src/testing-realtime.js +376 -0
  75. package/dist/src/testing-realtime.js.map +1 -0
  76. package/dist/src/testing.d.ts +40 -3
  77. package/dist/src/testing.d.ts.map +1 -1
  78. package/dist/src/testing.js +368 -81
  79. package/dist/src/testing.js.map +1 -1
  80. 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.4) embeds to talk
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, AI models through the
9
- Chest — and, for the tool's tests, a fake Chest. It also publishes the tool
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 and ai APIs
25
- as the namespaces `files`, `members`, `notifications`, `events`, `schedules`
26
- and `ai` (the testing module is not in the root).
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 the Chest posts to the tool's `/chest-events` (`"receives": ["member.*"]`), verified, deduplicated by id, and the acknowledgment of an erasure |
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/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 browser |
39
- | `@argentic/chest-sdk/errors` | `ChestError` (`code`, `status`), `CapabilityNotGranted` (403), `TooLarge` (413), `QuotaExceeded` (429), `RateLimited` (429), `Unavailable` (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 |
40
- | `@argentic/chest-sdk/testing` | `signAssertion`, `withMember`, `fakeChest`, types `AssertionOptions`, `FakeChest`, `FakeChestOptions`, `FakeGroup`, `FakeFile`, `FakeFormer`, `FakeNotification`, `FakeEvent`, `FakeRun`, `FakeAi`, `FakeAiModel`, `FakeAiReply`, `FakeAiCall`: for the tool's own tests only |
41
- | `@argentic/chest-sdk` | all of the above but `testing`; `files`, `members`, `notifications`, `events`, `schedules` and `ai` as namespaces |
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. Webpack and Turbopack resolve the
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.4"` — the MAJOR.MINOR of this SDK. A Chest older than that
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[]; // "grp_…": the groups that give the member this tool
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, `language` a primary tag of
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(); // [{id, name, members}]
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 that give the tool, with their members'
308
- identifiers; never the others.
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 only — no email, no push to a phone. Two
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?})`**: 1 to 500 identifiers
373
- (a duplicate counts once), one inbox item per recipient. `title` is 1 to 80
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 (1 to 500), once
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 1 to 500, a
394
- member at most once.
395
- - **Quotas**, per tool: 1,000 recipients an hour (those with access, muted or
396
- not), 100 items per member a day (a replacement counts; one recipient at 100
397
- refuses the whole call), 600 badge writes a minute (each badge of `setMany`
398
- counts). Beyond, `QuotaExceeded` (429, the Chest answers `Retry-After`); a
399
- refused call changes nothing.
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), `QuotaExceeded` (429), `Unavailable`
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`, `invalid_key`,
407
- `invalid_count`, `invalid_body`) — the SDK refuses these before sending
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, on its own `POST /chest-events`:
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>` — and `PGHOST`, `PGPORT`,
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. The one exception is a fake Chest of the same process
730
- (`@argentic/chest-sdk/testing`): while it runs, the links and uploads it
731
- signs on its own origin, `http://127.0.0.1:<port>`, are taken too. Only the
732
- testing module opens that exception, for that origin alone, until
733
- `close()`; nothing in the environment does, so production code that never
734
- imports the testing module never takes a local link.
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), `Unavailable` (the
738
- Chest not reached, or an answer that is not its own: a write may or may not
739
- have happened), `ChestError` for the rest (`invalid_type`, `no_thumbnail`,
740
- `not_found` for `url` and `move`…). Removing the tool removes its files; a new
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.emit({ type: "member.erased", data: { id: camille.id, erasure: "era_k2qhx4mzc7v3b6nfp5r2t7w4ya", deadline: "2026-10-28T10:00:00Z" } }, request => handler(request)), 204);
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 and erasure acknowledgments 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; `receives` is `["member.*"]` by default, `[]` refuses acknowledgments). `former: [{id, name?, status?}]` are those the tool had who no longer have it: `lookup` answers them `no_access`, `former` (by default) or `erased` |
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 member's browser |
843
- | `chest.emit(event, to)` | Delivers an event (`{type, data, id?, occurredAt?}`: a new id and now by default; name an id to deliver the same event twice) 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.erased` makes its erasure one the tool may acknowledge |
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, in the order sent (a replaced item removed, the new one last; `withdraw` removes), and each member's badge (`Map` member → count; 0 removes it) |
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.4.x for the
857
- contract 0.4. A new contract version is a new MINOR of the SDK.
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 the one they share (`api`, the Chest's API) —
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.