@argentic/chest-sdk 0.4.1 → 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 (76) hide show
  1. package/README.md +500 -73
  2. package/client/index.ts +11 -7
  3. package/client/src/api.ts +15 -8
  4. package/client/src/errors.ts +50 -0
  5. package/client/src/eventrules.ts +117 -0
  6. package/client/src/events.ts +181 -39
  7. package/client/src/files.ts +37 -7
  8. package/client/src/member.ts +15 -9
  9. package/client/src/members.ts +34 -16
  10. package/client/src/notifications.ts +84 -27
  11. package/client/src/realtime-client.ts +516 -0
  12. package/client/src/realtime.ts +118 -0
  13. package/client/src/sealed.ts +158 -0
  14. package/client/src/signed.ts +8 -4
  15. package/client/src/testing-realtime.ts +361 -0
  16. package/client/src/testing.ts +388 -89
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +11 -7
  20. package/dist/index.js.map +1 -1
  21. package/dist/src/api.d.ts +2 -0
  22. package/dist/src/api.d.ts.map +1 -1
  23. package/dist/src/api.js +14 -7
  24. package/dist/src/api.js.map +1 -1
  25. package/dist/src/errors.d.ts +18 -0
  26. package/dist/src/errors.d.ts.map +1 -1
  27. package/dist/src/errors.js +44 -0
  28. package/dist/src/errors.js.map +1 -1
  29. package/dist/src/eventrules.d.ts +23 -0
  30. package/dist/src/eventrules.d.ts.map +1 -0
  31. package/dist/src/eventrules.js +107 -0
  32. package/dist/src/eventrules.js.map +1 -0
  33. package/dist/src/events.d.ts +31 -6
  34. package/dist/src/events.d.ts.map +1 -1
  35. package/dist/src/events.js +130 -26
  36. package/dist/src/events.js.map +1 -1
  37. package/dist/src/files.d.ts +1 -0
  38. package/dist/src/files.d.ts.map +1 -1
  39. package/dist/src/files.js +34 -3
  40. package/dist/src/files.js.map +1 -1
  41. package/dist/src/member.d.ts +1 -1
  42. package/dist/src/member.d.ts.map +1 -1
  43. package/dist/src/member.js +9 -6
  44. package/dist/src/member.js.map +1 -1
  45. package/dist/src/members.d.ts +9 -2
  46. package/dist/src/members.d.ts.map +1 -1
  47. package/dist/src/members.js +29 -13
  48. package/dist/src/members.js.map +1 -1
  49. package/dist/src/notifications.d.ts +12 -1
  50. package/dist/src/notifications.d.ts.map +1 -1
  51. package/dist/src/notifications.js +60 -19
  52. package/dist/src/notifications.js.map +1 -1
  53. package/dist/src/realtime-client.d.ts +45 -0
  54. package/dist/src/realtime-client.d.ts.map +1 -0
  55. package/dist/src/realtime-client.js +453 -0
  56. package/dist/src/realtime-client.js.map +1 -0
  57. package/dist/src/realtime.d.ts +22 -0
  58. package/dist/src/realtime.d.ts.map +1 -0
  59. package/dist/src/realtime.js +99 -0
  60. package/dist/src/realtime.js.map +1 -0
  61. package/dist/src/sealed.d.ts +20 -0
  62. package/dist/src/sealed.d.ts.map +1 -0
  63. package/dist/src/sealed.js +121 -0
  64. package/dist/src/sealed.js.map +1 -0
  65. package/dist/src/signed.d.ts.map +1 -1
  66. package/dist/src/signed.js +6 -2
  67. package/dist/src/signed.js.map +1 -1
  68. package/dist/src/testing-realtime.d.ts +54 -0
  69. package/dist/src/testing-realtime.d.ts.map +1 -0
  70. package/dist/src/testing-realtime.js +376 -0
  71. package/dist/src/testing-realtime.js.map +1 -0
  72. package/dist/src/testing.d.ts +40 -3
  73. package/dist/src/testing.d.ts.map +1 -1
  74. package/dist/src/testing.js +365 -76
  75. package/dist/src/testing.js.map +1 -1
  76. package/package.json +23 -4
@@ -1,34 +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 { 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";
10
15
 
11
16
  // For a tool's own tests, never imported by its production code: a member's
12
17
  // assertion signed as the Chest signs it, and a Chest's API in the test's
13
18
  // process that answers members, groups, files (stat, move, links and
14
- // uploads, which it serves and takes itself as the team host would, on its
15
- // 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,
16
24
  // streamed or not, embeddings, models, usage: deterministic answers, no
17
- // provider) and the acknowledgment of an erasure with the Chest's bounds,
18
- // quotas and errors; and that delivers an event or a run of a schedule to
19
- // 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.
20
32
  //
21
33
  // import { fakeChest, withMember } from "@argentic/chest-sdk/testing";
22
- // 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" } } } });
23
35
  // const response = await app(withMember(new Request("http://tool/chest"), camille));
24
36
  // assert.equal(chest.notifications[0]?.member, camille.id);
25
- // 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");
26
40
  // assert.equal(await chest.run("morning", request => app(request)), 204);
27
41
  // await chest.close();
28
42
 
29
- // A group as a fake Chest keeps it: its identifier, its name, and the
30
- // identifiers of the members it gives the tool to.
31
- 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 };
32
49
  // A file as a fake Chest keeps it.
33
50
  export type FakeFile = { data: Uint8Array; type: string; updated: string };
34
51
  // Someone the tool had who no longer has it: a member of the Chest without
@@ -36,9 +53,12 @@ export type FakeFile = { data: Uint8Array; type: string; updated: string };
36
53
  // was erased (their name gone).
37
54
  export type FakeFormer = { id: string; name?: string; status?: "no_access" | "former" | "erased" };
38
55
  // A notification as a fake Chest keeps it: the member it went to, its text
39
- // cleaned as the Chest cleans it, its path (/chest when not said) and its
40
- // key.
41
- 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 };
42
62
  // An alias a fake Chest maps: the model behind it, its provider (openrouter
43
63
  // by default) and its prices in US dollars per million tokens (1 and 2 by
44
64
  // default).
@@ -58,30 +78,60 @@ export type FakeAi = { models?: FakeAiModel[]; reply?: (request: Record<string,
58
78
  // sent (null for a GET).
59
79
  export type FakeAiCall = { path: string; body: unknown };
60
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
+
61
86
  // What a fake Chest is given: the members who have the tool, those it had
62
87
  // who no longer have it (FakeFormer), its groups, the
63
88
  // capabilities its version holds (a capability left out answers 403;
64
89
  // members, files, notifications and ai by default, members.email to read the
65
- // addresses), the events it receives (["member.*"] by default, [] to answer
66
- // 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
67
95
  // (the chest module: "Test organization", UTC, English and euros by default;
68
96
  // the tool at https://<tool>-chest.chest.test, its public part at
69
- // 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.
70
99
  export type FakeChestOptions = {
71
100
  members?: Member[];
72
101
  former?: FakeFormer[];
73
102
  groups?: FakeGroup[];
74
103
  capabilities?: string[];
104
+ roles?: string[];
75
105
  receives?: string[];
106
+ emits?: Record<string, FakeEmits>;
76
107
  files?: Record<string, { data: Uint8Array | string; type?: string }>;
77
108
  ai?: FakeAi;
109
+ realtime?: FakeRealtimeOptions;
78
110
  chest?: { organization?: string; timeZone?: string; language?: string; currency?: string; teamUrl?: string; publicUrl?: string | null };
79
111
  };
80
112
 
81
- // 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
82
123
  // when it happened (now by default) may be named, to deliver the same event
83
- // twice.
84
- 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 };
85
135
 
86
136
  // A run for run: its id (a new run_… by default: name it to deliver the same
87
137
  // run twice), the time it stands for (now by default) and its attempt (1 by
@@ -93,7 +143,10 @@ export type FakeRun = { id?: string; scheduledAt?: string; attempt?: number };
93
143
  // the tool it set in the environment, what it keeps (members, groups and files a test
94
144
  // changes or reads; the notifications the tool sent, in the order sent, a
95
145
  // replaced one last; each member's badge; the erasures the tool
96
- // 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
97
150
  // /chest-events of its address, or a handler of Web Requests — and says the
98
151
  // status it answered, run, which delivers a run of a schedule the same way
99
152
  // on /chest-schedules, and close, which stops it and restores the
@@ -110,7 +163,10 @@ export type FakeChest = {
110
163
  badges: Map<string, number>;
111
164
  acknowledged: string[];
112
165
  ai: FakeAiCall[];
113
- 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>;
114
170
  run(name: string, to: string | ((request: Request) => Response | Promise<Response>), run?: FakeRun): Promise<number>;
115
171
  close(): Promise<void>;
116
172
  };
@@ -129,30 +185,39 @@ export function signAssertion(member: Member, options: AssertionOptions = {}): s
129
185
  const token = options.token ?? process.env["CHEST_TOKEN"];
130
186
  const tool = options.tool ?? process.env["CHEST_TOOL"];
131
187
  if (!token || !tool) throw new Error("signAssertion needs a token and a tool: start a fakeChest, or name them");
132
- 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_…)");
133
189
  const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
134
190
  // Under the label of the assertion's shape, as the Chest signs it and
135
191
  // member() reads it.
136
192
  return signClaims("Chest-Member v2", {
137
193
  iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
138
194
  given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
139
- 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 }),
140
196
  language: member.language,
141
197
  }, token);
142
198
  }
143
199
 
144
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
+
145
206
  // withMember is the request carrying that member's assertion, signed with
146
- // the options of signAssertion: a new Web Request, or the same Node request
147
- // 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.
148
210
  export function withMember<R extends Request | IncomingMessage>(request: R, member: Member, options: AssertionOptions = {}): R {
149
211
  const assertion = signAssertion(member, options);
212
+ const ticket = ticketOf?.(member.id);
150
213
  if (request instanceof Request) {
151
214
  const headers = new Headers(request.headers);
152
215
  headers.set("Chest-Member", assertion);
216
+ if (ticket) headers.set("Chest-Opener", ticket);
153
217
  return new Request(request, { headers }) as R;
154
218
  }
155
219
  (request as IncomingMessage).headers["chest-member"] = assertion;
220
+ if (ticket) (request as IncomingMessage).headers["chest-opener"] = ticket;
156
221
  return request;
157
222
  }
158
223
 
@@ -161,21 +226,54 @@ const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
161
226
  const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
162
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;
163
228
  // Its links and uploads: their paths on the team host, their lives in
164
- // seconds, the images it makes thumbnails of, the first bytes an upload of
165
- // 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.
166
234
  const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/", linkLife = 900, uploadLife = 900;
167
235
  const mediaPattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
168
236
  const thumbnailed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
169
237
  const starts = (...heads: string[]) => (data: Buffer): boolean => heads.some(head => data.subarray(0, head.length).equals(Buffer.from(head, "latin1")));
170
- 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> = {
171
244
  "image/jpeg": starts("\xff\xd8\xff"), "image/png": starts("\x89PNG\r\n\x1a\n"), "image/gif": starts("GIF87a", "GIF89a"),
172
- "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",
173
267
  };
174
- 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" };
175
268
  const newToken = (): string => randomBytes(18).toString("base64url") + "." + randomBytes(12).toString("base64url");
176
269
  // Its notifications: recipients and badges a call, text, quotas.
177
- const maxRecipients = 500, maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999;
178
- 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;
179
277
  const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
180
278
  const reordering = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
181
279
  const cleanTitle = (s: string): string => s.replace(/[\t\r\n]/gu, " ").replace(/\p{Cc}/gu, "").replace(reordering, "").trim();
@@ -198,6 +296,20 @@ function vectorOf(text: string, dimensions: number): number[] {
198
296
  return kept.map(x => x / norm);
199
297
  }
200
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
+
201
313
  const fold = (s: string): string => s.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
202
314
 
203
315
  function send(response: ServerResponse, status: number, value?: unknown, headers: Record<string, string> = {}): void {
@@ -223,7 +335,7 @@ async function body(request: IncomingMessage, limit: number): Promise<Buffer | n
223
335
  // chest and the modules of the SDK read is then this Chest's.
224
336
  export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChest> {
225
337
  const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications", "ai"]);
226
- const email = capabilities.has("members.email");
338
+ const email = capabilities.has("members.email"), everyGroup = capabilities.has("members.groups");
227
339
  const tool = process.env["CHEST_TOOL"] || "tool";
228
340
  const token = randomBytes(32).toString("base64url");
229
341
  const files = new Map<string, FakeFile>();
@@ -231,12 +343,29 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
231
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() });
232
344
  }
233
345
  const receives = options.receives ?? ["member.*"];
234
- // 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.
235
356
  const erasures = new Set<string>();
236
- 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 () => {} };
237
362
  const former = [...(options.former ?? [])];
238
363
  let window = 0, calls = 0;
239
- 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 } : {}) });
240
369
  const key = (m: Member) => fold(m.name) + "\u0000" + m.id;
241
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 });
242
371
 
@@ -253,7 +382,7 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
253
382
  const group = q.get("group"), role = q.get("role"), search = fold(q.get("q") ?? "");
254
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" });
255
384
  const shownList = chest.members
256
- .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))))
257
386
  .filter(m => after === "" || key(m) > after)
258
387
  .sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
259
388
  const page = shownList.slice(0, limit);
@@ -282,7 +411,16 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
282
411
  const m = chest.members.find(x => x.id === id);
283
412
  return m ? send(response, 200, shown(m)) : send(response, 404, { error: "member_not_found" });
284
413
  }
285
- 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
+ }
286
424
  send(response, 404, { error: "not_found" });
287
425
  }
288
426
 
@@ -308,15 +446,17 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
308
446
  return send(response, 200, { url: `${chest.api}${linkPath}${token}`, expires_in: linkLife });
309
447
  }
310
448
  if (url.pathname === "/files/upload-url") {
311
- const { name, max_size: maxSize, types, expires_in: expiresIn } = command;
449
+ const { name, max_size: maxSize, types, expires_in: expiresIn, public: visitor } = command;
312
450
  const folder = typeof name === "string" && name.endsWith("/");
313
- 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" });
314
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" });
315
- 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" });
316
456
  if ((maxSize ?? 0) > maxObject) return send(response, 413, { error: "too_large" });
317
457
  const token = newToken(), life = (expiresIn as number | undefined) ?? uploadLife;
318
- uploads.set(token, { name, maxSize: (maxSize as number | undefined) ?? maxObject, types: (types as string[] | undefined) ?? [], until: Date.now() + life * 1000 });
319
- 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 });
320
460
  }
321
461
  const from = command["from"], to = command["to"];
322
462
  if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to)) return send(response, 400, { error: "invalid_name" });
@@ -348,15 +488,17 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
348
488
  send(response, 404, { error: "not_found" });
349
489
  }
350
490
 
351
- // The team host's part of the files, on the fake's own origin: a link it
352
- // signed opens the content it was signed for, for its life, as long as
353
- // the file is that content — a thumbnail is the image itself, a fake does
354
- // not reduce it —; an upload token takes one file, once, within its life,
355
- // of the types it names and up to its size, whose first bytes are those of
356
- // 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:
357
499
  // the Chest's own front does that.
358
500
  const links = new Map<string, { name: string; object: FakeFile; until: number; download: boolean }>();
359
- 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 }>();
360
502
  async function front(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
361
503
  if (request.method === "GET" && url.pathname.startsWith(linkPath) && !url.pathname.startsWith(uploadPath)) {
362
504
  const link = links.get(url.pathname.slice(linkPath.length));
@@ -369,11 +511,15 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
369
511
  const token = url.pathname.slice(uploadPath.length), grant = uploads.get(token);
370
512
  uploads.delete(token);
371
513
  if (!grant || grant.until < Date.now()) return send(response, 403, { error: "invalid_token" });
372
- const type = (request.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
373
- 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" });
374
516
  const data = await body(request, grant.maxSize);
375
517
  if (data === null) return send(response, 413, { error: "too_large" });
376
- 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" });
377
523
  const name = grant.name.endsWith("/") ? grant.name + randomBytes(10).toString("hex") + (extensions[type] ?? "") : grant.name;
378
524
  const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
379
525
  if (total + data.length > maxTotal || (!files.has(name) && files.size >= maxObjects)) return send(response, 429, { error: "quota_exceeded" });
@@ -381,17 +527,25 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
381
527
  send(response, 201, { name, type, size: data.length });
382
528
  }
383
529
 
384
- // The windows of the notification quotas, each from the first call it
385
- // counts: the tool's recipients this hour, each member's items this day,
386
- // the tool's badge writes this minute.
530
+ // The windows of a request rate (AI), each from the first call it counts.
387
531
  type Window = { start: number; count: number };
388
- const hour: Window = { start: 0, count: 0 }, minute: Window = { start: 0, count: 0 }, days = new Map<string, Window>();
389
532
  const live = (w: Window | undefined, span: number, now: number): boolean => w !== undefined && w.count > 0 && now - w.start < span;
390
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))) });
391
534
  const count = (w: Window, span: number, now: number, n: number): void => {
392
535
  if (!live(w, span, now)) [w.start, w.count] = [now, 0];
393
536
  w.count += n;
394
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
+ };
395
549
  const access = (id: string) => chest.members.some(m => m.id === id);
396
550
  // drop removes kept notifications in place: a test may hold the list.
397
551
  const drop = (gone: (n: FakeNotification) => boolean): void => {
@@ -399,7 +553,7 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
399
553
  };
400
554
  // recipients reads 1 to 500 member identifiers, each once.
401
555
  function recipients(value: unknown): string[] | { error: string } {
402
- 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" };
403
557
  if (!value.every(v => typeof v === "string" && memberIdPattern.test(v))) return { error: "invalid_id" };
404
558
  return [...new Set(value as string[])];
405
559
  }
@@ -407,8 +561,8 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
407
561
  async function notificationsRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
408
562
  if (!capabilities.has("notifications")) return send(response, 403, { error: "capability_not_granted" });
409
563
  const badge = request.method === "PUT" && url.pathname.startsWith("/badges/");
410
- 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" });
411
- 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);
412
566
  let command: Record<string, unknown> | null = null;
413
567
  try {
414
568
  const value = JSON.parse(raw?.toString() ?? "") as unknown;
@@ -427,14 +581,12 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
427
581
  writes = [{ member: id, count: command!["count"] as number }];
428
582
  } else {
429
583
  const list = command?.["badges"];
430
- 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" });
431
585
  writes = list as { member: string; count: number }[];
432
586
  if (!writes.every(b => typeof b.member === "string" && memberIdPattern.test(b.member))) return send(response, 400, { error: "invalid_id" });
433
587
  }
434
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" });
435
589
  if (new Set(writes.map(b => b.member)).size !== writes.length) return send(response, 400, { error: "invalid_body" });
436
- if (live(minute, 60_000, now) && minute.count + writes.length > badgesPerMinute) return send(response, 429, { error: "quota_exceeded" }, wait(minute, 60_000, now));
437
- count(minute, 60_000, now, writes.length);
438
590
  const answer = { set: [] as string[], skipped: [] as string[] };
439
591
  for (const b of writes) {
440
592
  if (!access(b.member)) {
@@ -456,26 +608,60 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
456
608
  drop(n => n.key === key && (named === null || named.includes(n.member)));
457
609
  return send(response, 204);
458
610
  }
459
- if (!keys("members", "title", "body", "path", "key")) return send(response, 400, { error: "invalid_body" });
460
- const { title, body: text, path, key } = command!;
461
- const ids = recipients(command!["members"]);
462
- if (!Array.isArray(ids)) return send(response, 400, ids);
463
- if (typeof title !== "string" || [...title].length < 1 || [...title].length > maxTitle || cleanTitle(title) === "") return send(response, 400, { error: "invalid_title" });
464
- 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);
465
638
  if (path !== undefined && !isPath(path)) return send(response, 400, { error: "invalid_path" });
466
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
+ }
467
650
  const kept = ids.filter(access);
468
- 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));
469
- const full = kept.map(id => days.get(id)).filter((w): w is Window => live(w, 86_400_000, now) && w!.count >= itemsPerDay);
470
- 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));
471
- count(hour, 3_600_000, now, kept.length);
472
- const cleaned = typeof text === "string" ? cleanText(text) : "";
473
651
  for (const id of kept) {
474
- if (!days.has(id)) days.set(id, { start: 0, count: 0 });
475
- count(days.get(id)!, 86_400_000, now, 1);
476
- if (key !== undefined) drop(n => n.member === id && n.key === key);
477
- 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);
478
663
  }
664
+ if (broadcast) return send(response, 204);
479
665
  send(response, 200, { delivered: kept, skipped: ids.filter(id => !access(id)) });
480
666
  }
481
667
 
@@ -573,7 +759,65 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
573
759
  response.end();
574
760
  }
575
761
 
576
- // 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).
577
821
  async function erasuresRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
578
822
  if (!receives.includes("member.*")) return send(response, 403, { error: "capability_not_granted" });
579
823
  const done = /^\/erasures\/([^/]+)\/done$/u.exec(url.pathname);
@@ -585,17 +829,68 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
585
829
  send(response, 204);
586
830
  }
587
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
+
588
874
  const server = createServer((request, response) => {
589
875
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
590
- 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
591
878
  : url.pathname.startsWith("/erasures/") ? erasuresRoute
879
+ : url.pathname === "/events" ? eventsRoute
592
880
  : url.pathname.startsWith("/ai/") ? aiRoute
881
+ : url.pathname.startsWith("/sealed/") ? sealedRoute
593
882
  : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
594
883
  : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
595
884
  : url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
596
885
  if (!route) return send(response, 404, { error: "not_found" });
597
886
  route(request, response, url).catch(() => { if (!response.headersSent) send(response, 503, { error: "unavailable" }); else response.destroy(); });
598
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
+ });
599
894
  await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
600
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]]));
601
896
  chest.api = "http://127.0.0.1:" + (server.address() as AddressInfo).port;
@@ -615,12 +910,14 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
615
910
  await answer.body?.cancel();
616
911
  return answer.status;
617
912
  };
618
- const newId = (prefix: string): string => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
619
- chest.emit = async (event, to) => {
620
- const id = event.id ?? newId("evt_");
621
- 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
+ }
622
919
  if (event.type === "member.erased") erasures.add(event.data.erasure);
623
- 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);
624
921
  };
625
922
  chest.run = async (name, to, run = {}) => {
626
923
  const id = run.id ?? newId("run_");
@@ -628,6 +925,8 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
628
925
  return post("/chest-schedules", scheduleChannel, id, body, to);
629
926
  };
630
927
  chest.close = async () => {
928
+ realtime.close();
929
+ ticketOf = null;
631
930
  server.closeAllConnections();
632
931
  await new Promise<void>(resolve => server.close(() => resolve()));
633
932
  for (const [name, value] of Object.entries(saved)) {