@argentic/chest-sdk 0.1.0 → 0.2.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 (47) hide show
  1. package/README.md +391 -48
  2. package/client/index.ts +10 -7
  3. package/client/src/api.ts +81 -0
  4. package/client/src/errors.ts +14 -3
  5. package/client/src/events.ts +228 -0
  6. package/client/src/files.ts +95 -89
  7. package/client/src/member.ts +40 -17
  8. package/client/src/members.ts +167 -0
  9. package/client/src/notifications.ts +148 -0
  10. package/client/src/testing.ts +417 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +10 -7
  14. package/dist/index.js.map +1 -1
  15. package/dist/src/api.d.ts +9 -0
  16. package/dist/src/api.d.ts.map +1 -0
  17. package/dist/src/api.js +88 -0
  18. package/dist/src/api.js.map +1 -0
  19. package/dist/src/errors.d.ts +3 -0
  20. package/dist/src/errors.d.ts.map +1 -1
  21. package/dist/src/errors.js +13 -3
  22. package/dist/src/errors.js.map +1 -1
  23. package/dist/src/events.d.ts +56 -0
  24. package/dist/src/events.d.ts.map +1 -0
  25. package/dist/src/events.js +193 -0
  26. package/dist/src/events.js.map +1 -0
  27. package/dist/src/files.d.ts +17 -1
  28. package/dist/src/files.d.ts.map +1 -1
  29. package/dist/src/files.js +96 -92
  30. package/dist/src/files.js.map +1 -1
  31. package/dist/src/member.d.ts +6 -3
  32. package/dist/src/member.d.ts.map +1 -1
  33. package/dist/src/member.js +22 -11
  34. package/dist/src/member.js.map +1 -1
  35. package/dist/src/members.d.ts +34 -0
  36. package/dist/src/members.d.ts.map +1 -0
  37. package/dist/src/members.js +146 -0
  38. package/dist/src/members.js.map +1 -0
  39. package/dist/src/notifications.d.ts +25 -0
  40. package/dist/src/notifications.d.ts.map +1 -0
  41. package/dist/src/notifications.js +121 -0
  42. package/dist/src/notifications.js.map +1 -0
  43. package/dist/src/testing.d.ts +70 -0
  44. package/dist/src/testing.d.ts.map +1 -0
  45. package/dist/src/testing.js +419 -0
  46. package/dist/src/testing.js.map +1 -0
  47. package/package.json +28 -4
package/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # Chest SDK
2
2
 
3
3
  `@argentic/chest-sdk` is what a server tool (tool contract v2) embeds to talk
4
- with its Chest: the member the Chest asserts on a request, the address of the
5
- tool's own database and its private files. The SDK has no dependency: it only
6
- imports `node:*`.
4
+ with its Chest: the member the Chest asserts on a request, the other members
5
+ who have the tool, the address of the tool's own database, its private
6
+ files, the badges and notifications it shows members inside the Chest, the
7
+ events of its members' lifecycle — and, for the tool's tests, a fake Chest. The SDK has no dependency: it
8
+ only imports `node:*`.
7
9
 
8
10
  ```sh
9
11
  npm install @argentic/chest-sdk
@@ -14,22 +16,31 @@ Node 22 or later. ESM only, compiled JavaScript with its type declarations.
14
16
  ## Imports
15
17
 
16
18
  Each module is its own subpath and pulls in nothing else; the root gives them
17
- all, with the files API as the namespace `files`.
19
+ all, with the files, members, notifications and events APIs as the
20
+ namespaces `files`, `members`, `notifications` and `events` (the testing
21
+ module is not in the root).
18
22
 
19
23
  | Import | Gives |
20
24
  |---|---|
21
- | `@argentic/chest-sdk/member` | `member(request)`, type `Member`: the member of a request on the team host of a server tool, read from the `Chest-Member` assertion and verified; `null` without a valid assertion |
25
+ | `@argentic/chest-sdk/member` | `member(request)`, type `Member`: the member of a request on the team host of a server tool, read from the `Chest-Member` assertion and verified; `null` without a valid assertion. `memberIdPattern`, `groupIdPattern`: the grammars of the identifiers (`mbr_…`, `grp_…`) |
26
+ | `@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`) |
27
+ | `@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`) |
28
+ | `@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 |
22
29
  | `@argentic/chest-sdk/database` | `databaseUrl()`: the address of the tool's own PostgreSQL database (capability `database`) |
23
- | `@argentic/chest-sdk/files` | `put`, `get`, `list`, `delete`, `url`, types `FileObject`, `FileData`, `FilePage`: the tool's private files (capability `files`), kept by the Chest, and a 15-minute signed link to one |
24
- | `@argentic/chest-sdk/errors` | `ChestError` (`code`, `status`), `CapabilityNotGranted` (403), `TooLarge` (413), `QuotaExceeded` (429), `Unavailable` (503): what the SDK throws when the Chest does not give what a tool asks |
25
- | `@argentic/chest-sdk` | all of the above; `files` as a namespace |
30
+ | `@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 |
31
+ | `@argentic/chest-sdk/errors` | `ChestError` (`code`, `status`), `CapabilityNotGranted` (403), `TooLarge` (413), `QuotaExceeded` (429), `RateLimited` (429), `Unavailable` (503): what the SDK throws when the Chest does not give what a tool asks |
32
+ | `@argentic/chest-sdk/testing` | `signAssertion`, `withMember`, `fakeChest`, types `FakeChest`, `FakeChestOptions`, `FakeGroup`, `FakeFile`, `FakeNotification`, `FakeEvent`: for the tool's own tests only |
33
+ | `@argentic/chest-sdk` | all of the above but `testing`; `files`, `members`, `notifications` and `events` as namespaces |
26
34
 
27
35
  ```ts
28
36
  import { member } from "@argentic/chest-sdk/member";
29
37
  import { databaseUrl } from "@argentic/chest-sdk/database";
30
38
  import * as files from "@argentic/chest-sdk/files";
39
+ import * as members from "@argentic/chest-sdk/members";
40
+ import * as notifications from "@argentic/chest-sdk/notifications";
41
+ import * as events from "@argentic/chest-sdk/events";
31
42
  import { CapabilityNotGranted } from "@argentic/chest-sdk/errors";
32
- // or: import { member, databaseUrl, files } from "@argentic/chest-sdk";
43
+ // or: import { member, databaseUrl, files, members, notifications, events } from "@argentic/chest-sdk";
33
44
  ```
34
45
 
35
46
  Types refer to `node:http` (`IncomingMessage`): a TypeScript project needs
@@ -59,8 +70,10 @@ export function GET(request: Request) {
59
70
  A v2 tool is an ordinary web server in a container without network, run by
60
71
  its Chest. The Chest's front is the only one to reach it; the tool reaches only
61
72
  what its launcher gives it on `127.0.0.1` (its database, the Chest's API for
62
- its files). Rights come from the Chest — the signed member, the capabilities
63
- approved for the version — and the Chest enforces them even outside the SDK:
73
+ its files, its members and its notifications), and the Chest posts it the
74
+ events it receives on `/chest-events`, through the same launcher. Rights come
75
+ from the Chest — the signed member, the capabilities approved for the
76
+ version — and the Chest enforces them even outside the SDK:
64
77
  the SDK makes the calls easier, it is not a security boundary. The full
65
78
  contract (manifest `chest.json`, capabilities, build from source, catalogue)
66
79
  is described in the Chest repository, `docs/architecture.md`.
@@ -73,16 +86,29 @@ signed-in member. `member(request)` accepts a Node request (`IncomingMessage`)
73
86
  or a Web `Request` and returns:
74
87
 
75
88
  ```ts
76
- type Member = { id: string; firstName: string; lastName: string; name: string; email: string; photo?: string; role?: string; isAdmin: boolean; isBuilder: boolean };
89
+ type Member = {
90
+ id: string; // "mbr_…": the member in this Chest, the same in all its tools
91
+ firstName: string;
92
+ lastName: string;
93
+ name: string; // "Camille Martin", or the local part of the address without names
94
+ photo: string | null; // /_chest/members/{id}/photo?v=<rev> on the tool's team host
95
+ role: string | null; // one of the roles chest.json declares; null if it declares none
96
+ isAdmin: boolean; // owner or admin of the Chest
97
+ isBuilder: boolean; // builder of this tool
98
+ groups: string[]; // "grp_…": the groups that give the member this tool
99
+ email?: string; // only with the capability "members.email"
100
+ };
77
101
  ```
78
102
 
79
103
  or `null`: without the header, on the public host (the Chest never sends an
80
104
  assertion there and strips a client's), or for any assertion that is not
81
105
  exactly its own. Checks: compact JWS, header exactly
82
106
  `{"alg":"HS256","typ":"JWT"}`, HMAC-SHA256 signature compared in constant time
83
- under the key HMAC-SHA256("Chest-Member v1") of the text of `CHEST_TOKEN` —
84
- the Chest's derivation —, `aud` equal to `CHEST_TOOL`, `iat` and `exp` within
85
- 5 s, the shape of each claim (an unknown claim is ignored). Without
107
+ under the key HMAC-SHA256("Chest-Member v2") of the text of `CHEST_TOKEN` —
108
+ the Chest's derivation; the label changes with the shape of the claims, so an
109
+ assertion of another shape is refused rather than misread —, `aud` equal to
110
+ `CHEST_TOOL`, `iat` and `exp` within 5 s, the shape of each claim (`sub` an
111
+ `mbr_` identifier, `groups` `grp_` identifiers; an unknown claim is ignored). Without
86
112
  `CHEST_TOKEN` or `CHEST_TOOL`, nobody is a member. The function never throws
87
113
  for what a request carries.
88
114
 
@@ -92,16 +118,233 @@ const who = member(request);
92
118
  if (!who) { response.writeHead(401).end(); return; }
93
119
  ```
94
120
 
95
- `photo` is the address of the photo on the team host, `role` the role the
96
- Chest gives the member among those the manifest declares. Only the Chest's
97
- front reaches the container: the signature is a second defence; business rules
98
- (who writes what) remain the tool's.
121
+ `id` is the member's identifier in the Chest: random, never an address nor
122
+ an account of the sign-in provider, stable when the member changes their name
123
+ or address, never given to anyone else. Store it in your data; resolve names
124
+ when rendering (`members.lookup`). `photo` is served by the Chest on the team
125
+ host to members who have the tool; `role` is the one the Chest gives the
126
+ member among those the manifest declares. Only the Chest's front reaches the
127
+ container: the signature is a second defence; business rules (who writes
128
+ what) remain the tool's.
129
+
130
+ ## `members` — who has the tool
131
+
132
+ A v2 tool that declares `"capabilities": ["members"]` (approved like a
133
+ permission: “Sees the name, photo, role and groups of the members who have
134
+ access to it.”) reads the members who have it, through the Chest's API
135
+ (`CHEST_API`, as for files). `"members.email"`, a permission of its own that
136
+ requires `members`, adds their addresses — to these answers and to
137
+ `member(request)`.
138
+
139
+ ```ts
140
+ import * as members from "@argentic/chest-sdk/members";
141
+ const { members: page, next } = await members.list({ q: "cam", limit: 50 }); // by name, then id
142
+ const camille = await members.get("mbr_k2qhx4mzc7v3b6nfp5r2t7w4ya"); // Member, or null
143
+ const { members: found, former, unknown } = await members.lookup(ids); // any number of ids
144
+ const teams = await members.groups.list(); // [{id, name, members}]
145
+ ```
146
+
147
+ - **Who**: exactly the members who have the tool now — by a grant, a group,
148
+ open to all, or because they run it (owner, admins, its builders);
149
+ recomputed at every call. A member without access answers as an identifier
150
+ that does not exist (`get` → `null`, `lookup` → `unknown`).
151
+ - **`list({after, limit, q, role, group})`**: ordered by name (accents aside)
152
+ then identifier; `limit` 100 by default, 500 at most; `next` is an opaque
153
+ cursor for `after`, `null` after the last page. `q` finds the start of a
154
+ first name, a last name or a name — and of an address with `members.email`
155
+ —, whatever its case and accents; `role` and `group` keep the members of that
156
+ role or group.
157
+ - **`lookup(ids)`**: each identifier once, in the order given: `members`,
158
+ `former` (`{id, name, status: "former"}`: someone who left the Chest after
159
+ having the tool, so a record still reads “Camille Martin (former member)”;
160
+ `{id, name: null, status: "erased"}` once the owner had their data erased,
161
+ rendered “Former member”) and `unknown`. The SDK asks 200 at a time and
162
+ keeps each answer a minute in the process (5,000 at most); `forget()`
163
+ empties it, and so does every event of the members' lifecycle
164
+ (`events.handle`).
165
+ - **`groups.list()`**: the groups that give the tool, with their members'
166
+ identifiers; never the others.
167
+ - Errors: `CapabilityNotGranted` (403), `RateLimited` (429: 600 calls a minute
168
+ per instance), `Unavailable` (503), `ChestError` for the rest (`invalid_id`,
169
+ `invalid_query`).
170
+
171
+ Store identifiers, resolve names when rendering, never copy them: a copied
172
+ name or address goes stale and makes the tool a second directory to erase.
173
+
174
+ ```sql
175
+ create table tasks (
176
+ id bigint generated always as identity primary key,
177
+ title text not null,
178
+ assignee text, -- a member id, "mbr_…"
179
+ created_by text not null,
180
+ constraint assignee_is_member check (assignee ~ '^mbr_[a-z2-7]{26}$')
181
+ );
182
+ ```
183
+
184
+ ```ts
185
+ const rows = await sql`select * from tasks order by id desc limit 50`;
186
+ const people = await members.lookup(rows.flatMap(r => [r.assignee, r.created_by]).filter(Boolean));
187
+ ```
188
+
189
+ To search tasks by assignee name: `members.list({ q })` first, then
190
+ `where assignee = any($ids)`.
191
+
192
+ ## `notifications` — badges and inbox items
193
+
194
+ A v2 tool that declares `"capabilities": ["notifications"]` (approved like a
195
+ permission: “Shows counters and sends notifications, inside the Chest, to the
196
+ members who have access to it.”) tells its members what needs their
197
+ attention, inside the Chest only — no email, no push to a phone. Two
198
+ primitives:
199
+
200
+ - a **badge** is a count on the tool's tile in the Chest home and on its row
201
+ in the tools list, for one member (“99+” beyond 99): a state, set again as
202
+ often as it changes;
203
+ - a **notification** is an item in a member's inbox (the bell of the Chest):
204
+ the tool's icon and name, a title, a body, and a link that opens a page of
205
+ the tool on its team host.
206
+
207
+ ```ts
208
+ import * as notifications from "@argentic/chest-sdk/notifications";
209
+
210
+ const { delivered, skipped } = await notifications.notify([assignee], {
211
+ title: "New task: fix the door", // 1 to 80 characters
212
+ body: "Before Friday.\nKeys at the desk.", // 280 characters at most; optional
213
+ path: "/chest/tasks/42", // under /chest; /chest when not said
214
+ key: "task:42", // optional: replace, then withdraw
215
+ });
216
+ await notifications.withdraw("task:42"); // done: its items go, for everyone
217
+ await notifications.withdraw("task:42", [assignee]); // only for those
218
+ const shown = await notifications.badge.set(assignee, 3); // false: no access
219
+ const { set, skipped: noAccess } = await notifications.badge.setMany([
220
+ { memberId: assignee, count: 3 },
221
+ { memberId: reviewer, count: 0 }, // 0 clears it
222
+ ]);
223
+ ```
224
+
225
+ - **Who**: only members who have access to the tool now receive either.
226
+ `notify` answers `{delivered, skipped}`, each identifier once in the order
227
+ given; `skipped` holds identifiers the Chest does not know and members
228
+ without access (as for `members`, the two are indistinguishable). `badge.set`
229
+ answers `false` for such a member, `setMany` puts them in `skipped`.
230
+ - **`notify(memberIds, {title, body?, path?, key?})`**: 1 to 500 identifiers
231
+ (a duplicate counts once), one inbox item per recipient. `title` is 1 to 80
232
+ characters (Unicode code points), `body` 280 at most (an empty body is
233
+ none). `path` is a page of the tool's private part: `/chest`, or `/chest`
234
+ followed by `/`, `?` or `#`; printable ASCII without spaces or `\`, 512
235
+ characters at most, never `//`, no `.` or `..` segment; the Chest builds the
236
+ link on the tool's team host, so it cannot point anywhere else. `key` is
237
+ 1 to 64 of `a-z 0-9 . _ : -`.
238
+ - **Replace and withdraw.** A notification with the key of an earlier one, for
239
+ the same member, replaces it: new text, new time, first in the inbox and
240
+ unread again — never a duplicate. `withdraw(key, memberIds?)` removes the
241
+ items of that key, from every member or from those named (1 to 500), once
242
+ the thing they were about is done. It never says what existed.
243
+ - **Plain text.** The Chest removes control characters (a tab or a line break
244
+ in a title becomes a space; `body` keeps its line breaks) and the characters
245
+ that reorder text, trims both, and interprets neither Markdown nor HTML.
246
+ Every item shows the tool's icon and name beside it: a tool cannot pass for
247
+ the Chest or another tool. A title that is empty once cleaned is refused.
248
+ - **Muting is invisible.** A member may mute the tool in their profile: their
249
+ new items are then dropped, but they still count as `delivered`, and their
250
+ badges stay. The tool never learns who muted it.
251
+ - **Badges** go from 0 to 9,999, 0 clears one. `setMany` takes 1 to 500, a
252
+ member at most once.
253
+ - **Quotas**, per tool: 1,000 recipients an hour (those with access, muted or
254
+ not), 100 items per member a day (a replacement counts; one recipient at 100
255
+ refuses the whole call), 600 badge writes a minute (each badge of `setMany`
256
+ counts). Beyond, `QuotaExceeded` (429, the Chest answers `Retry-After`); a
257
+ refused call changes nothing.
258
+ - **Lifecycle**: a member who loses access loses the tool's items and badge;
259
+ removing the tool removes them all. A member's inbox keeps 500 items for 90
260
+ days.
261
+ - Errors: `CapabilityNotGranted` (403), `QuotaExceeded` (429), `Unavailable`
262
+ (503, the Chest not reached, or an answer that is not its own: the call may
263
+ or may not have happened), `ChestError` for the rest (`invalid_id`,
264
+ `invalid_title`, `invalid_text`, `invalid_path`, `invalid_key`,
265
+ `invalid_count`, `invalid_body`) — the SDK refuses these before sending
266
+ anything.
267
+
268
+ A badge suits a count that goes up and down (tasks assigned, messages
269
+ unread); a notification, an event worth a look — with a key, so that it goes
270
+ away by itself once handled.
271
+
272
+ ## `events` — the members' lifecycle
273
+
274
+ A v2 tool that holds `members` and declares `"receives": ["member.*"]` in its
275
+ `chest.json` (approved like a permission: “Is told when the members who have
276
+ access to it change or leave.”) is told, on its own `POST /chest-events`:
277
+
278
+ | Event | `data` | When |
279
+ |---|---|---|
280
+ | `member.updated` | `{id, changed: ("name" \| "photo" \| "role" \| "groups" \| "email")[]}` | Something the tool sees of a member who has it changed (`email` only with `members.email`) |
281
+ | `access.revoked` | `{id}` | The member lost access to the tool but stays in the Chest |
282
+ | `member.removed` | `{id}` | The member left the Chest: `lookup` now reads them `former` |
283
+ | `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)` |
284
+
285
+ A member who gets the tool is no event: the next `list` has them.
286
+
287
+ ```jsonc
288
+ // chest.json
289
+ { "capabilities": ["members"], "receives": ["member.*"] }
290
+ ```
291
+
292
+ ```ts
293
+ // app/chest-events/route.ts — at the root, outside /chest: the Chest calls it
294
+ // through the tool's launcher, never from a browser (its front answers 404 there).
295
+ import * as events from "@argentic/chest-sdk/events";
296
+
297
+ const seen = {
298
+ has: async (id: string) => (await sql`select 1 from chest_events where id = ${id}`).length > 0,
299
+ add: async (id: string) => { await sql`insert into chest_events (id) values (${id}) on conflict do nothing`; },
300
+ };
301
+
302
+ export async function POST(request: Request) {
303
+ return new Response(null, { status: await events.handle(request, {
304
+ "member.updated": e => refreshCache(e.data.id, e.data.changed),
305
+ "access.revoked": e => sql`update tasks set assignee = null where assignee = ${e.data.id}`,
306
+ "member.erased": async e => {
307
+ await sql`update tasks set created_by = 'erased' where created_by = ${e.data.id}`;
308
+ await events.acknowledgeErasure(e.data.erasure);
309
+ },
310
+ }, { seen }) });
311
+ }
312
+ ```
313
+
314
+ - **Delivery**: at least once, in no guaranteed order. An event is an
315
+ envelope `{id: "evt_…", type, occurredAt, data}`, signed for this tool
316
+ (`Chest-Event` header, HS256 under a key derived from `CHEST_TOKEN` with
317
+ the label `Chest-Event v1`, naming the event and the SHA-256 of the body,
318
+ 60 seconds). Any answer but a 2xx is delivered again, the same event with
319
+ the same id, after 5 s, 15 s, 30 s, 1 min, 2 min, 5 min, 10 min, 30 min, then
320
+ every hour, for 72 hours — a restart of the node included. Given up, the
321
+ tool is marked “out of sync” on its page until its next start.
322
+ - **`members.list` is the truth.** Reconcile by listing at start (and so after
323
+ being out of sync): events keep a tool current between starts, they do not
324
+ replace reading who has it.
325
+ - **`handle(request, handlers, {seen?})`** answers the status to give the
326
+ Chest: 401 for what is not a delivery of the Chest for this tool, 204 for an
327
+ event handled, one already in `seen`, a type without a handler, or one of a
328
+ later Chest (signed, ignored). It reads the body (64 KiB at most): mount it
329
+ before any body parser. A handler that throws leaves the event unseen and
330
+ `handle` throws: answer 500, it comes again. `seen` is the store of the
331
+ handled ids — `memorySeen()` (the default: 10,000 ids in the process, lost
332
+ at a restart) or a table of the tool's own, as above. Make handlers
333
+ idempotent anyway: an event handled but not yet added to `seen` when the
334
+ tool stops comes again.
335
+ - **`verify(request)`** is the event of a delivery, typed, or `null`; for a
336
+ tool that routes events itself.
337
+ - **`acknowledgeErasure(erasure)`**: `POST /erasures/{erasure}/done` on the
338
+ Chest's API; the owner then sees the tool's part done (“Erased on 3 Oct.”),
339
+ “Overdue” past the deadline otherwise. Again is harmless. Errors:
340
+ `ChestError` `erasure_not_found` (404: an erasure this tool was not told of)
341
+ or `invalid_id` (400), `CapabilityNotGranted` (403), `Unavailable`.
99
342
 
100
343
  ## `databaseUrl()` — database of a server tool
101
344
 
102
345
  A v2 tool that declares `"capabilities": ["database"]` in its `chest.json`
103
346
  gets a PostgreSQL database of its own (the capability is shown and approved
104
- like a permission, « Base de données »). The container has no network: its
347
+ like a permission, in the approval screen). The container has no network: its
105
348
  launcher listens on `127.0.0.1` and relays each connection to the Chest. The
106
349
  launcher sets `DATABASE_URL` —
107
350
  `postgres://<user>:<password>@127.0.0.1:<port>/<database>?sslmode=disable`,
@@ -135,52 +378,146 @@ previous version working — going back to the previous version undoes nothing.
135
378
 
136
379
  ## `files` — files of a server tool
137
380
 
138
- A v2 tool that declares `"capabilities": ["files"]` (« Fichiers » at approval)
381
+ A v2 tool that declares `"capabilities": ["files"]` (approved like a permission)
139
382
  keeps private files **through its Chest**, never on its disk (the container's
140
- root is read-only): 1 GiB and 10,000 objects per tool, 32 MiB per object. The
141
- launcher gives the tool `CHEST_API=http://127.0.0.1:<port>` — its own port,
142
- relayed to the Chest; the container has no network — and the instance is the
143
- identity: the tool reaches its own files only.
383
+ root is read-only). The launcher gives the tool
384
+ `CHEST_API=http://127.0.0.1:<port>` — its own port, relayed to the Chest; the
385
+ container has no network — and the instance is the identity: the tool reaches
386
+ its own files only.
144
387
 
145
388
  ```ts
146
389
  import * as files from "@argentic/chest-sdk/files";
147
390
  await files.put("photos/cat.png", bytes, "image/png"); // Uint8Array or text
148
391
  const file = await files.get("photos/cat.png"); // {data, type, size} or null
392
+ const info = await files.stat("photos/cat.png"); // {name, type, size, updated, width?, height?} or null
149
393
  const { files: page, next } = await files.list({ prefix: "photos/" }); // 1000 per page
150
- await files.delete("photos/cat.png"); // true, or false if it did not exist
151
- const { url, expiresIn } = await files.url("photos/cat.png");
394
+ await files.move("photos/cat.png", "archive/cat.png"); // atomic; replaces archive/cat.png
395
+ await files.delete("archive/cat.png"); // true, or false if it did not exist
396
+ const { url, expiresIn } = await files.url("photos/dog.png", { thumbnail: 256 });
152
397
  ```
153
398
 
154
399
  A name: up to 8 segments of 1 to 100 letters, digits, `.`, `_` or `-`,
155
400
  separated by `/`, none starting with `.` or `-`; refused before anything is
156
- sent otherwise (`ChestError`, `invalid_name`). `url` signs a link to the file
157
- as it is, on the tool's **team host** (`/_chest/files/…`): whoever has it opens
158
- it without signing in for 15 minutes, or until the file changes or goes; the
159
- Chest serves it in a sandbox, displayed for an image, a PDF or plain text,
160
- downloaded otherwise. Give it to a member's browser, never to a public page.
401
+ sent otherwise (`ChestError`, `invalid_name`).
402
+
403
+ ### Limits
404
+
405
+ Per tool: 1 GiB, 10,000 objects and 32 MiB per object, unless its manifest
406
+ asks otherwise — approved by the owner like any permission, and a later
407
+ version that asks more is approved again:
408
+
409
+ ```jsonc
410
+ // chest.json
411
+ { "capabilities": ["files"], "files": { "quota": "5 GiB", "maxObject": "100 MiB" } }
412
+ ```
413
+
414
+ `quota` goes from 100 MiB to 100 GiB (10 GiB and more allow 100,000 objects),
415
+ `maxObject` from 1 to 512 MiB; the owner or an admin may also set the quota
416
+ by hand. The SDK refuses beyond 512 MiB before sending anything; the Chest
417
+ holds the tool to its own bounds (`TooLarge`, `QuotaExceeded`). `put` and
418
+ `get` carry the bytes through the tool's server: for large files, let the
419
+ browser upload them itself.
420
+
421
+ ### Uploads from a member's browser
422
+
423
+ The bytes go from the browser to the Chest directly, never through the tool.
424
+ The tool authorises one upload, in a `/chest` route, once `member()` said who
425
+ asks:
426
+
427
+ ```ts
428
+ // Server side: app/chest/api/invoices/upload/route.ts
429
+ const up = await files.uploadUrl("invoices/2026/0042.pdf", {
430
+ maxSize: 10 << 20, // bytes; the tool's largest object when not said
431
+ types: ["application/pdf"], // up to 8, "image/*" for a family; any when not said
432
+ expiresIn: 300, // 1 to 900 seconds; 900 when not said
433
+ });
434
+ // → { url, method: "PUT", expiresIn }: hand it to the member's browser
435
+ ```
436
+
437
+ ```ts
438
+ // Browser side, on a page under /chest: the member's session goes with it
439
+ const response = await fetch(up.url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
440
+ // 201 {name, type, size}; 403 invalid_token (used, expired), 415 type_refused,
441
+ // 400 type_mismatch, 413 too_large, 429 quota_exceeded, 401 without a session
442
+ ```
443
+
444
+ ```ts
445
+ // Server side, when the browser says it is done
446
+ const info = await files.stat("invoices/2026/0042.pdf"); // null if nothing came
447
+ ```
448
+
449
+ `url` is `https://<tool's team host>/_chest/files/upload/<token>`: the same
450
+ origin as the tool's `/chest` pages, so no CORS. The token is signed by the
451
+ Chest and binds the name, the size, the types and the expiry; it serves once.
452
+ A name ending in `/` is a folder: the Chest then names the object (20 hex
453
+ characters and an extension from its type) and answers its name. The Chest
454
+ checks the declared size before reading, drops the body at the first byte too
455
+ many, and for images, PDFs and archives checks that the first bytes are of the
456
+ type sent; nothing of a refused upload remains. There is no antivirus scan.
457
+ `uploadUrl` answers `Unavailable` while the Chest does not know the tool's
458
+ team host yet.
459
+
460
+ ### Links and thumbnails
461
+
462
+ `url(name, {thumbnail?, download?})` signs a link to the file as it is, on the
463
+ tool's **team host** (`/_chest/files/…`): whoever has it opens it without
464
+ signing in for 15 minutes, or until the file changes or goes; the Chest serves
465
+ it in a sandbox, displayed for an image, a PDF or plain text, downloaded
466
+ otherwise, or always downloaded with `download: true`. `thumbnail: 256` or
467
+ `1024` links to the image reduced to that many pixels (JPEG, PNG, GIF — its
468
+ first frame — and WebP up to 40 megapixels; `ChestError` `no_thumbnail`
469
+ otherwise); thumbnails are made once, not counted in the quota. `stat` gives
470
+ `width` and `height` for these images. Give a link to a member's browser,
471
+ never to a public page.
472
+
161
473
  Errors: `CapabilityNotGranted` (a version without the capability, or no
162
474
  `CHEST_API`), `TooLarge` (413), `QuotaExceeded` (429), `Unavailable` (the
163
475
  Chest not reached, or an answer that is not its own: a write may or may not
164
- have happened), `ChestError` for the rest (`invalid_type`, `not_found` for
165
- `url`…). Removing the tool removes its files; a new version keeps them.
476
+ have happened), `ChestError` for the rest (`invalid_type`, `no_thumbnail`,
477
+ `not_found` for `url` and `move`…). Removing the tool removes its files; a new
478
+ version keeps them.
479
+
480
+ ## `testing` — a tool's own tests
166
481
 
167
- ## Retired v1 contract
482
+ `@argentic/chest-sdk/testing` is for tests, never imported by production code.
168
483
 
169
- Tool contract v1 (a worker talking over stdout/stdin: invocations, a record)
170
- is retired and **not part of the package**. Its modules,
171
- `client/src/{channel,record,requests,worker}.ts` and
172
- `client/test/worker.test.ts`, stay in this repository only because the Chest
173
- repository vendors `client/src`, `client/test` and `template/` by exact file
174
- list (`scripts/sync-sdk.mjs`, copies under `tests/sdk/chest-client` and
175
- `tests/creator`, each with a `VENDORED.md` naming the commit); they go once the
176
- Chest repository drops them. They are not built into `dist/`, not exported and
177
- not published. `template/` (the v1 starter project) is not published either.
484
+ ```ts
485
+ import { fakeChest, signAssertion, withMember } from "@argentic/chest-sdk/testing";
486
+
487
+ const camille = { id: "mbr_k2qhx4mzc7v3b6nfp5r2t7w4ya", firstName: "Camille", lastName: "Martin", name: "Camille Martin", photo: null, role: "editor", isAdmin: false, isBuilder: false, groups: [] };
488
+ const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications"] });
489
+ const response = await handler(withMember(new Request("http://tool.test/chest/tasks"), camille));
490
+ 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);
491
+ assert.deepEqual(chest.acknowledged, ["era_k2qhx4mzc7v3b6nfp5r2t7w4ya"]);
492
+ assert.deepEqual((await members.list()).members.map(m => m.id), [camille.id]);
493
+ assert.ok(chest.files.has("reports/2026.pdf"));
494
+ assert.deepEqual(chest.notifications, [{ member: camille.id, title: "New task", path: "/chest/tasks/42", key: "task:42" }]);
495
+ assert.equal(chest.badges.get(camille.id), 1);
496
+ await chest.close();
497
+ ```
498
+
499
+ | Function | Gives |
500
+ |---|---|
501
+ | `signAssertion(member, {token?, tool?, now?})` | A `Chest-Member` header value signed like the Chest's (the token and tool of the environment by default) |
502
+ | `withMember(request, member, options?)` | The request carrying that assertion: a new Web `Request`, or the same Node request |
503
+ | `fakeChest({members?, former?, groups?, capabilities?, receives?, files?})` | An HTTP server on `127.0.0.1` that sets `CHEST_API`, `CHEST_TOKEN`, `CHEST_TOOL` (`tool` unless set) and answers members, groups, files, badges, notifications and erasure acknowledgments with a Chest's bounds, quotas and errors; a capability left out answers 403 (`members`, `files` and `notifications` by default; `members.email` adds the addresses; `receives` is `["member.*"]` by default, `[]` refuses acknowledgments). A former member `{id, name?, erased?}` looks up as `former`, or `erased` |
504
+ | `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 |
505
+ | `chest.acknowledged` | The erasures the tool acknowledged, each once |
506
+ | `chest.members`, `chest.groups`, `chest.files` | What the fake Chest holds, to change or assert on; its `members` are those who have the tool |
507
+ | `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) |
508
+ | `chest.close()` | Stops it and restores the environment |
178
509
 
179
510
  ## Version
180
511
 
181
512
  The package version is `version` in `package.json` (semver), published by a
182
513
  tag `vX.Y.Z` (see `PUBLISHING.md`).
183
514
 
515
+ ## The MCP server
516
+
517
+ The MCP server an assistant runs to work on a Chest, `@argentic/chest-mcp`,
518
+ lives in its own repository:
519
+ [chest-by-argentic/Chest-MCP](https://github.com/chest-by-argentic/Chest-MCP).
520
+
184
521
  ## What this repository is not
185
522
 
186
523
  This repository is public and **is not a tool**: it has no `chest.json`, and a
@@ -191,15 +528,21 @@ that carry a manifest — never offers it.
191
528
 
192
529
  ```sh
193
530
  npm ci
194
- npm test # build dist/, compile all tests (v1 included) into build/, run them
531
+ npm test # build dist/, compile the tests into build/, run them
195
532
  npm run check:package # npm pack, install into a temp project, import every subpath
196
533
  # from Node and through esbuild, type-check a TS consumer
197
534
  ```
198
535
 
199
536
  `client/src` holds the modules, `client/index.ts` the package root,
200
- `client/test` the tests. `npm run build` compiles `client/index.ts` and the
201
- four published modules (`errors`, `member`, `database`, `files`; TypeScript
202
- strict, ES2022, NodeNext) into `dist/`: ESM `.js`, `.d.ts` and their maps. Read `AGENTS.md` before changing anything.
537
+ `client/test` the tests. `npm run build` compiles `client/index.ts`, the
538
+ seven published modules (`errors`, `member`, `members`, `database`, `files`,
539
+ `notifications`, `testing`) and the one they share (`api`, the Chest's API) —
540
+ TypeScript strict, ES2022, NodeNext — into `dist/`: ESM `.js`, `.d.ts` and
541
+ their maps. `member.ts` imports nothing but `node:*`, so that a tool may copy
542
+ it alone.
543
+ The package stays dependency-free (`node:*` only) and reaches nothing but the
544
+ Chest's API on `127.0.0.1`. `AGENTS.md` is a usage guide for AI agents
545
+ building a tool with this package.
203
546
 
204
547
  ## Licence
205
548
 
package/client/index.ts CHANGED
@@ -1,11 +1,14 @@
1
- // The package root: every published module of the SDK (tool contract v2).
2
- // Each one is also its own subpath (@argentic/chest-sdk/member, /database,
3
- // /files, /errors), which pulls in nothing else. The files API is a namespace
4
- // here, as its names (get, put, list, delete, url) are too plain to stand
5
- // alone. channel, record, requests and worker in client/src are the retired
6
- // v1 contract: kept for the Chest repository's vendored copy, never built
7
- // into nor published with the package.
1
+ // The package root: every published module a tool's code uses (tool contract
2
+ // v2). Each one is also its own subpath (@argentic/chest-sdk/member,
3
+ // /database, /files, /members, /notifications, /events, /errors), which
4
+ // pulls in nothing else. The files, members, notifications and events APIs
5
+ // are namespaces here, as their names (get, list, stat, move, notify,
6
+ // verify…) are too plain to stand alone. @argentic/chest-sdk/testing is for a tool's tests only, and
7
+ // is not here.
8
8
  export * from "./src/errors.js";
9
9
  export * from "./src/member.js";
10
10
  export * from "./src/database.js";
11
11
  export * as files from "./src/files.js";
12
+ export * as members from "./src/members.js";
13
+ export * as notifications from "./src/notifications.js";
14
+ export * as events from "./src/events.js";
@@ -0,0 +1,81 @@
1
+ import { CapabilityNotGranted, ChestError, QuotaExceeded, RateLimited, TooLarge, Unavailable } from "./errors.js";
2
+
3
+ // The Chest's API as a server tool reaches it, shared by the modules that
4
+ // call it (files, members): CHEST_API is http://127.0.0.1:<port>, the tool's
5
+ // launcher, which relays each request to the Chest — the container has no
6
+ // network. A call reaches what is the tool's only: its instance is its
7
+ // identity. Not a published module.
8
+
9
+ const maxAnswer = 4 << 20;
10
+ const deadline = 120000;
11
+
12
+ // base is the Chest's API as the launcher gives it; without, the version holds
13
+ // none of the capabilities that use it.
14
+ function base(capability: string): string {
15
+ const value = process.env["CHEST_API"];
16
+ if (typeof value !== "string" || !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/u.test(value) || Number(value.slice(17)) > 65535) throw new CapabilityNotGranted(capability);
17
+ return value;
18
+ }
19
+
20
+ // ask sends one request of a capability to the Chest; a failure to reach it
21
+ // is Unavailable.
22
+ export async function ask(capability: string, method: string, path: string, init: { body?: Uint8Array<ArrayBuffer> | string; type?: string } = {}): Promise<Response> {
23
+ const headers: Record<string, string> = {};
24
+ if (init.type !== undefined) headers["Content-Type"] = init.type;
25
+ const url = base(capability) + path;
26
+ try {
27
+ return await fetch(url, { method, headers, ...(init.body !== undefined ? { body: init.body } : {}), redirect: "error", signal: AbortSignal.timeout(deadline) });
28
+ } catch {
29
+ throw new Unavailable();
30
+ }
31
+ }
32
+
33
+ // read takes a body of limit bytes at most; beyond, or cut, the answer is not
34
+ // the Chest's.
35
+ export async function read(response: Response, limit: number): Promise<Uint8Array> {
36
+ const declared = Number(response.headers.get("content-length") ?? "0");
37
+ if (declared > limit) throw new Unavailable();
38
+ const chunks: Uint8Array[] = [];
39
+ let size = 0;
40
+ try {
41
+ for await (const chunk of response.body ?? []) {
42
+ size += chunk.byteLength;
43
+ if (size > limit) throw new Unavailable();
44
+ chunks.push(chunk);
45
+ }
46
+ } catch {
47
+ throw new Unavailable();
48
+ }
49
+ const all = new Uint8Array(size);
50
+ let at = 0;
51
+ for (const chunk of chunks) {
52
+ all.set(chunk, at);
53
+ at += chunk.byteLength;
54
+ }
55
+ return all;
56
+ }
57
+
58
+ // json reads an answer of the Chest as JSON; anything else is Unavailable.
59
+ export async function json(response: Response): Promise<unknown> {
60
+ try {
61
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await read(response, maxAnswer)));
62
+ } catch {
63
+ throw new Unavailable();
64
+ }
65
+ }
66
+
67
+ // refusal turns an answer that is not a success into what the tool tests.
68
+ export async function refusal(response: Response, capability: string): Promise<ChestError> {
69
+ let code = "refused";
70
+ try {
71
+ const given = ((await json(response)) as { error?: unknown } | null)?.error;
72
+ if (typeof given === "string" && /^[a-z_]{1,40}$/u.test(given)) code = given;
73
+ } catch {
74
+ // The code stays "refused".
75
+ }
76
+ if (response.status === 403) return new CapabilityNotGranted(capability);
77
+ if (response.status === 413) return new TooLarge();
78
+ if (response.status === 429) return code === "rate_limited" ? new RateLimited() : new QuotaExceeded();
79
+ if (response.status >= 500) return new Unavailable();
80
+ return new ChestError(code, response.status, `the Chest refused: ${code}`);
81
+ }