@argentic/chest-sdk 0.3.0 → 0.4.1

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 (51) hide show
  1. package/README.md +210 -44
  2. package/client/index.ts +4 -3
  3. package/client/src/api.ts +12 -0
  4. package/client/src/chest.ts +30 -6
  5. package/client/src/database.ts +6 -1
  6. package/client/src/events.ts +12 -104
  7. package/client/src/files.ts +9 -9
  8. package/client/src/members.ts +18 -13
  9. package/client/src/schedules.ts +87 -0
  10. package/client/src/signed.ts +166 -0
  11. package/client/src/testing.ts +130 -54
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +4 -3
  15. package/dist/index.js.map +1 -1
  16. package/dist/src/api.d.ts +1 -0
  17. package/dist/src/api.d.ts.map +1 -1
  18. package/dist/src/api.js +13 -0
  19. package/dist/src/api.js.map +1 -1
  20. package/dist/src/chest.d.ts +5 -0
  21. package/dist/src/chest.d.ts.map +1 -1
  22. package/dist/src/chest.js +13 -1
  23. package/dist/src/chest.js.map +1 -1
  24. package/dist/src/database.d.ts.map +1 -1
  25. package/dist/src/database.js +5 -1
  26. package/dist/src/database.js.map +1 -1
  27. package/dist/src/events.d.ts +3 -6
  28. package/dist/src/events.d.ts.map +1 -1
  29. package/dist/src/events.js +8 -102
  30. package/dist/src/events.js.map +1 -1
  31. package/dist/src/files.d.ts +1 -0
  32. package/dist/src/files.d.ts.map +1 -1
  33. package/dist/src/files.js +6 -7
  34. package/dist/src/files.js.map +1 -1
  35. package/dist/src/members.d.ts +1 -1
  36. package/dist/src/members.d.ts.map +1 -1
  37. package/dist/src/members.js +6 -5
  38. package/dist/src/members.js.map +1 -1
  39. package/dist/src/schedules.d.ts +15 -0
  40. package/dist/src/schedules.d.ts.map +1 -0
  41. package/dist/src/schedules.js +45 -0
  42. package/dist/src/schedules.js.map +1 -0
  43. package/dist/src/signed.d.ts +29 -0
  44. package/dist/src/signed.d.ts.map +1 -0
  45. package/dist/src/signed.js +139 -0
  46. package/dist/src/signed.js.map +1 -0
  47. package/dist/src/testing.d.ts +15 -5
  48. package/dist/src/testing.d.ts.map +1 -1
  49. package/dist/src/testing.js +122 -52
  50. package/dist/src/testing.js.map +1 -1
  51. package/package.json +14 -3
package/README.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # Chest SDK
2
2
 
3
- `@argentic/chest-sdk` is what a server tool (tool contract v2) embeds to talk
3
+ `@argentic/chest-sdk` is what a server tool (tool contract 0.4) embeds to talk
4
4
  with its Chest: the member the Chest asserts on a request, the Chest itself
5
- (its organization, time zone and language), the other members
6
- who have the tool, the address of the tool's own database, its private
7
- files, the badges and notifications it shows members inside the Chest, the
8
- events of its members' lifecycle, AI models through the Chest — and, for the
9
- tool's tests, a fake Chest. The SDK has no dependency: it
10
- only imports `node:*`.
5
+ (its organization, time zone, language and currency, and where the tool is
6
+ reached), the other members who have the tool, the address of the tool's own
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
10
+ contract ([`contract/`](contract/README.md): what `chest.json` may say, what
11
+ the Chest builds, what migrations may do, the policies it adds) and `chest
12
+ check`, the Chest's own validator. The SDK has no dependency: it only
13
+ imports `node:*`.
11
14
 
12
15
  ```sh
13
16
  npm install @argentic/chest-sdk
@@ -18,23 +21,24 @@ Node 22 or later. ESM only, compiled JavaScript with its type declarations.
18
21
  ## Imports
19
22
 
20
23
  Each module is its own subpath and pulls in nothing else; the root gives them
21
- all, with the files, members, notifications, events and ai APIs as the
22
- namespaces `files`, `members`, `notifications`, `events` and `ai` (the
23
- testing module is not in the root).
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).
24
27
 
25
28
  | Import | Gives |
26
29
  |---|---|
27
30
  | `@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 |
28
- | `@argentic/chest-sdk/chest` | `chest`, type `Chest`: the Chest the tool runs in — `chest.organization.name`, `chest.timeZone`, `chest.language`, `chest.today()` —, the same for every member, on a request or outside one |
31
+ | `@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 |
29
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`) |
30
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`) |
31
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 |
35
+ | `@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 |
32
36
  | `@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`) |
33
37
  | `@argentic/chest-sdk/database` | `databaseUrl()`: the address of the tool's own PostgreSQL database (capability `database`) |
34
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 |
35
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 |
36
- | `@argentic/chest-sdk/testing` | `signAssertion`, `withMember`, `fakeChest`, types `AssertionOptions`, `FakeChest`, `FakeChestOptions`, `FakeGroup`, `FakeFile`, `FakeNotification`, `FakeEvent`, `FakeAi`, `FakeAiModel`, `FakeAiReply`, `FakeAiCall`: for the tool's own tests only |
37
- | `@argentic/chest-sdk` | all of the above but `testing`; `files`, `members`, `notifications`, `events` and `ai` as namespaces |
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 |
38
42
 
39
43
  ```ts
40
44
  import { member } from "@argentic/chest-sdk/member";
@@ -49,6 +53,10 @@ import { CapabilityNotGranted } from "@argentic/chest-sdk/errors";
49
53
  // or: import { member, chest, databaseUrl, files, members, notifications, events, ai } from "@argentic/chest-sdk";
50
54
  ```
51
55
 
56
+ `chest check`, the Chest's validator, is a separate development package,
57
+ `@argentic/chest-check`, not published yet (see [Check a tool](#check-a-tool--chest-check)):
58
+ this one stays a small runtime client.
59
+
52
60
  Types refer to `node:http` (`IncomingMessage`): a TypeScript project needs
53
61
  `@types/node`, as any Node project does. Both `moduleResolution` `bundler` and
54
62
  `nodenext` work.
@@ -73,20 +81,53 @@ export function GET(request: Request) {
73
81
 
74
82
  ## The contract, in short
75
83
 
76
- A v2 tool is an ordinary web server in a container without network, run by
84
+ A tool is an ordinary web server in a container without network, run by
77
85
  its Chest. The Chest's front is the only one to reach it; the tool reaches only
78
86
  what its launcher gives it on `127.0.0.1` (its database, the Chest's API for
79
87
  its files, its members, its notifications and AI), and the Chest posts it the
80
- events it receives on `/chest-events`, through the same launcher. Rights come
88
+ events it receives on `/chest-events` and the runs of its schedules on
89
+ `/chest-schedules`, through the same launcher. Rights come
81
90
  from the Chest — the signed member, the capabilities approved for the
82
91
  version — and the Chest enforces them even outside the SDK:
83
- the SDK makes the calls easier, it is not a security boundary. The full
84
- contract (manifest `chest.json`, capabilities, build from source, catalogue)
85
- is described in the Chest repository, `docs/architecture.md`.
92
+ the SDK makes the calls easier, it is not a security boundary. The contract
93
+ itself — every key of `chest.json` and its bounds, what the Chest builds,
94
+ what migrations may create, the Content-Security-Policy it adds, Next.js on
95
+ a Chest — is [`contract/README.md`](contract/README.md), rendered from the
96
+ Chest's own code.
97
+
98
+ ## Check a tool — `chest check`
99
+
100
+ ```sh
101
+ # once, in a clone of chest-by-argentic/Chest-SDK (not on npm yet)
102
+ npm ci # builds check/ too
103
+ npx chest check /path/to/the/tool # --json for agents and CI
104
+ # or, in the tool's repository, a local devDependency
105
+ npm install --save-dev /path/to/Chest-SDK/check
106
+ npx chest check
107
+ ```
108
+
109
+ `@argentic/chest-check` is `check/` of this repository, not published on npm
110
+ yet: it runs from a clone.
111
+
112
+ The Chest's own validator — the code a Chest runs on every repository it
113
+ builds, compiled to WebAssembly (1.6 MB, in its own package, `check/` of
114
+ this repository, so that a tool's runtime dependencies stay small) — judges the repository as
115
+ the Chest would receive it: the files Git tracks or would add, as they are
116
+ now, committed or not. It says `OK` with the tool's name, roles, what it
117
+ asks and its migrations, or `Refused` with the Chest's reason (`manifest`,
118
+ `migrations`, `no_lock`, `newer_chest`…) and the rule broken; exit status 0,
119
+ 1, or 2 when it could not run (not a Git repository). It reads nothing but
120
+ the archive it is given, and needs no network and no Chest. Details:
121
+ [`contract/README.md`](contract/README.md#check-a-repository).
86
122
 
87
- ## `member(request)` — server tool (contract v2)
123
+ `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
125
+ refuses the tool with “This tool needs a newer version of your Chest”;
126
+ up to its own version, a key it does not know is refused, never ignored.
88
127
 
89
- A v2 tool is an ordinary web server; on its team host, the Chest relays
128
+ ## `member(request)` — the member of a request
129
+
130
+ A tool is an ordinary web server; on its team host, the Chest relays
90
131
  `/chest` and everything below it with the `Chest-Member` header of the
91
132
  signed-in member. `member(request)` accepts a Node request (`IncomingMessage`)
92
133
  or a Web `Request` and returns a `Member`, the type the `members` API
@@ -176,13 +217,20 @@ import { chest } from "@argentic/chest-sdk/chest";
176
217
  chest.organization.name; // "Acme SAS": the organization the Chest is of, as its owner wrote it
177
218
  chest.timeZone; // "Europe/Paris": an IANA zone, "UTC" until the owner sets one
178
219
  chest.language; // "fr": the Chest's own language (a member's is member(request).language)
220
+ chest.currency; // "EUR": the Chest's currency, ISO 4217
221
+ chest.tool.teamUrl; // "https://tasks-chest.acme.argentic.work": where members open /chest
222
+ chest.tool.publicUrl; // "https://status.acme.com": the public part (its custom domain), or null
179
223
  chest.today(); // "2026-09-30": the date now in the Chest's zone (or chest.today(at))
180
224
  ```
181
225
 
182
226
  The Chest gives these to every tool in its environment at each start
183
- (`CHEST_ORGANIZATION`, `CHEST_TIME_ZONE`, `CHEST_LANGUAGE`), and starts every
184
- tool again when its owner changes one in Settings → General — the tool never
185
- asks its own admin for the company's name or zone. They are there outside a
227
+ (`CHEST_ORGANIZATION`, `CHEST_TIME_ZONE`, `CHEST_LANGUAGE`, `CHEST_CURRENCY`,
228
+ `CHEST_TEAM_URL`, `CHEST_PUBLIC_URL`), and starts every tool that is awake
229
+ again when one changes — the owner changes the first four in Settings →
230
+ General; a custom domain served, or no longer, changes the public address —;
231
+ a tool asleep reads them when it wakes. The tool never asks its own admin for
232
+ the company's name, zone or currency, nor guesses its own address from a
233
+ request. They are there outside a
186
234
  request too: a scheduled job, a start-up task, an export. No capability is
187
235
  needed: nothing here is more than what the Chest's pages show its members.
188
236
 
@@ -195,6 +243,16 @@ needed: nothing here is more than what the Chest's pages show its members.
195
243
  - `language` is the language of what the tool writes for no one in
196
244
  particular: a public page before the visitor chooses, an export's default.
197
245
  A page of `/chest` speaks `member(request).language` instead.
246
+ - `currency` is the ISO 4217 code of the Chest's currency (`"EUR"` until
247
+ the owner sets one): the amounts of a quote, a price, an expense. Format
248
+ them with `Intl.NumberFormat(language, { style: "currency", currency:
249
+ chest.currency })`.
250
+ - `tool.teamUrl` and `tool.publicUrl` are origins, without a path: build a
251
+ link where no request tells the host — an email sent from a scheduled job,
252
+ a calendar feed — with `new URL("/chest/tasks/42", chest.tool.teamUrl)`.
253
+ `publicUrl` is the company's own domain once the owner connected one, else
254
+ the tool's public host; `null` for a tool without a public part. Store
255
+ paths in your data, never these origins: they change.
198
256
  - `today(at?)` is `YYYY-MM-DD` in the Chest's zone, for now or for an instant
199
257
  (`Date` or milliseconds): compare it with dates your database keeps as
200
258
  `date`, never with `new Date().toISOString().slice(0, 10)`, which is UTC's.
@@ -203,11 +261,12 @@ Each value is read from the environment at each access, and checked: outside a
203
261
  Chest (a development server without the variables), or for a value the Chest
204
262
  never gives, reading it throws a `ChestError` with the code `not_in_chest` —
205
263
  a wrong zone read silently is exactly what this module exists to prevent. In
206
- tests, `fakeChest({chest: {organization, timeZone, language}})` sets them.
264
+ tests, `fakeChest({chest: {organization, timeZone, language, currency,
265
+ teamUrl, publicUrl}})` sets them.
207
266
 
208
267
  ## `members` — who has the tool
209
268
 
210
- A v2 tool that declares `"capabilities": ["members"]` (approved like a
269
+ A tool that declares `"capabilities": ["members"]` (approved like a
211
270
  permission: “Sees the name, photo, role and groups of the members who have
212
271
  access to it.”) reads the members who have it, through the Chest's API
213
272
  (`CHEST_API`, as for files). `"members.email"`, a permission of its own that
@@ -224,8 +283,9 @@ const teams = await members.groups.list(); // [
224
283
 
225
284
  - **Who**: exactly the members who have the tool now — by a grant, a group,
226
285
  open to all, or because they run it (owner, admins, its builders);
227
- recomputed at every call. A member without access answers as an identifier
228
- that does not exist (`get` → `null`, `lookup` → `unknown`).
286
+ recomputed at every call. `list` and `get` see only them (`get` → `null`
287
+ for anyone else); `lookup` also names those the tool had who no longer
288
+ have it (below).
229
289
  - **`list({after, limit, q, role, group})`**: ordered by name (accents aside)
230
290
  then identifier; `limit` 100 by default, 500 at most; `next` is an opaque
231
291
  cursor for `after`, `null` after the last page. `q` finds the start of a
@@ -233,10 +293,14 @@ const teams = await members.groups.list(); // [
233
293
  —, whatever its case and accents; `role` and `group` keep the members of that
234
294
  role or group.
235
295
  - **`lookup(ids)`**: each identifier once, in the order given: `members`,
236
- `former` (`{id, name, status: "former"}`: someone who left the Chest after
237
- having the tool, so a record still reads “Camille Martin (former member)”;
238
- `{id, name: null, status: "erased"}` once the owner had their data erased,
239
- rendered “Former member”) and `unknown`. The SDK asks 200 at a time and
296
+ `former` — those the tool had who no longer have it: `{id, name, status:
297
+ "no_access"}`, a member of the Chest who lost access to the tool (“Léa
298
+ Dubois (no access)”: the laptops she holds, the goals that need a new
299
+ owner); `{id, name, status: "former"}`, someone who left the Chest, so a
300
+ record still reads “Camille Martin (former member)”; `{id, name: null,
301
+ status: "erased"}` once the owner had their data erased, rendered “Former
302
+ member” — and `unknown`: an identifier the tool never had (the Chest names
303
+ nobody the tool never had, not even a member of the Chest). The SDK asks 200 at a time and
240
304
  keeps each answer a minute in the process (5,000 at most); `forget()`
241
305
  empties it, and so does every event of the members' lifecycle
242
306
  (`events.handle`).
@@ -269,7 +333,7 @@ To search tasks by assignee name: `members.list({ q })` first, then
269
333
 
270
334
  ## `notifications` — badges and inbox items
271
335
 
272
- A v2 tool that declares `"capabilities": ["notifications"]` (approved like a
336
+ A tool that declares `"capabilities": ["notifications"]` (approved like a
273
337
  permission: “Shows counters and sends notifications, inside the Chest, to the
274
338
  members who have access to it.”) tells its members what needs their
275
339
  attention, inside the Chest only — no email, no push to a phone. Two
@@ -349,13 +413,13 @@ away by itself once handled.
349
413
 
350
414
  ## `events` — the members' lifecycle
351
415
 
352
- A v2 tool that holds `members` and declares `"receives": ["member.*"]` in its
416
+ A tool that holds `members` and declares `"receives": ["member.*"]` in its
353
417
  `chest.json` (approved like a permission: “Is told when the members who have
354
418
  access to it change or leave.”) is told, on its own `POST /chest-events`:
355
419
 
356
420
  | Event | `data` | When |
357
421
  |---|---|---|
358
- | `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`) |
422
+ | `member.updated` | `{id, changed: ("name" \| "photo" \| "role" \| "groups" \| "email" \| "language" \| "timeZone")[]}` | Something the tool sees of a member who has it changed (`email` only with `members.email`; `language` and `timeZone`: the language the Chest speaks to them and the zone they work in — a digest's words and hour) |
359
423
  | `access.revoked` | `{id}` | The member lost access to the tool but stays in the Chest |
360
424
  | `member.removed` | `{id}` | The member left the Chest: `lookup` now reads them `former` |
361
425
  | `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)` |
@@ -420,7 +484,7 @@ export async function POST(request: Request) {
420
484
 
421
485
  ## `ai` — AI models through the Chest
422
486
 
423
- A v2 tool that declares the `ai` capability calls AI models through its
487
+ A tool that declares the `ai` capability calls AI models through its
424
488
  Chest. The Chest's owner connects OpenRouter with the company's own key;
425
489
  the tool calls models by four **aliases**: `default`, `fast`, `smart`,
426
490
  `embedding`, each led by the Chest to a model it chose (the owner may choose
@@ -529,13 +593,14 @@ before it.
529
593
 
530
594
  ## `databaseUrl()` — database of a server tool
531
595
 
532
- A v2 tool that declares `"capabilities": ["database"]` in its `chest.json`
596
+ A tool that declares `"capabilities": ["database"]` in its `chest.json`
533
597
  gets a PostgreSQL database of its own (the capability is shown and approved
534
598
  like a permission, in the approval screen). The container has no network: its
535
599
  launcher listens on `127.0.0.1` and relays each connection to the Chest. The
536
600
  launcher sets `DATABASE_URL` —
537
601
  `postgres://<user>:<password>@127.0.0.1:<port>/<database>?sslmode=disable`,
538
- the user and the database both named `t_<tool>` — and `PGHOST`, `PGPORT`,
602
+ the user and the database both named `t_<tool>`, or `pb_<project>` in the
603
+ preview of a draft Perseus Code builds — and `PGHOST`, `PGPORT`,
539
604
  `PGUSER`, `PGPASSWORD`, `PGDATABASE`, which take precedence over a variable of
540
605
  the tool with the same name. `databaseUrl()` returns `DATABASE_URL` when it has
541
606
  exactly this shape, and throws `CapabilityNotGranted` otherwise (a version
@@ -565,7 +630,7 @@ previous version working — going back to the previous version undoes nothing.
565
630
 
566
631
  ## `files` — files of a server tool
567
632
 
568
- A v2 tool that declares `"capabilities": ["files"]` (approved like a permission)
633
+ A tool that declares `"capabilities": ["files"]` (approved like a permission)
569
634
  keeps private files **through its Chest**, never on its disk (the container's
570
635
  root is read-only). The launcher gives the tool
571
636
  `CHEST_API=http://127.0.0.1:<port>` — its own port, relayed to the Chest; the
@@ -576,7 +641,7 @@ its own files only.
576
641
  import * as files from "@argentic/chest-sdk/files";
577
642
  await files.put("photos/cat.png", bytes, "image/png"); // Uint8Array or text
578
643
  const file = await files.get("photos/cat.png"); // {data, type, size} or null
579
- const info = await files.stat("photos/cat.png"); // {name, type, size, updated, width?, height?} or null
644
+ const info = await files.stat("photos/cat.png"); // {name, type, size, sha256, updated, width?, height?} or null
580
645
  const { files: page, next } = await files.list({ prefix: "photos/" }); // 1000 per page
581
646
  await files.move("photos/cat.png", "archive/cat.png"); // atomic; replaces archive/cat.png
582
647
  await files.delete("archive/cat.png"); // true, or false if it did not exist
@@ -657,6 +722,18 @@ otherwise); thumbnails are made once, not counted in the quota. `stat` gives
657
722
  `width` and `height` for these images. Give a link to a member's browser,
658
723
  never to a public page.
659
724
 
725
+ Every file the Chest answers (`put`, `stat`, `list`, `move`) carries
726
+ `sha256`, the digest of its content in hex, as the Chest took it: compare
727
+ it, or detect a duplicate receipt, without reading the file again.
728
+
729
+ The SDK takes a link or an upload address from the Chest only in `https`
730
+ on the team host, or on the origin of the Chest's API itself (`CHEST_API`,
731
+ `http://127.0.0.1:<port>`): the address the tool already sends every call
732
+ to, where only a fake Chest (`@argentic/chest-sdk/testing`) serves its
733
+ links. A real Chest never answers one there, and no other local address is
734
+ ever taken — so a tool a test starts in its own process (`next start` with
735
+ the fake's environment) takes the fake's links as the test itself does.
736
+
660
737
  Errors: `CapabilityNotGranted` (a version without the capability, or no
661
738
  `CHEST_API`), `TooLarge` (413), `QuotaExceeded` (429), `Unavailable` (the
662
739
  Chest not reached, or an answer that is not its own: a write may or may not
@@ -664,6 +741,79 @@ have happened), `ChestError` for the rest (`invalid_type`, `no_thumbnail`,
664
741
  `not_found` for `url` and `move`…). Removing the tool removes its files; a new
665
742
  version keeps them.
666
743
 
744
+ ## `schedules` — work the tool does by itself
745
+
746
+ Nothing runs in a tool's container between requests — the Chest puts a tool
747
+ nobody uses to sleep —: a morning digest, reminders, a purge or a badge kept
748
+ true overnight come from the Chest, which calls the tool at set times. The
749
+ tool declares each schedule in its `chest.json`, a name and a cron line read
750
+ on the wall clock of the Chest's time zone (`chest.timeZone`), approved in
751
+ words (“Runs by itself: morning, weekdays at 7:30 AM”):
752
+
753
+ ```jsonc
754
+ // chest.json
755
+ { "schedules": [{ "name": "morning", "cron": "30 7 * * 1-5" }, { "name": "retry-mail", "cron": "*/15 * * * *" }] }
756
+ ```
757
+
758
+ ```ts
759
+ // app/chest-schedules/route.ts — at the root, outside /chest: the Chest calls
760
+ // it through the tool's launcher, never from a browser (its front answers 404 there).
761
+ import * as schedules from "@argentic/chest-sdk/schedules";
762
+ import { chest } from "@argentic/chest-sdk/chest";
763
+
764
+ export async function POST(request: Request) {
765
+ return new Response(null, { status: await schedules.handle(request, {
766
+ morning: async () => { await sendDigest(chest.today()); },
767
+ "retry-mail": () => retryOutbox(),
768
+ }, { seen }) });
769
+ }
770
+ ```
771
+
772
+ - **The line**: five fields — minute, hour, day of the month, month, day of
773
+ the week —, each numbers, `*`, ranges (`1-5`), lists (`1,15`) and steps
774
+ (`*/15`); Sunday is 0 or 7; no names nor `@daily`, one space between
775
+ fields. When both days are restricted, either one runs (as cron). A time
776
+ a change of clock skips runs once, shifted; a repeated one runs once.
777
+ - **Bounds** (the Chest's, checked when the manifest is read): 8 schedules,
778
+ names of 1 to 32 lowercase letters, digits and hyphens, each running 15
779
+ minutes apart at least; 5 minutes a run.
780
+ - **Approval**: running by itself is a permission, one sentence per
781
+ schedule. A later version that changes, adds or removes schedules of a
782
+ tool that already had one asks nothing more.
783
+ - **Delivery**: `POST /chest-schedules`, the tool woken first when it
784
+ sleeps, body `{id: "run_…", name, scheduledAt, attempt}` signed for this
785
+ tool (`Chest-Schedule` header, HS256 under a key derived from
786
+ `CHEST_TOKEN` with the label `Chest-Schedule v1` — the scheme of events,
787
+ under a key of its own —, naming the run and the SHA-256 of the body, 60
788
+ seconds). `scheduledAt` is the time the run stands for (UTC); a run asked
789
+ now stands for the time it was asked.
790
+ - **Answer once the work is done**, within 5 minutes: a 2xx is done; a 404
791
+ (a schedule without a handler) is given up at once; anything else, or no
792
+ answer, is delivered again, the same run with the same id, after 1, 5 and
793
+ 15 minutes (`attempt` 2 to 4), unless the next time of its schedule comes
794
+ first. Runs of one schedule never overlap: a time that comes while the
795
+ previous run still runs is skipped. A server that was stopped runs a
796
+ missed time once when it starts again — the latest, never a backlog.
797
+ Longer work: do a batch per run and keep your place in the database.
798
+ - **`handle(request, handlers, {seen?})`** answers the status to give the
799
+ Chest: 401 for what is not a run of the Chest for this tool, 404 for a
800
+ schedule without a handler, 204 for a run handled or one already in
801
+ `seen`. It reads the body (1 KiB at most): mount it before any body
802
+ parser. A handler that throws leaves the run unseen and `handle` throws:
803
+ answer 500, it comes again. `seen` is as for `events` (`events.memorySeen`
804
+ by default; a table of the tool's for runs that must never be done twice —
805
+ the same table serves both, the ids never meet). Make handlers idempotent
806
+ anyway.
807
+ - **`verify(request)`** is the run of a delivery, or `null`; for a tool that
808
+ routes runs itself.
809
+ - **The Chest's times, the members' zones**: a line is the company's clock.
810
+ To reach each member at *their* 8:00, run hourly (`0 * * * *`) and pick
811
+ the members whose local hour it is (`members.list`, `member.timeZone`).
812
+ - **Whoever runs the tool** sees each schedule on its overview — when it
813
+ runs next, its last runs and why one failed — and may **Run now**; an
814
+ agent reads `GET /api/v1/tools/<tool>/schedules` and runs one with
815
+ `POST /api/v1/tools/<tool>/schedules/run {name}` (a token that writes).
816
+
667
817
  ## `testing` — a tool's own tests
668
818
 
669
819
  `@argentic/chest-sdk/testing` is for tests, never imported by production code.
@@ -681,6 +831,7 @@ assert.ok(chest.files.has("reports/2026.pdf"));
681
831
  assert.deepEqual(chest.notifications, [{ member: camille.id, title: "New task", path: "/chest/tasks/42", key: "task:42" }]);
682
832
  assert.equal(chest.badges.get(camille.id), 1);
683
833
  assert.equal(chest.ai[0]?.path, "/ai/chat");
834
+ assert.equal(await chest.run("morning", request => handler(request)), 204);
684
835
  await chest.close();
685
836
  ```
686
837
 
@@ -688,9 +839,11 @@ await chest.close();
688
839
  |---|---|
689
840
  | `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 |
690
841
  | `withMember(request, member, options?)` | The request carrying that assertion (the options of `signAssertion`): a new Web `Request`, or the same Node request |
691
- | `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: {organization, timeZone, language}`: `"Test organization"`, `"UTC"`, `"en"` by default) 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). A former member `{id, name?, erased?}` looks up as `former`, or `erased` |
842
+ | `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` |
843
+ | 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 |
692
844
  | `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 |
693
845
  | `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 |
846
+ | `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 |
694
847
  | `chest.ai` | The tool's calls to AI, `{path, body}` in order (`body` null for a `GET`) |
695
848
  | `chest.acknowledged` | The erasures the tool acknowledged, each once |
696
849
  | `chest.members`, `chest.groups`, `chest.files` | What the fake Chest holds, to change or assert on; its `members` are those who have the tool |
@@ -700,7 +853,9 @@ await chest.close();
700
853
  ## Version
701
854
 
702
855
  The package version is `version` in `package.json` (semver), published by a
703
- tag `vX.Y.Z` (see `PUBLISHING.md`).
856
+ tag `vX.Y.Z` (see `PUBLISHING.md`). Its MAJOR.MINOR is the version of the
857
+ tool contract it is written for (`"chest"` in `chest.json`): 0.4.x for the
858
+ contract 0.4. A new contract version is a new MINOR of the SDK.
704
859
 
705
860
  ## The MCP server
706
861
 
@@ -718,11 +873,22 @@ that carry a manifest — never offers it.
718
873
 
719
874
  ```sh
720
875
  npm ci
721
- npm test # build dist/, compile the tests into build/, run them
722
- npm run check:package # npm pack, install into a temp project, import every subpath
723
- # from Node and through esbuild, type-check a TS consumer
876
+ npm test # build dist/, compile the tests into build/, check that
877
+ # contract/README.md says what contract.json says, run them,
878
+ # then check/'s (the chest command)
879
+ npm run check:package # npm pack both packages, the SDK under 200 KiB, install into a
880
+ # temp project, run chest check, import every subpath from Node
881
+ # and through esbuild, type-check a TS consumer
724
882
  ```
725
883
 
884
+ `contract/contract.json`, `check/check.wasm.gz` and
885
+ `check/check.wasm.sha256` are written by the Chest's repository
886
+ (`scripts/build-contract.mjs`) from the code that decides; never edit them
887
+ here. `npm run contract` renders the parts of `contract/README.md` they say;
888
+ the words around them are written here. `check/` is the workspace of
889
+ `@argentic/chest-check`, released with the SDK under the same version
890
+ (`PUBLISHING.md`); `check/src/cli.ts` is the `chest` command.
891
+
726
892
  `client/src` holds the modules, `client/index.ts` the package root,
727
893
  `client/test` the tests. `npm run build` compiles `client/index.ts`, the
728
894
  nine published modules (`errors`, `member`, `members`, `database`, `files`,
package/client/index.ts CHANGED
@@ -1,7 +1,7 @@
1
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, /chest,
3
- // /database, /files, /members, /notifications, /events, /ai, /errors), which
4
- // pulls in nothing else. The files, members, notifications, events and ai
2
+ // 0.4). Each one is also its own subpath (@argentic/chest-sdk/member, /chest,
3
+ // /database, /files, /members, /notifications, /events, /schedules, /ai, /errors), which
4
+ // pulls in nothing else. The files, members, notifications, events, schedules and ai
5
5
  // APIs are namespaces here, as their names (get, list, stat, move, notify,
6
6
  // verify, chat…) are too plain to stand alone. @argentic/chest-sdk/testing is for a tool's tests only, and
7
7
  // is not here.
@@ -13,4 +13,5 @@ export * as files from "./src/files.js";
13
13
  export * as members from "./src/members.js";
14
14
  export * as notifications from "./src/notifications.js";
15
15
  export * as events from "./src/events.js";
16
+ export * as schedules from "./src/schedules.js";
16
17
  export * as ai from "./src/ai.js";
package/client/src/api.ts CHANGED
@@ -9,6 +9,18 @@ import { CapabilityNotGranted, ChestError, QuotaExceeded, RateLimited, TooLarge,
9
9
  const maxAnswer = 4 << 20;
10
10
  const deadline = 120000;
11
11
 
12
+ // chestLink reads a link to the team host the Chest answered, at path (its
13
+ // links, its uploads): the token it carries, or undefined for an address
14
+ // that is not one — https, or the origin of the Chest's API itself
15
+ // (CHEST_API), where only a fake Chest of a tool's tests serves its links:
16
+ // the address the tool already trusts for every call, never another.
17
+ export function chestLink(url: unknown, path: string): string | undefined {
18
+ if (typeof url !== "string") return undefined;
19
+ const found = /^(https:\/\/[A-Za-z0-9.-]{1,253}(?::[0-9]{1,5})?|http:\/\/127\.0\.0\.1:[0-9]{1,5})(\/_chest\/[a-z/]+\/)([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/u.exec(url);
20
+ if (!found || found[2] !== path || (found[1]!.startsWith("http:") && found[1] !== process.env["CHEST_API"])) return undefined;
21
+ return found[3];
22
+ }
23
+
12
24
  // base is the Chest's API as the launcher gives it; without, the version holds
13
25
  // none of the capabilities that use it.
14
26
  function base(capability: string): string {
@@ -2,11 +2,13 @@ import { ChestError } from "./errors.js";
2
2
  import { languagePattern, timeZonePattern } from "./member.js";
3
3
 
4
4
  // The Chest the tool runs in, the same for every member and every request:
5
- // the organization it is of, its time zone and its language. The Chest sets
6
- // them in the tool's environment at each start (CHEST_ORGANIZATION,
7
- // CHEST_TIME_ZONE, CHEST_LANGUAGE) and starts the tool again when its owner
8
- // changes one, so they are there outside any request too: in a scheduled
9
- // job, at start-up, in a migration script. The Chest also sets its time zone
5
+ // the organization it is of, its time zone, its language and its currency —
6
+ // and where this tool is reached. The Chest sets them in the tool's
7
+ // environment at each start (CHEST_ORGANIZATION, CHEST_TIME_ZONE,
8
+ // CHEST_LANGUAGE, CHEST_CURRENCY, CHEST_TEAM_URL, CHEST_PUBLIC_URL) and
9
+ // starts the tool again, when it is awake, as soon as one changes, so they
10
+ // are there outside any request too: in a scheduled job, at start-up, in a
11
+ // migration script. The Chest also sets its time zone
10
12
  // as the zone of the tool's database sessions: there, current_date and
11
13
  // now()::date are the Chest's day too.
12
14
  //
@@ -18,6 +20,14 @@ import { languagePattern, timeZonePattern } from "./member.js";
18
20
  // - language is the Chest's own language, a primary tag ("en", "fr"): the
19
21
  // language of what the tool writes for no one in particular (a public page
20
22
  // before the visitor chooses, an export). A member's is member.language.
23
+ // - currency is the ISO 4217 code of the Chest's currency ("EUR" until the
24
+ // owner sets one): the amounts the tool writes — a quote, a price.
25
+ // - tool.teamUrl is the origin of the tool's team host, where its members
26
+ // open /chest; tool.publicUrl the origin of its public part — the
27
+ // company's own domain when the owner connected one —, null for a tool
28
+ // without a public part. Origins, without a path: a link in an email is
29
+ // new URL("/chest/tasks/42", chest.tool.teamUrl). Store paths, never
30
+ // these origins: they change with a custom domain.
21
31
  // - today() is the date ("YYYY-MM-DD") in the Chest's zone, now or at the
22
32
  // instant given.
23
33
  //
@@ -28,13 +38,19 @@ export type Chest = {
28
38
  readonly organization: { readonly name: string };
29
39
  readonly timeZone: string;
30
40
  readonly language: string;
41
+ readonly currency: string;
42
+ readonly tool: { readonly teamUrl: string; readonly publicUrl: string | null };
31
43
  today(at?: Date | number): string;
32
44
  };
33
45
 
34
46
  // The shapes the Chest gives: the organization's (2 to 80 characters,
35
47
  // counted as code points, without control characters), a zone's
36
- // (timeZonePattern, and one this runtime knows), a language's.
48
+ // (timeZonePattern, and one this runtime knows), a language's, a currency's,
49
+ // an origin's.
37
50
  const organizationPattern = /^[^\u0000-\u001f\u007f-\u009f]{2,80}$/u;
51
+ // An ISO 4217 code; an https origin without a path, as the Chest gives them.
52
+ const currencyPattern = /^[A-Z]{3}$/u;
53
+ const originPattern = /^https:\/\/[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?(:[0-9]{1,5})?$/u;
38
54
 
39
55
  function read(name: string, valid: (value: string) => boolean): string {
40
56
  const value = process.env[name];
@@ -72,6 +88,14 @@ export const chest: Chest = {
72
88
  get language() {
73
89
  return read("CHEST_LANGUAGE", value => languagePattern.test(value));
74
90
  },
91
+ get currency() {
92
+ return read("CHEST_CURRENCY", value => currencyPattern.test(value));
93
+ },
94
+ get tool() {
95
+ const teamUrl = read("CHEST_TEAM_URL", value => originPattern.test(value));
96
+ const publicUrl = process.env["CHEST_PUBLIC_URL"] === undefined ? null : read("CHEST_PUBLIC_URL", value => originPattern.test(value));
97
+ return { teamUrl, publicUrl };
98
+ },
75
99
  today(at: Date | number = Date.now()): string {
76
100
  const instant = typeof at === "number" ? new Date(at) : at;
77
101
  if (Number.isNaN(instant.getTime())) throw new RangeError("today() needs a valid date");
@@ -8,9 +8,14 @@ import { CapabilityNotGranted } from "./errors.js";
8
8
  // postgres (porsager) or pg; the SDK carries none. PGHOST, PGPORT, PGUSER,
9
9
  // PGPASSWORD and PGDATABASE say the same for a client that reads them.
10
10
  //
11
+ // Its user is the tool's role (t_<tool>), or, in the preview of a draft
12
+ // Perseus Code builds, the draft's (pb_<project>: its own empty database).
13
+ //
11
14
  // Throws CapabilityNotGranted when the Chest gave no database: the version
12
15
  // does not declare it, or a DATABASE_URL of the tool's own is not the
13
16
  // Chest's. The value is a secret: never log it, never send it to a browser.
17
+ const role = /^(t_[a-z][a-z0-9_]{0,47}|pb_[a-z2-7]{26})$/u;
18
+
14
19
  export function databaseUrl(): string {
15
20
  const value = process.env["DATABASE_URL"];
16
21
  if (typeof value !== "string" || value.length > 1024) throw new CapabilityNotGranted("database");
@@ -21,7 +26,7 @@ export function databaseUrl(): string {
21
26
  throw new CapabilityNotGranted("database");
22
27
  }
23
28
  const port = Number(url.port);
24
- if (url.protocol !== "postgres:" || url.hostname !== "127.0.0.1" || !Number.isInteger(port) || port < 1 || port > 65535 || !/^t_[a-z][a-z0-9_]{0,47}$/u.test(url.username) || url.pathname !== "/" + url.username || url.password === "" || url.search !== "?sslmode=disable" || url.hash !== "") {
29
+ if (url.protocol !== "postgres:" || url.hostname !== "127.0.0.1" || !Number.isInteger(port) || port < 1 || port > 65535 || !role.test(url.username) || url.pathname !== "/" + url.username || url.password === "" || url.search !== "?sslmode=disable" || url.hash !== "") {
25
30
  throw new CapabilityNotGranted("database");
26
31
  }
27
32
  return value;