@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
@@ -1,35 +1,51 @@
1
- import { createHash, randomBytes } from "node:crypto";
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3
3
  import type { AddressInfo } from "node:net";
4
+ import type { Duplex } from "node:stream";
4
5
  import type { Alias, Provider } from "./ai.js";
5
6
  import type { AiUnavailableReason } from "./errors.js";
6
- import type { ChestEvent } from "./events.js";
7
- import { groupIdPattern, memberIdPattern, type Member } from "./member.js";
7
+ import { clockSkew, eventWindow, fieldNamePattern, fieldOf, instantOf, isAudience, isDeclaredData, isToolEventType, itemIdPattern, maxData, rolePattern } from "./eventrules.js";
8
+ import type { ChestEvent, EmitAudience, ToolEvent } from "./events.js";
9
+ import { groupIdPattern, languagePattern, memberIdPattern, type Member } from "./member.js";
8
10
  import { forget } from "./members.js";
9
- import { fakeOrigins } from "./api.js";
10
- import { eventChannel, scheduleChannel, sign, signClaims, type Channel } from "./signed.js";
11
+ import { eventChannel, json, object, scheduleChannel, sign, signClaims, type Channel } from "./signed.js";
12
+ import { fakeRealtime, type FakeRealtime, type FakeRealtimeOptions } from "./testing-realtime.js";
13
+
14
+ export type { FakeChannelRule, FakeFeed, FakePublished, FakeRealtime, FakeRealtimeOptions, FakeSent } from "./testing-realtime.js";
11
15
 
12
16
  // For a tool's own tests, never imported by its production code: a member's
13
17
  // assertion signed as the Chest signs it, and a Chest's API in the test's
14
18
  // process that answers members, groups, files (stat, move, links and
15
- // uploads, which it serves and takes itself as the team host would, on its
16
- // own origin), badges, notifications, AI (chat,
19
+ // uploads — its members' and its visitors' —, which it serves and takes
20
+ // itself as the team host and the public part would, on its own origin),
21
+ // sealed values (sealed and opened under a key of its own,
22
+ // for the members and roles it keeps; withMember carries the member's
23
+ // ticket), badges, notifications and broadcasts, AI (chat,
17
24
  // streamed or not, embeddings, models, usage: deterministic answers, no
18
- // provider) and the acknowledgment of an erasure with the Chest's bounds,
19
- // quotas and errors; and that delivers an event or a run of a schedule to
20
- // the tool, signed as the Chest signs them.
25
+ // provider), the acknowledgment of an erasure, the events the tool emits
26
+ // (checked against the "emits" of its chest.json, as the Chest checks them,
27
+ // and kept in order) and realtime (the tool's publishes and sends, and the
28
+ // Chest's side of its pages, which the browser client connects to) with the
29
+ // Chest's bounds, quotas and errors; and that delivers an event — of the
30
+ // members' lifecycle, or of another tool — or a run of a schedule to the
31
+ // tool, signed as the Chest signs them.
21
32
  //
22
33
  // import { fakeChest, withMember } from "@argentic/chest-sdk/testing";
23
- // const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications"] });
34
+ // const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications"], emits: { "task.done": { description: "A task is done", data: { task: "id" } } } });
24
35
  // const response = await app(withMember(new Request("http://tool/chest"), camille));
25
36
  // assert.equal(chest.notifications[0]?.member, camille.id);
26
- // assert.equal(await chest.emit({ type: "access.revoked", data: { id: camille.id } }, request => app(request)), 204);
37
+ // assert.equal(await chest.deliver({ type: "access.revoked", data: { id: camille.id } }, request => app(request)), 204);
38
+ // assert.equal(await chest.deliver({ type: "quote.accepted", source: "quotes", data: { quote: "q-1" } }, request => app(request)), 204);
39
+ // assert.equal(chest.emitted[0]?.type, "task.done");
27
40
  // assert.equal(await chest.run("morning", request => app(request)), 204);
28
41
  // await chest.close();
29
42
 
30
- // A group as a fake Chest keeps it: its identifier, its name, and the
31
- // identifiers of the members it gives the tool to.
32
- export type FakeGroup = { id: string; name: string; members: string[] };
43
+ // A group as a fake Chest keeps it: its identifier, its name, the
44
+ // identifiers of its members, and whether it gives the tool (true by
45
+ // default). One that does not is seen — in groups and in each member's
46
+ // groups — only with the capability members.groups; a group's members are
47
+ // told among those who have the tool.
48
+ export type FakeGroup = { id: string; name: string; members: string[]; grants?: boolean };
33
49
  // A file as a fake Chest keeps it.
34
50
  export type FakeFile = { data: Uint8Array; type: string; updated: string };
35
51
  // Someone the tool had who no longer has it: a member of the Chest without
@@ -37,9 +53,12 @@ export type FakeFile = { data: Uint8Array; type: string; updated: string };
37
53
  // was erased (their name gone).
38
54
  export type FakeFormer = { id: string; name?: string; status?: "no_access" | "former" | "erased" };
39
55
  // A notification as a fake Chest keeps it: the member it went to, its text
40
- // cleaned as the Chest cleans it, its path (/chest when not said) and its
41
- // key.
42
- export type FakeNotification = { member: string; title: string; body?: string; path: string; key?: string };
56
+ // in that member's language — the translation of theirs, the tool's own
57
+ // otherwise — cleaned as the Chest cleans it, its path (/chest when not
58
+ // said) and its key. A broadcast keeps one for each member it reached.
59
+ // Beyond the pace, a member's notices are folded into one item, grouped
60
+ // counting them, its text and path the latest's.
61
+ export type FakeNotification = { member: string; title: string; body?: string; path: string; key?: string; grouped?: number };
43
62
  // An alias a fake Chest maps: the model behind it, its provider (openrouter
44
63
  // by default) and its prices in US dollars per million tokens (1 and 2 by
45
64
  // default).
@@ -59,30 +78,60 @@ export type FakeAi = { models?: FakeAiModel[]; reply?: (request: Record<string,
59
78
  // sent (null for a GET).
60
79
  export type FakeAiCall = { path: string; body: unknown };
61
80
 
81
+ // An open of sealed values the tool asked of a fake Chest, as the Chest
82
+ // journals it: the member on whose behalf, how many opened, how many
83
+ // refused — never a value.
84
+ export type FakeOpen = { member: string; opened: number; refused: number };
85
+
62
86
  // What a fake Chest is given: the members who have the tool, those it had
63
87
  // who no longer have it (FakeFormer), its groups, the
64
88
  // capabilities its version holds (a capability left out answers 403;
65
89
  // members, files, notifications and ai by default, members.email to read the
66
- // addresses), the events it receives (["member.*"] by default, [] to answer
67
- // an acknowledgment 403), the files it keeps, its AI, and what the Chest is
90
+ // addresses, members.groups to see every group, sealed to seal and open
91
+ // values), the roles the tool declares (any role of the grammar a value is
92
+ // sealed for, when not said), the events it receives (["member.*"] by default, [] to answer
93
+ // an acknowledgment 403), the events the tool emits (chest.json "emits":
94
+ // none by default, which answers an emit 403), the files it keeps, its AI, and what the Chest is
68
95
  // (the chest module: "Test organization", UTC, English and euros by default;
69
96
  // the tool at https://<tool>-chest.chest.test, its public part at
70
- // https://<tool>.chest.test).
97
+ // https://<tool>.chest.test), and its realtime: the channels and feeds of
98
+ // its chest.json, and who is in its membership tables.
71
99
  export type FakeChestOptions = {
72
100
  members?: Member[];
73
101
  former?: FakeFormer[];
74
102
  groups?: FakeGroup[];
75
103
  capabilities?: string[];
104
+ roles?: string[];
76
105
  receives?: string[];
106
+ emits?: Record<string, FakeEmits>;
77
107
  files?: Record<string, { data: Uint8Array | string; type?: string }>;
78
108
  ai?: FakeAi;
109
+ realtime?: FakeRealtimeOptions;
79
110
  chest?: { organization?: string; timeZone?: string; language?: string; currency?: string; teamUrl?: string; publicUrl?: string | null };
80
111
  };
81
112
 
82
- // An event for emit: its type and data; its id (a new evt_… by default) and
113
+ // A type a tool emits, as its chest.json declares it in "emits": what
114
+ // happened, in a sentence of 1 to 80 characters, and each field of its data
115
+ // with its kind (id, text, number, boolean, time, date, member, members; a
116
+ // trailing ? for an optional one).
117
+ export type FakeEmits = { description: string; data: Record<string, string> };
118
+
119
+ // An event for deliver: of the members' lifecycle, its type and data; or of
120
+ // another tool, told apart by its source (the publisher's name), its type,
121
+ // data, subject (none by default) and audience ("all" by default, or the
122
+ // members of this tool who may see it). Its id (a new evt_… by default) and
83
123
  // when it happened (now by default) may be named, to deliver the same event
84
- // twice.
85
- export type FakeEvent = { [K in ChestEvent["type"]]: { type: K; data: Extract<ChestEvent, { type: K }>["data"]; id?: string; occurredAt?: string } }[ChestEvent["type"]];
124
+ // twice. It is signed as given: an envelope the Chest would never send
125
+ // makes handle() refuse it.
126
+ export type FakeEvent =
127
+ | { [K in ChestEvent["type"]]: { type: K; data: Extract<ChestEvent, { type: K }>["data"]; id?: string; occurredAt?: string } }[ChestEvent["type"]]
128
+ | { type: string; source: string; data: Record<string, unknown>; audience?: ToolEvent["audience"]; subject?: string; id?: string; occurredAt?: string };
129
+
130
+ // An event the tool emitted, as the fake Chest took it: its id, type and
131
+ // data, its subject and key when given, when it happened (the tool's time,
132
+ // or the Chest's), its audience when given, and the event whose handler
133
+ // emitted it (cause).
134
+ export type FakeEmitted = { id: string; type: string; data: Record<string, unknown>; subject?: string; key?: string; occurredAt: string; audience?: EmitAudience; cause?: string };
86
135
 
87
136
  // A run for run: its id (a new run_… by default: name it to deliver the same
88
137
  // run twice), the time it stands for (now by default) and its attempt (1 by
@@ -94,7 +143,10 @@ export type FakeRun = { id?: string; scheduledAt?: string; attempt?: number };
94
143
  // the tool it set in the environment, what it keeps (members, groups and files a test
95
144
  // changes or reads; the notifications the tool sent, in the order sent, a
96
145
  // replaced one last; each member's badge; the erasures the tool
97
- // acknowledged; its calls to AI, in order), emit, which delivers an event to the tool — POST
146
+ // acknowledged; its calls to AI, in order; its opens of sealed values, in
147
+ // order; the events it emitted, in order; its realtime: what the tool
148
+ // published and sent, where a member's page connects, a feed's row
149
+ // committed, a membership row removed), deliver, which delivers an event to the tool — POST
98
150
  // /chest-events of its address, or a handler of Web Requests — and says the
99
151
  // status it answered, run, which delivers a run of a schedule the same way
100
152
  // on /chest-schedules, and close, which stops it and restores the
@@ -111,7 +163,10 @@ export type FakeChest = {
111
163
  badges: Map<string, number>;
112
164
  acknowledged: string[];
113
165
  ai: FakeAiCall[];
114
- emit(event: FakeEvent, to: string | ((request: Request) => Response | Promise<Response>)): Promise<number>;
166
+ opens: FakeOpen[];
167
+ realtime: FakeRealtime;
168
+ emitted: FakeEmitted[];
169
+ deliver(event: FakeEvent, to: string | ((request: Request) => Response | Promise<Response>)): Promise<number>;
115
170
  run(name: string, to: string | ((request: Request) => Response | Promise<Response>), run?: FakeRun): Promise<number>;
116
171
  close(): Promise<void>;
117
172
  };
@@ -130,30 +185,39 @@ export function signAssertion(member: Member, options: AssertionOptions = {}): s
130
185
  const token = options.token ?? process.env["CHEST_TOKEN"];
131
186
  const tool = options.tool ?? process.env["CHEST_TOOL"];
132
187
  if (!token || !tool) throw new Error("signAssertion needs a token and a tool: start a fakeChest, or name them");
133
- if (!memberIdPattern.test(member.id) || !member.groups.every(g => groupIdPattern.test(g))) throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
188
+ if (!memberIdPattern.test(member.id) || !(member.groups ?? []).every(g => groupIdPattern.test(g))) throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
134
189
  const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
135
190
  // Under the label of the assertion's shape, as the Chest signs it and
136
191
  // member() reads it.
137
192
  return signClaims("Chest-Member v2", {
138
193
  iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
139
194
  given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
140
- admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, time_zone: member.timeZone, ...(member.email === undefined ? {} : { email: member.email }),
195
+ admin: member.isAdmin, builder: member.isBuilder, ...(member.groups === null ? { groups_overage: true } : { groups: member.groups }), time_zone: member.timeZone, ...(member.email === undefined ? {} : { email: member.email }),
141
196
  language: member.language,
142
197
  }, token);
143
198
  }
144
199
 
145
200
 
201
+ // ticketOf makes the ticket of a member's request to open sealed values
202
+ // (Chest-Opener), under the key of the fake Chest running; null when none
203
+ // runs. The tool never holds that key: only the Chest makes tickets.
204
+ let ticketOf: ((id: string) => string) | null = null;
205
+
146
206
  // withMember is the request carrying that member's assertion, signed with
147
- // the options of signAssertion: a new Web Request, or the same Node request
148
- // with its header set.
207
+ // the options of signAssertion, and — while a fake Chest runs — the
208
+ // member's ticket to open sealed values: a new Web Request, or the same
209
+ // Node request with its headers set.
149
210
  export function withMember<R extends Request | IncomingMessage>(request: R, member: Member, options: AssertionOptions = {}): R {
150
211
  const assertion = signAssertion(member, options);
212
+ const ticket = ticketOf?.(member.id);
151
213
  if (request instanceof Request) {
152
214
  const headers = new Headers(request.headers);
153
215
  headers.set("Chest-Member", assertion);
216
+ if (ticket) headers.set("Chest-Opener", ticket);
154
217
  return new Request(request, { headers }) as R;
155
218
  }
156
219
  (request as IncomingMessage).headers["chest-member"] = assertion;
220
+ if (ticket) (request as IncomingMessage).headers["chest-opener"] = ticket;
157
221
  return request;
158
222
  }
159
223
 
@@ -162,21 +226,54 @@ const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
162
226
  const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
163
227
  const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}(\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}){0,7}$/u;
164
228
  // Its links and uploads: their paths on the team host, their lives in
165
- // seconds, the images it makes thumbnails of, the first bytes an upload of
166
- // a type must start with, the endings of the names it chooses (chest/toolfiles).
229
+ // seconds, the images it makes thumbnails of, the types it recognises by
230
+ // their content — a member's upload of one must hold it, a visitor's is of
231
+ // the one its content is —, the endings of the names it chooses
232
+ // (chest/toolfiles). An office document is told by the names of its parts,
233
+ // read in its bytes rather than in its archive's directory.
167
234
  const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/", linkLife = 900, uploadLife = 900;
168
235
  const mediaPattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
169
236
  const thumbnailed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
170
237
  const starts = (...heads: string[]) => (data: Buffer): boolean => heads.some(head => data.subarray(0, head.length).equals(Buffer.from(head, "latin1")));
171
- const signatures: Record<string, (data: Buffer) => boolean> = {
238
+ const text = (data: Buffer, from: number, to: number): string => data.subarray(from, to).toString("latin1");
239
+ const brands = (...said: string[]) => (data: Buffer): boolean => text(data, 4, 8) === "ftyp" && Array.from({ length: Math.max(0, Math.min(data.readUInt32BE(0), data.length) - 8) / 4 }, (_, i) => text(data, 8 + 4 * i, 12 + 4 * i)).some((brand, i) => i !== 1 && said.includes(brand));
240
+ const zip = starts("PK\x03\x04", "PK\x05\x06");
241
+ const office = (main: string) => (data: Buffer): boolean => zip(data) && data.includes("[Content_Types].xml") && data.includes(main);
242
+ const openDocument = (type: string) => (data: Buffer): boolean => zip(data) && text(data, 30, 38) === "mimetype" && text(data, 38, 38 + type.length) === type;
243
+ const recognisers: Record<string, (data: Buffer) => boolean> = {
172
244
  "image/jpeg": starts("\xff\xd8\xff"), "image/png": starts("\x89PNG\r\n\x1a\n"), "image/gif": starts("GIF87a", "GIF89a"),
173
- "image/webp": data => data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP", "application/pdf": starts("%PDF-"),
245
+ "image/webp": data => text(data, 0, 4) === "RIFF" && text(data, 8, 12) === "WEBP", "image/avif": brands("avif", "avis"), "image/heic": brands("heic", "heix", "heim", "heis", "hevc", "hevx"),
246
+ "image/bmp": starts("BM"), "image/tiff": starts("II*\x00", "MM\x00*"), "application/pdf": starts("%PDF-"),
247
+ "application/zip": zip, "application/gzip": starts("\x1f\x8b"), "application/x-7z-compressed": starts("7z\xbc\xaf\x27\x1c"), "application/vnd.rar": starts("Rar!\x1a\x07"),
248
+ "application/x-tar": data => text(data, 257, 262) === "ustar", "application/x-bzip2": starts("BZh"), "application/x-xz": starts("\xfd7zXZ\x00"), "application/vnd.ms-cab-compressed": starts("MSCF"),
249
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": office("word/document.xml"),
250
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": office("xl/workbook.xml"),
251
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": office("ppt/presentation.xml"),
252
+ "application/vnd.oasis.opendocument.text": openDocument("application/vnd.oasis.opendocument.text"),
253
+ "application/vnd.oasis.opendocument.spreadsheet": openDocument("application/vnd.oasis.opendocument.spreadsheet"),
254
+ "application/vnd.oasis.opendocument.presentation": openDocument("application/vnd.oasis.opendocument.presentation"),
255
+ };
256
+ // The types recognised, an office document before the archive it also is.
257
+ const precise = (type: string): boolean => type.includes("officedocument") || type.includes("opendocument");
258
+ const recognised = Object.keys(recognisers).sort((a, b) => Number(precise(b)) - Number(precise(a)) || (a < b ? -1 : a > b ? 1 : 0));
259
+ const accepted = (types: string[], type: string): boolean => types.some(t => t === type || (t.endsWith("/*") && type.startsWith(t.slice(0, -1))));
260
+ const extensions: Record<string, string> = {
261
+ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", "image/heic": ".heic", "image/bmp": ".bmp", "image/tiff": ".tiff",
262
+ "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json", "application/zip": ".zip",
263
+ "application/gzip": ".gz", "application/x-7z-compressed": ".7z", "application/vnd.rar": ".rar", "application/x-tar": ".tar", "application/x-bzip2": ".bz2", "application/x-xz": ".xz", "application/vnd.ms-cab-compressed": ".cab",
264
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
265
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", "application/vnd.oasis.opendocument.text": ".odt",
266
+ "application/vnd.oasis.opendocument.spreadsheet": ".ods", "application/vnd.oasis.opendocument.presentation": ".odp", "video/mp4": ".mp4", "audio/mpeg": ".mp3",
174
267
  };
175
- const extensions: Record<string, string> = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", "image/heic": ".heic", "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json", "application/zip": ".zip", "video/mp4": ".mp4", "audio/mpeg": ".mp3" };
176
268
  const newToken = (): string => randomBytes(18).toString("base64url") + "." + randomBytes(12).toString("base64url");
177
269
  // Its notifications: recipients and badges a call, text, quotas.
178
- const maxRecipients = 500, maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999;
179
- const recipientsPerHour = 1000, itemsPerDay = 100, badgesPerMinute = 600;
270
+ // No count bounds a call but the team's size: a body takes the texts and an
271
+ // entry for each member who has the tool. Nothing is refused for its pace:
272
+ // ten items at once to a member, one more every six minutes; beyond, the
273
+ // notices are folded into the one grouped item of that member.
274
+ const maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999, maxRoles = 16;
275
+ const textBytes = 64 << 10, memberBytes = 64;
276
+ const paceBurst = 10, paceEvery = 6 * 60_000;
180
277
  const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
181
278
  const reordering = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
182
279
  const cleanTitle = (s: string): string => s.replace(/[\t\r\n]/gu, " ").replace(/\p{Cc}/gu, "").replace(reordering, "").trim();
@@ -199,6 +296,20 @@ function vectorOf(text: string, dimensions: number): number[] {
199
296
  return kept.map(x => x / norm);
200
297
  }
201
298
 
299
+ // Its sealed values: a call's body, one value, a context, a ticket's life
300
+ // in seconds (chest/toolseal).
301
+ const sealedBody = 4 << 20, sealedValue = 512 << 10, sealedContext = 256, ticketLife = 60;
302
+ const sealedText = /^chest:sealed:1:((?:[a-z][a-z0-9-]{0,47}(?:,[a-z][a-z0-9-]{0,47}){0,15})?):([A-Za-z0-9_-]+)$/u;
303
+ // Its events: the keys of an emit, and its envelope beyond the data and
304
+ // the identifiers of its audience.
305
+ const emitKeys = ["type", "data", "subject", "key", "occurredAt", "audience", "cause"], envelopeBytes = 4096, idBytes = 64;
306
+ // canonical is a value's JSON with the keys of each object in order: the
307
+ // content an idempotency key stands for, whatever order the tool wrote.
308
+ const canonical = (value: unknown): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
309
+ : value !== null && typeof value === "object" ? `{${Object.keys(value).sort().map(k => JSON.stringify(k) + ":" + canonical((value as Record<string, unknown>)[k])).join(",")}}`
310
+ : JSON.stringify(value) ?? "null";
311
+ const newId = (prefix: string): string => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
312
+
202
313
  const fold = (s: string): string => s.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
203
314
 
204
315
  function send(response: ServerResponse, status: number, value?: unknown, headers: Record<string, string> = {}): void {
@@ -224,7 +335,7 @@ async function body(request: IncomingMessage, limit: number): Promise<Buffer | n
224
335
  // chest and the modules of the SDK read is then this Chest's.
225
336
  export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChest> {
226
337
  const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications", "ai"]);
227
- const email = capabilities.has("members.email");
338
+ const email = capabilities.has("members.email"), everyGroup = capabilities.has("members.groups");
228
339
  const tool = process.env["CHEST_TOOL"] || "tool";
229
340
  const token = randomBytes(32).toString("base64url");
230
341
  const files = new Map<string, FakeFile>();
@@ -232,12 +343,29 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
232
343
  files.set(name, { data: typeof file.data === "string" ? new TextEncoder().encode(file.data) : file.data, type: file.type ?? "application/octet-stream", updated: new Date().toISOString() });
233
344
  }
234
345
  const receives = options.receives ?? ["member.*"];
235
- // The erasures the tool was told of, by emit: those it may acknowledge.
346
+ // What the tool's chest.json declares it emits, refused as the Chest
347
+ // refuses the manifest.
348
+ const emits = options.emits ?? {};
349
+ for (const [type, declared] of Object.entries(emits)) {
350
+ const length = typeof declared?.description === "string" ? [...declared.description].length : 0;
351
+ if (!isToolEventType(type) || length < 1 || length > 80 || !object(declared.data) || !Object.entries(declared.data).every(([name, kind]) => fieldNamePattern.test(name) && fieldOf(kind) !== null)) {
352
+ throw new Error(`fakeChest: the Chest refuses "emits" ${JSON.stringify(type)}: a type of two to four dotted words, a description of 1 to 80 characters, camelCase fields of the kinds id, text, number, boolean, time, date, member, members (? when optional)`);
353
+ }
354
+ }
355
+ // The erasures the tool was told of, by deliver: those it may acknowledge.
236
356
  const erasures = new Set<string>();
237
- const chest: FakeChest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], ai: [], emit: async () => 0, run: async () => 0, close: async () => {} };
357
+ const realtime = fakeRealtime(options.realtime ?? {}, () => chest.api, id => {
358
+ const m = chest.members.find(x => x.id === id);
359
+ return m ? m.role : undefined;
360
+ });
361
+ const chest: FakeChest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], ai: [], opens: [], realtime: realtime.realtime, emitted: [], deliver: async () => 0, run: async () => 0, close: async () => {} };
238
362
  const former = [...(options.former ?? [])];
239
363
  let window = 0, calls = 0;
240
- const shown = (m: Member) => ({ id: m.id, first_name: m.firstName, last_name: m.lastName, name: m.name, photo: m.photo, role: m.role, admin: m.isAdmin, builder: m.isBuilder, groups: m.groups, language: m.language, time_zone: m.timeZone, ...(email && m.email !== undefined ? { email: m.email } : {}) });
364
+ // The groups the tool sees: those that give it, or all with members.groups;
365
+ // a member's groups among them (an identifier it was not given stays).
366
+ const unseen = () => new Set(everyGroup ? [] : chest.groups.filter(g => g.grants === false).map(g => g.id));
367
+ const groupsOf = (m: Member) => (m.groups ?? []).filter(g => !unseen().has(g));
368
+ const shown = (m: Member) => ({ id: m.id, first_name: m.firstName, last_name: m.lastName, name: m.name, photo: m.photo, role: m.role, admin: m.isAdmin, builder: m.isBuilder, groups: groupsOf(m), language: m.language, time_zone: m.timeZone, ...(email && m.email !== undefined ? { email: m.email } : {}) });
241
369
  const key = (m: Member) => fold(m.name) + "\u0000" + m.id;
242
370
  const described = (name: string, f: FakeFile) => ({ name, type: f.type, size: f.data.byteLength, sha256: createHash("sha256").update(f.data).digest("hex"), updated: f.updated });
243
371
 
@@ -254,7 +382,7 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
254
382
  const group = q.get("group"), role = q.get("role"), search = fold(q.get("q") ?? "");
255
383
  if (keys.some(k => !["after", "limit", "q", "role", "group"].includes(k) || q.getAll(k).length !== 1) || !Number.isInteger(limit) || limit < 1 || limit > maxLimit || String(limit) !== (q.get("limit") ?? String(defaultLimit)) || (group !== null && !groupIdPattern.test(group)) || search.length > 64) return send(response, 400, { error: "invalid_query" });
256
384
  const shownList = chest.members
257
- .filter(m => (role === null || m.role === role) && (group === null || m.groups.includes(group)) && (search === "" || [m.firstName, m.lastName, m.name, ...(email && m.email ? [m.email] : [])].some(n => fold(n).startsWith(search))))
385
+ .filter(m => (role === null || m.role === role) && (group === null || groupsOf(m).includes(group)) && (search === "" || [m.firstName, m.lastName, m.name, ...(email && m.email ? [m.email] : [])].some(n => fold(n).startsWith(search))))
258
386
  .filter(m => after === "" || key(m) > after)
259
387
  .sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
260
388
  const page = shownList.slice(0, limit);
@@ -283,7 +411,16 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
283
411
  const m = chest.members.find(x => x.id === id);
284
412
  return m ? send(response, 200, shown(m)) : send(response, 404, { error: "member_not_found" });
285
413
  }
286
- if (request.method === "GET" && url.pathname === "/groups") return send(response, 200, { groups: chest.groups });
414
+ if (request.method === "GET" && url.pathname === "/groups") {
415
+ const q = url.searchParams, keys = [...q.keys()];
416
+ const limit = q.has("limit") ? Number(q.get("limit")) : defaultLimit;
417
+ const after = q.has("after") ? Buffer.from(q.get("after")!, "base64url").toString() : "";
418
+ if ((q.has("after") && !groupIdPattern.test(after.split("\u0000")[1] ?? "")) || keys.some(k => !["after", "limit"].includes(k) || q.getAll(k).length !== 1) || !Number.isInteger(limit) || limit < 1 || limit > maxLimit || String(limit) !== (q.get("limit") ?? String(defaultLimit))) return send(response, 400, { error: "invalid_query" });
419
+ const groupKey = (g: FakeGroup) => fold(g.name) + "\u0000" + g.id;
420
+ const listed = chest.groups.filter(g => !unseen().has(g.id) && (after === "" || groupKey(g) > after)).sort((a, b) => groupKey(a) < groupKey(b) ? -1 : groupKey(a) > groupKey(b) ? 1 : 0);
421
+ const page = listed.slice(0, limit);
422
+ return send(response, 200, { groups: page.map(g => ({ id: g.id, name: g.name, size: g.members.filter(access).length })), next: listed.length > limit ? Buffer.from(groupKey(page.at(-1)!)).toString("base64url") : null });
423
+ }
287
424
  send(response, 404, { error: "not_found" });
288
425
  }
289
426
 
@@ -309,15 +446,17 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
309
446
  return send(response, 200, { url: `${chest.api}${linkPath}${token}`, expires_in: linkLife });
310
447
  }
311
448
  if (url.pathname === "/files/upload-url") {
312
- const { name, max_size: maxSize, types, expires_in: expiresIn } = command;
449
+ const { name, max_size: maxSize, types, expires_in: expiresIn, public: visitor } = command;
313
450
  const folder = typeof name === "string" && name.endsWith("/");
314
- if (typeof name !== "string" || !namePattern.test(folder ? name.slice(0, -1) : name)) return send(response, 400, { error: "invalid_name" });
451
+ if (typeof name !== "string" || !namePattern.test(folder ? name.slice(0, -1) : name) || (visitor === true && !folder)) return send(response, 400, { error: "invalid_name" });
315
452
  if (!(types === undefined || (Array.isArray(types) && types.length <= 8 && types.every(t => typeof t === "string" && mediaPattern.test(t))))) return send(response, 400, { error: "invalid_type" });
316
- if (!(maxSize === undefined || (typeof maxSize === "number" && Number.isSafeInteger(maxSize) && maxSize > 0)) || !(expiresIn === undefined || (typeof expiresIn === "number" && Number.isInteger(expiresIn) && expiresIn > 0 && expiresIn <= uploadLife))) return send(response, 400, { error: "invalid_body" });
453
+ if (visitor === true && (!Array.isArray(types) || types.length === 0 || !types.every(t => recognised.some(r => accepted([t as string], r))))) return send(response, 400, { error: "invalid_type" });
454
+ if (!(maxSize === undefined || (typeof maxSize === "number" && Number.isSafeInteger(maxSize) && maxSize > 0)) || !(expiresIn === undefined || (typeof expiresIn === "number" && Number.isInteger(expiresIn) && expiresIn > 0 && expiresIn <= uploadLife)) || !(visitor === undefined || typeof visitor === "boolean")) return send(response, 400, { error: "invalid_body" });
455
+ if (visitor === true && process.env["CHEST_PUBLIC_URL"] === undefined) return send(response, 409, { error: "no_public_part" });
317
456
  if ((maxSize ?? 0) > maxObject) return send(response, 413, { error: "too_large" });
318
457
  const token = newToken(), life = (expiresIn as number | undefined) ?? uploadLife;
319
- uploads.set(token, { name, maxSize: (maxSize as number | undefined) ?? maxObject, types: (types as string[] | undefined) ?? [], until: Date.now() + life * 1000 });
320
- return send(response, 200, { url: `${chest.api}${uploadPath}${token}`, method: "PUT", expires_in: life });
458
+ uploads.set(token, { name, maxSize: (maxSize as number | undefined) ?? maxObject, types: (types as string[] | undefined) ?? [], until: Date.now() + life * 1000, visitor: visitor === true });
459
+ return send(response, 200, { url: visitor === true ? uploadPath + token : `${chest.api}${uploadPath}${token}`, method: "PUT", expires_in: life });
321
460
  }
322
461
  const from = command["from"], to = command["to"];
323
462
  if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to)) return send(response, 400, { error: "invalid_name" });
@@ -349,15 +488,17 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
349
488
  send(response, 404, { error: "not_found" });
350
489
  }
351
490
 
352
- // The team host's part of the files, on the fake's own origin: a link it
353
- // signed opens the content it was signed for, for its life, as long as
354
- // the file is that content — a thumbnail is the image itself, a fake does
355
- // not reduce it —; an upload token takes one file, once, within its life,
356
- // of the types it names and up to its size, whose first bytes are those of
357
- // its type, named by the Chest in a folder. Nobody's session is checked:
491
+ // The team host's and the public part's files, on the fake's own origin:
492
+ // a link it signed opens the content it was signed for, for its life, as
493
+ // long as the file is that content — a thumbnail is the image itself, a
494
+ // fake does not reduce it —; an upload token takes one file, once, within
495
+ // its life, up to its size, named by the Chest in a folder: a member's of
496
+ // the types it names, holding the type it says when the fake recognises
497
+ // it; a visitor's of the type its content is among those it names,
498
+ // whatever it says. Nobody's session is checked, nor a visitor's pace:
358
499
  // the Chest's own front does that.
359
500
  const links = new Map<string, { name: string; object: FakeFile; until: number; download: boolean }>();
360
- const uploads = new Map<string, { name: string; maxSize: number; types: string[]; until: number }>();
501
+ const uploads = new Map<string, { name: string; maxSize: number; types: string[]; until: number; visitor: boolean }>();
361
502
  async function front(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
362
503
  if (request.method === "GET" && url.pathname.startsWith(linkPath) && !url.pathname.startsWith(uploadPath)) {
363
504
  const link = links.get(url.pathname.slice(linkPath.length));
@@ -370,11 +511,15 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
370
511
  const token = url.pathname.slice(uploadPath.length), grant = uploads.get(token);
371
512
  uploads.delete(token);
372
513
  if (!grant || grant.until < Date.now()) return send(response, 403, { error: "invalid_token" });
373
- const type = (request.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
374
- if (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !grant.types.some(t => t === type || (t.endsWith("/*") && type.startsWith(t.slice(0, -1)))))) return send(response, 415, { error: "type_refused" });
514
+ let type = (request.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
515
+ if (!grant.visitor && (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !accepted(grant.types, type)))) return send(response, 415, { error: "type_refused" });
375
516
  const data = await body(request, grant.maxSize);
376
517
  if (data === null) return send(response, 413, { error: "too_large" });
377
- if (signatures[type] && !signatures[type]!(data)) return send(response, 400, { error: "type_mismatch" });
518
+ if (grant.visitor) {
519
+ const found = recognised.find(r => accepted(grant.types, r) && recognisers[r]!(data));
520
+ if (found === undefined) return send(response, 415, { error: "type_refused" });
521
+ type = found;
522
+ } else if (recognisers[type] && !recognisers[type]!(data)) return send(response, 400, { error: "type_mismatch" });
378
523
  const name = grant.name.endsWith("/") ? grant.name + randomBytes(10).toString("hex") + (extensions[type] ?? "") : grant.name;
379
524
  const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
380
525
  if (total + data.length > maxTotal || (!files.has(name) && files.size >= maxObjects)) return send(response, 429, { error: "quota_exceeded" });
@@ -382,17 +527,25 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
382
527
  send(response, 201, { name, type, size: data.length });
383
528
  }
384
529
 
385
- // The windows of the notification quotas, each from the first call it
386
- // counts: the tool's recipients this hour, each member's items this day,
387
- // the tool's badge writes this minute.
530
+ // The windows of a request rate (AI), each from the first call it counts.
388
531
  type Window = { start: number; count: number };
389
- const hour: Window = { start: 0, count: 0 }, minute: Window = { start: 0, count: 0 }, days = new Map<string, Window>();
390
532
  const live = (w: Window | undefined, span: number, now: number): boolean => w !== undefined && w.count > 0 && now - w.start < span;
391
533
  const wait = (w: Window, span: number, now: number): Record<string, string> => ({ "Retry-After": String(Math.max(1, Math.ceil((w.start + span - now) / 1000))) });
392
534
  const count = (w: Window, span: number, now: number, n: number): void => {
393
535
  if (!live(w, span, now)) [w.start, w.count] = [now, 0];
394
536
  w.count += n;
395
537
  };
538
+ // The pace of each member: a token bucket, as of when it was last used.
539
+ const buckets = new Map<string, { tokens: number; at: number }>();
540
+ const paced = (id: string, now: number): boolean => {
541
+ const b = buckets.get(id) ?? { tokens: paceBurst, at: now };
542
+ b.tokens = Math.min(paceBurst, b.tokens + (now - b.at) / paceEvery);
543
+ b.at = now;
544
+ buckets.set(id, b);
545
+ if (b.tokens < 1) return false;
546
+ b.tokens--;
547
+ return true;
548
+ };
396
549
  const access = (id: string) => chest.members.some(m => m.id === id);
397
550
  // drop removes kept notifications in place: a test may hold the list.
398
551
  const drop = (gone: (n: FakeNotification) => boolean): void => {
@@ -400,7 +553,7 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
400
553
  };
401
554
  // recipients reads 1 to 500 member identifiers, each once.
402
555
  function recipients(value: unknown): string[] | { error: string } {
403
- if (!Array.isArray(value) || value.length < 1 || value.length > maxRecipients) return { error: "invalid_body" };
556
+ if (!Array.isArray(value) || value.length < 1) return { error: "invalid_body" };
404
557
  if (!value.every(v => typeof v === "string" && memberIdPattern.test(v))) return { error: "invalid_id" };
405
558
  return [...new Set(value as string[])];
406
559
  }
@@ -408,8 +561,8 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
408
561
  async function notificationsRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
409
562
  if (!capabilities.has("notifications")) return send(response, 403, { error: "capability_not_granted" });
410
563
  const badge = request.method === "PUT" && url.pathname.startsWith("/badges/");
411
- if (!badge && !(request.method === "PUT" && url.pathname === "/badges") && !(request.method === "POST" && (url.pathname === "/notifications" || url.pathname === "/notifications/withdraw"))) return send(response, 404, { error: "not_found" });
412
- const raw = await body(request, 64 << 10);
564
+ if (!badge && !(request.method === "PUT" && url.pathname === "/badges") && !(request.method === "POST" && ["/notifications", "/notifications/broadcast", "/notifications/withdraw"].includes(url.pathname))) return send(response, 404, { error: "not_found" });
565
+ const raw = await body(request, textBytes + memberBytes * chest.members.length);
413
566
  let command: Record<string, unknown> | null = null;
414
567
  try {
415
568
  const value = JSON.parse(raw?.toString() ?? "") as unknown;
@@ -428,14 +581,12 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
428
581
  writes = [{ member: id, count: command!["count"] as number }];
429
582
  } else {
430
583
  const list = command?.["badges"];
431
- if (!keys("badges") || !Array.isArray(list) || list.length < 1 || list.length > maxRecipients || !list.every(b => b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).every(k => k === "member" || k === "count"))) return send(response, 400, { error: "invalid_body" });
584
+ if (!keys("badges") || !Array.isArray(list) || list.length < 1 || !list.every(b => b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).every(k => k === "member" || k === "count"))) return send(response, 400, { error: "invalid_body" });
432
585
  writes = list as { member: string; count: number }[];
433
586
  if (!writes.every(b => typeof b.member === "string" && memberIdPattern.test(b.member))) return send(response, 400, { error: "invalid_id" });
434
587
  }
435
588
  if (!writes.every(b => typeof b.count === "number" && Number.isInteger(b.count) && b.count >= 0 && b.count <= maxCount)) return send(response, 400, { error: "invalid_count" });
436
589
  if (new Set(writes.map(b => b.member)).size !== writes.length) return send(response, 400, { error: "invalid_body" });
437
- if (live(minute, 60_000, now) && minute.count + writes.length > badgesPerMinute) return send(response, 429, { error: "quota_exceeded" }, wait(minute, 60_000, now));
438
- count(minute, 60_000, now, writes.length);
439
590
  const answer = { set: [] as string[], skipped: [] as string[] };
440
591
  for (const b of writes) {
441
592
  if (!access(b.member)) {
@@ -457,26 +608,60 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
457
608
  drop(n => n.key === key && (named === null || named.includes(n.member)));
458
609
  return send(response, 204);
459
610
  }
460
- if (!keys("members", "title", "body", "path", "key")) return send(response, 400, { error: "invalid_body" });
461
- const { title, body: text, path, key } = command!;
462
- const ids = recipients(command!["members"]);
463
- if (!Array.isArray(ids)) return send(response, 400, ids);
464
- if (typeof title !== "string" || [...title].length < 1 || [...title].length > maxTitle || cleanTitle(title) === "") return send(response, 400, { error: "invalid_title" });
465
- if (text !== undefined && (typeof text !== "string" || [...text].length > maxText)) return send(response, 400, { error: "invalid_text" });
611
+ const broadcast = url.pathname === "/notifications/broadcast";
612
+ if (!keys(broadcast ? "to" : "members", ...(broadcast ? ["except"] : []), "title", "body", "path", "key", "translations")) return send(response, 400, { error: "invalid_body" });
613
+ let ids: string[];
614
+ if (broadcast) {
615
+ const to = command!["to"] as Record<string, unknown> | undefined, except = command!["except"];
616
+ if (to !== undefined && (to === null || typeof to !== "object" || Array.isArray(to) || !Object.keys(to).every(k => k === "groups" || k === "roles"))) return send(response, 400, { error: "invalid_body" });
617
+ const groups = to?.["groups"] ?? [], roles = to?.["roles"] ?? [];
618
+ if (!Array.isArray(groups) || !Array.isArray(roles) || (to !== undefined && groups.length + roles.length === 0) || roles.length > maxRoles || (except !== undefined && !Array.isArray(except))) return send(response, 400, { error: "invalid_body" });
619
+ if (!groups.every(g => typeof g === "string" && groupIdPattern.test(g)) || !((except ?? []) as unknown[]).every(id => typeof id === "string" && memberIdPattern.test(id))) return send(response, 400, { error: "invalid_id" });
620
+ if (!roles.every(r => typeof r === "string" && rolePattern.test(r))) return send(response, 400, { error: "invalid_role" });
621
+ ids = chest.members.filter(m => !((except ?? []) as string[]).includes(m.id) && (to === undefined || (m.role !== null && roles.includes(m.role)) || groupsOf(m).some(g => groups.includes(g)))).map(m => m.id);
622
+ } else {
623
+ const named = recipients(command!["members"]);
624
+ if (!Array.isArray(named)) return send(response, 400, named);
625
+ ids = named;
626
+ }
627
+ const { path, key, translations } = command!;
628
+ const words = (value: unknown): { title: string; body?: string } | { error: string } => {
629
+ const { title, body: text, ...rest } = (value ?? {}) as Record<string, unknown>;
630
+ if (value === null || typeof value !== "object" || Array.isArray(value) || Object.keys(rest).length > 0 || title === undefined) return { error: "invalid_body" };
631
+ if (typeof title !== "string" || [...title].length < 1 || [...title].length > maxTitle || cleanTitle(title) === "") return { error: "invalid_title" };
632
+ if (text !== undefined && (typeof text !== "string" || [...text].length > maxText)) return { error: "invalid_text" };
633
+ const cleaned = typeof text === "string" ? cleanText(text) : "";
634
+ return { title: cleanTitle(title), ...(cleaned ? { body: cleaned } : {}) };
635
+ };
636
+ const own = words({ title: command!["title"], ...(command!["body"] !== undefined ? { body: command!["body"] } : {}) });
637
+ if ("error" in own) return send(response, 400, own);
466
638
  if (path !== undefined && !isPath(path)) return send(response, 400, { error: "invalid_path" });
467
639
  if (key !== undefined && (typeof key !== "string" || !keyPattern.test(key))) return send(response, 400, { error: "invalid_key" });
640
+ const other = new Map<string, { title: string; body?: string }>();
641
+ if (translations !== undefined) {
642
+ if (translations === null || typeof translations !== "object" || Array.isArray(translations)) return send(response, 400, { error: "invalid_body" });
643
+ for (const [language, value] of Object.entries(translations)) {
644
+ if (!languagePattern.test(language)) return send(response, 400, { error: "invalid_language" });
645
+ const translated = words(value);
646
+ if ("error" in translated) return send(response, 400, translated);
647
+ other.set(language, translated);
648
+ }
649
+ }
468
650
  const kept = ids.filter(access);
469
- if (live(hour, 3_600_000, now) && hour.count + kept.length > recipientsPerHour) return send(response, 429, { error: "quota_exceeded" }, wait(hour, 3_600_000, now));
470
- const full = kept.map(id => days.get(id)).filter((w): w is Window => live(w, 86_400_000, now) && w!.count >= itemsPerDay);
471
- if (full.length > 0) return send(response, 429, { error: "quota_exceeded" }, wait(full.reduce((a, b) => a.start > b.start ? a : b), 86_400_000, now));
472
- count(hour, 3_600_000, now, kept.length);
473
- const cleaned = typeof text === "string" ? cleanText(text) : "";
474
651
  for (const id of kept) {
475
- if (!days.has(id)) days.set(id, { start: 0, count: 0 });
476
- count(days.get(id)!, 86_400_000, now, 1);
477
- if (key !== undefined) drop(n => n.member === id && n.key === key);
478
- chest.notifications.push({ member: id, title: cleanTitle(title), ...(cleaned ? { body: cleaned } : {}), path: (path as string | undefined) ?? "/chest", ...(key !== undefined ? { key: key as string } : {}) });
652
+ const language = chest.members.find(m => m.id === id)!.language;
653
+ const item: FakeNotification = { member: id, ...(other.get(language) ?? own), path: (path as string | undefined) ?? "/chest", ...(key !== undefined ? { key: key as string } : {}) };
654
+ if (key !== undefined && chest.notifications.some(n => n.member === id && n.key === key)) drop(n => n.member === id && n.key === key);
655
+ else if (!paced(id, now)) {
656
+ // Beyond the pace: folded into the member's grouped item, never refused.
657
+ const grouped = chest.notifications.find(n => n.member === id && n.grouped !== undefined);
658
+ drop(n => n === grouped);
659
+ delete item.key;
660
+ item.grouped = (grouped?.grouped ?? 0) + 1;
661
+ }
662
+ chest.notifications.push(item);
479
663
  }
664
+ if (broadcast) return send(response, 204);
480
665
  send(response, 200, { delivered: kept, skipped: ids.filter(id => !access(id)) });
481
666
  }
482
667
 
@@ -574,7 +759,65 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
574
759
  response.end();
575
760
  }
576
761
 
577
- // The acknowledgment of an erasure the tool was told of (emit).
762
+ // Its sealed values: AES-256-GCM under a key of its own — the format and
763
+ // the rules of a Chest, not its cipher —, bound to the tool, the roles
764
+ // and the context; its tickets, HMAC under another, for 60 seconds.
765
+ const sealingKey = randomBytes(32), ticketKey = randomBytes(32);
766
+ const additional = (roles: string, context: string) => Buffer.from(`chest:sealed:1:\u0000${tool}\u0000${roles}\u0000${context}`);
767
+ const ticketMac = (id: string, expiry: string) => createHmac("sha256", ticketKey).update(`${id}\u0000${expiry}`).digest();
768
+ ticketOf = id => { const expiry = String(Math.floor(Date.now() / 1000) + ticketLife); return `1.${id}.${expiry}.${ticketMac(id, expiry).toString("base64url")}`; };
769
+ const ticketMember = (ticket: string | string[] | undefined): string | null => {
770
+ const parts = typeof ticket === "string" ? ticket.split(".") : [];
771
+ if (parts.length !== 4 || parts[0] !== "1" || !memberIdPattern.test(parts[1]!) || Number(parts[2]) < Date.now() / 1000) return null;
772
+ const mac = Buffer.from(parts[3]!, "base64url"), expected = ticketMac(parts[1]!, parts[2]!);
773
+ return mac.length === expected.length && timingSafeEqual(mac, expected) ? parts[1]! : null;
774
+ };
775
+ const isContext = (c: unknown): c is string | undefined => c === undefined || (typeof c === "string" && Buffer.byteLength(c) <= sealedContext);
776
+ async function sealedRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
777
+ if (!capabilities.has("sealed")) return send(response, 403, { error: "capability_not_granted" });
778
+ if (request.method !== "POST" || url.search || (url.pathname !== "/sealed/seal" && url.pathname !== "/sealed/open")) return send(response, 404, { error: "not_found" });
779
+ const id = url.pathname === "/sealed/open" ? ticketMember(request.headers["chest-opener"]) : null;
780
+ if (url.pathname === "/sealed/open" && id === null) return send(response, 401, { error: "member_required" });
781
+ const raw = await body(request, sealedBody);
782
+ if (raw === null) return send(response, 413, { error: "too_large" });
783
+ let items: unknown;
784
+ try { items = (JSON.parse(raw.toString()) as { items?: unknown }).items; } catch { items = undefined; }
785
+ if (!Array.isArray(items)) return send(response, 400, { error: "invalid_body" });
786
+ if (url.pathname === "/sealed/seal") {
787
+ const sealed: string[] = [];
788
+ for (const item of items as { value?: unknown; roles?: unknown; context?: unknown }[]) {
789
+ if (typeof item?.value !== "string" || Buffer.byteLength(item.value) > sealedValue || !isContext(item.context)) return send(response, 400, { error: "invalid_body" });
790
+ const roles = item.roles;
791
+ if (roles !== undefined && (!Array.isArray(roles) || roles.length === 0 || roles.length > maxRoles || !roles.every(r => typeof r === "string" && rolePattern.test(r) && (!options.roles || options.roles.includes(r))) || new Set(roles).size !== roles.length)) return send(response, 400, { error: "invalid_role" });
792
+ const joined = (roles as string[] | undefined)?.join(",") ?? "", nonce = randomBytes(12);
793
+ const cipher = createCipheriv("aes-256-gcm", sealingKey, nonce).setAAD(additional(joined, item.context ?? ""));
794
+ const box = Buffer.concat([nonce, cipher.update(item.value, "utf8"), cipher.final(), cipher.getAuthTag()]);
795
+ sealed.push(`chest:sealed:1:${joined}:${box.toString("base64url")}`);
796
+ }
797
+ return send(response, 200, { sealed });
798
+ }
799
+ const holder = chest.members.find(m => m.id === id);
800
+ if (!holder) return send(response, 403, { error: "access_removed" });
801
+ const values: ({ value: string } | { refused: "role" | "invalid" })[] = [];
802
+ for (const item of items as { sealed?: unknown; context?: unknown }[]) {
803
+ if (typeof item?.sealed !== "string" || !isContext(item.context)) return send(response, 400, { error: "invalid_body" });
804
+ const found = sealedText.exec(item.sealed), box = found ? Buffer.from(found[2]!, "base64url") : Buffer.alloc(0);
805
+ let value: string | null = null;
806
+ if (found && box.length >= 28) {
807
+ try {
808
+ const decipher = createDecipheriv("aes-256-gcm", sealingKey, box.subarray(0, 12)).setAAD(additional(found[1]!, item.context ?? ""));
809
+ decipher.setAuthTag(box.subarray(box.length - 16));
810
+ value = Buffer.concat([decipher.update(box.subarray(12, box.length - 16)), decipher.final()]).toString("utf8");
811
+ } catch { value = null; }
812
+ }
813
+ const roles = found?.[1] ? found[1].split(",") : [];
814
+ values.push(value === null ? { refused: "invalid" } : roles.length && !roles.includes(holder.role ?? "") ? { refused: "role" } : { value });
815
+ }
816
+ if (values.length) chest.opens.push({ member: holder.id, opened: values.filter(v => "value" in v).length, refused: values.filter(v => "refused" in v).length });
817
+ send(response, 200, { values });
818
+ }
819
+
820
+ // The acknowledgment of an erasure the tool was told of (deliver).
578
821
  async function erasuresRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
579
822
  if (!receives.includes("member.*")) return send(response, 403, { error: "capability_not_granted" });
580
823
  const done = /^\/erasures\/([^/]+)\/done$/u.exec(url.pathname);
@@ -586,23 +829,74 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
586
829
  send(response, 204);
587
830
  }
588
831
 
832
+ // Realtime: the tool's API, and the probe of a page that asks whether its
833
+ // member may connect.
834
+ async function realtimeRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
835
+ if (!capabilities.has("realtime")) return send(response, url.pathname === "/_chest/realtime" ? 404 : 403, url.pathname === "/_chest/realtime" ? undefined : { error: "capability_not_granted" });
836
+ if (url.pathname === "/_chest/realtime") return realtime.probe(response, url, send);
837
+ return realtime.api(request, response, url, body, send);
838
+ }
839
+
840
+ // The events the tool emits (POST /events), checked against its "emits"
841
+ // as the Chest checks them: a member it never had, a group it does not
842
+ // know, are refused; an idempotency key used within 72 hours answers its
843
+ // first event for the same content, key_reused for other content.
844
+ const used = new Map<string, { id: string; content: string; at: number }>();
845
+ const had = (id: string): boolean => access(id) || former.some(f => f.id === id);
846
+ async function eventsRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
847
+ if (request.method !== "POST" || url.pathname !== "/events" || url.search) return send(response, 404, { error: "not_found" });
848
+ if (Object.keys(emits).length === 0) return send(response, 403, { error: "capability_not_granted" });
849
+ // The data, the envelope, and an audience of as many identifiers as the Chest holds.
850
+ const raw = await body(request, maxData + envelopeBytes + idBytes * (chest.members.length + former.length + chest.groups.length + maxRoles));
851
+ if (raw === null) return send(response, 413, { error: "too_large" });
852
+ const command = object(json(raw.toString("utf8")));
853
+ if (!command || !Object.keys(command).every(k => emitKeys.includes(k))) return send(response, 400, { error: "invalid_event" });
854
+ const { type, data, subject, key, occurredAt, audience, cause } = command;
855
+ if (!isToolEventType(type) || !Object.hasOwn(emits, type)) return send(response, 400, { error: "invalid_type" });
856
+ const now = Date.now(), time = occurredAt === undefined ? now : instantOf(occurredAt);
857
+ const identifier = (value: unknown, pattern: RegExp): boolean => value === undefined || (typeof value === "string" && pattern.test(value));
858
+ if (!identifier(subject, itemIdPattern) || !identifier(key, itemIdPattern) || !identifier(cause, eventChannel.id) || time === null || time > now + clockSkew || time < now - eventWindow) return send(response, 400, { error: "invalid_event" });
859
+ if (Buffer.byteLength(JSON.stringify(data) ?? "") > maxData) return send(response, 413, { error: "too_large" });
860
+ if (!isDeclaredData(data, emits[type]!.data, had)) return send(response, 400, { error: "invalid_data" });
861
+ if (audience !== undefined && (!isAudience(audience) || !(audience.members ?? []).every(had) || !(audience.groups ?? []).every(g => chest.groups.some(x => x.id === g)))) return send(response, 400, { error: "invalid_audience" });
862
+ const content = canonical({ type, data, subject, occurredAt, audience });
863
+ const first = key === undefined ? undefined : used.get(key as string);
864
+ if (first && now - first.at < eventWindow) return first.content === content ? send(response, 200, { id: first.id, receivers: 0 }) : send(response, 409, { error: "key_reused" });
865
+ const id = newId("evt_");
866
+ if (key !== undefined) used.set(key as string, { id, content, at: now });
867
+ chest.emitted.push({
868
+ id, type, data: data as Record<string, unknown>, ...(subject === undefined ? {} : { subject: subject as string }), ...(key === undefined ? {} : { key: key as string }),
869
+ occurredAt: (occurredAt as string | undefined) ?? new Date(now).toISOString(), ...(audience === undefined ? {} : { audience }), ...(cause === undefined ? {} : { cause: cause as string }),
870
+ });
871
+ send(response, 202, { id, receivers: 0 });
872
+ }
873
+
589
874
  const server = createServer((request, response) => {
590
875
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
591
- const route = url.pathname.startsWith("/_chest/files/") ? front
876
+ const route = url.pathname === "/_chest/realtime" || url.pathname.startsWith("/realtime/") ? realtimeRoute
877
+ : url.pathname.startsWith("/_chest/files/") ? front
592
878
  : url.pathname.startsWith("/erasures/") ? erasuresRoute
879
+ : url.pathname === "/events" ? eventsRoute
593
880
  : url.pathname.startsWith("/ai/") ? aiRoute
881
+ : url.pathname.startsWith("/sealed/") ? sealedRoute
594
882
  : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
595
883
  : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
596
884
  : url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
597
885
  if (!route) return send(response, 404, { error: "not_found" });
598
886
  route(request, response, url).catch(() => { if (!response.headersSent) send(response, 503, { error: "unavailable" }); else response.destroy(); });
599
887
  });
888
+ // A page's connection: the Chest's side of the browser client.
889
+ server.on("upgrade", (request: IncomingMessage, socket: Duplex) => {
890
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
891
+ if (url.pathname !== "/_chest/realtime" || !capabilities.has("realtime")) return void socket.end("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
892
+ realtime.upgrade(request, socket, url);
893
+ });
600
894
  await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
601
895
  const saved = Object.fromEntries(["CHEST_API", "CHEST_TOKEN", "CHEST_TOOL", "CHEST_ORGANIZATION", "CHEST_TIME_ZONE", "CHEST_LANGUAGE", "CHEST_CURRENCY", "CHEST_TEAM_URL", "CHEST_PUBLIC_URL"].map(name => [name, process.env[name]]));
602
896
  chest.api = "http://127.0.0.1:" + (server.address() as AddressInfo).port;
603
- // Its links are taken by the files module of this process, for as long as
604
- // it runs (api.ts, fakeOrigins).
605
- fakeOrigins.add(chest.api);
897
+ // Its links are on its own origin, the CHEST_API it sets: the files
898
+ // module takes them there (api.ts, chestLink), in this process or in a
899
+ // tool it starts with this environment.
606
900
  const publicUrl = options.chest?.publicUrl === undefined ? `https://${tool}.chest.test` : options.chest.publicUrl;
607
901
  Object.assign(process.env, { CHEST_API: chest.api, CHEST_TOKEN: token, CHEST_TOOL: tool, CHEST_ORGANIZATION: options.chest?.organization ?? "Test organization", CHEST_TIME_ZONE: options.chest?.timeZone ?? "UTC", CHEST_LANGUAGE: options.chest?.language ?? "en", CHEST_CURRENCY: options.chest?.currency ?? "EUR", CHEST_TEAM_URL: options.chest?.teamUrl ?? `https://${tool}-chest.chest.test` });
608
902
  if (publicUrl === null) delete process.env["CHEST_PUBLIC_URL"];
@@ -616,12 +910,14 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
616
910
  await answer.body?.cancel();
617
911
  return answer.status;
618
912
  };
619
- const newId = (prefix: string): string => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
620
- chest.emit = async (event, to) => {
621
- const id = event.id ?? newId("evt_");
622
- const body = JSON.stringify({ id, type: event.type, occurredAt: event.occurredAt ?? new Date().toISOString(), data: event.data });
913
+ chest.deliver = async (event, to) => {
914
+ const id = event.id ?? newId("evt_"), occurredAt = event.occurredAt ?? new Date().toISOString();
915
+ if ("source" in event) {
916
+ const body = JSON.stringify({ id, type: event.type, source: event.source, occurredAt, ...(event.subject === undefined ? {} : { subject: event.subject }), audience: event.audience ?? "all", data: event.data });
917
+ return post("/chest-events", eventChannel, id, body, to);
918
+ }
623
919
  if (event.type === "member.erased") erasures.add(event.data.erasure);
624
- return post("/chest-events", eventChannel, id, body, to);
920
+ return post("/chest-events", eventChannel, id, JSON.stringify({ id, type: event.type, occurredAt, data: event.data }), to);
625
921
  };
626
922
  chest.run = async (name, to, run = {}) => {
627
923
  const id = run.id ?? newId("run_");
@@ -629,7 +925,8 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
629
925
  return post("/chest-schedules", scheduleChannel, id, body, to);
630
926
  };
631
927
  chest.close = async () => {
632
- fakeOrigins.delete(chest.api);
928
+ realtime.close();
929
+ ticketOf = null;
633
930
  server.closeAllConnections();
634
931
  await new Promise<void>(resolve => server.close(() => resolve()));
635
932
  for (const [name, value] of Object.entries(saved)) {