@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,8 +1,10 @@
1
- import { createHash, randomBytes } from "node:crypto";
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
- import { groupIdPattern, memberIdPattern } from "./member.js";
3
+ import { clockSkew, eventWindow, fieldNamePattern, fieldOf, instantOf, isAudience, isDeclaredData, isToolEventType, itemIdPattern, maxData, rolePattern } from "./eventrules.js";
4
+ import { groupIdPattern, languagePattern, memberIdPattern } from "./member.js";
4
5
  import { forget } from "./members.js";
5
- import { eventChannel, scheduleChannel, sign, signClaims } from "./signed.js";
6
+ import { eventChannel, json, object, scheduleChannel, sign, signClaims } from "./signed.js";
7
+ import { fakeRealtime } from "./testing-realtime.js";
6
8
  // signAssertion is the Chest-Member value the Chest's front would send for
7
9
  // that member: HS256 under the key of the token, for the tool, valid 60
8
10
  // seconds from when it is issued.
@@ -11,7 +13,7 @@ export function signAssertion(member, options = {}) {
11
13
  const tool = options.tool ?? process.env["CHEST_TOOL"];
12
14
  if (!token || !tool)
13
15
  throw new Error("signAssertion needs a token and a tool: start a fakeChest, or name them");
14
- if (!memberIdPattern.test(member.id) || !member.groups.every(g => groupIdPattern.test(g)))
16
+ if (!memberIdPattern.test(member.id) || !(member.groups ?? []).every(g => groupIdPattern.test(g)))
15
17
  throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
16
18
  const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
17
19
  // Under the label of the assertion's shape, as the Chest signs it and
@@ -19,21 +21,31 @@ export function signAssertion(member, options = {}) {
19
21
  return signClaims("Chest-Member v2", {
20
22
  iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
21
23
  given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
22
- admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, time_zone: member.timeZone, ...(member.email === undefined ? {} : { email: member.email }),
24
+ 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 }),
23
25
  language: member.language,
24
26
  }, token);
25
27
  }
28
+ // ticketOf makes the ticket of a member's request to open sealed values
29
+ // (Chest-Opener), under the key of the fake Chest running; null when none
30
+ // runs. The tool never holds that key: only the Chest makes tickets.
31
+ let ticketOf = null;
26
32
  // withMember is the request carrying that member's assertion, signed with
27
- // the options of signAssertion: a new Web Request, or the same Node request
28
- // with its header set.
33
+ // the options of signAssertion, and — while a fake Chest runs — the
34
+ // member's ticket to open sealed values: a new Web Request, or the same
35
+ // Node request with its headers set.
29
36
  export function withMember(request, member, options = {}) {
30
37
  const assertion = signAssertion(member, options);
38
+ const ticket = ticketOf?.(member.id);
31
39
  if (request instanceof Request) {
32
40
  const headers = new Headers(request.headers);
33
41
  headers.set("Chest-Member", assertion);
42
+ if (ticket)
43
+ headers.set("Chest-Opener", ticket);
34
44
  return new Request(request, { headers });
35
45
  }
36
46
  request.headers["chest-member"] = assertion;
47
+ if (ticket)
48
+ request.headers["chest-opener"] = ticket;
37
49
  return request;
38
50
  }
39
51
  // The bounds of a Chest (chest/toolmembers, chest/toolfiles).
@@ -41,21 +53,54 @@ const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
41
53
  const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
42
54
  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;
43
55
  // Its links and uploads: their paths on the team host, their lives in
44
- // seconds, the images it makes thumbnails of, the first bytes an upload of
45
- // a type must start with, the endings of the names it chooses (chest/toolfiles).
56
+ // seconds, the images it makes thumbnails of, the types it recognises by
57
+ // their content — a member's upload of one must hold it, a visitor's is of
58
+ // the one its content is —, the endings of the names it chooses
59
+ // (chest/toolfiles). An office document is told by the names of its parts,
60
+ // read in its bytes rather than in its archive's directory.
46
61
  const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/", linkLife = 900, uploadLife = 900;
47
62
  const mediaPattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
48
63
  const thumbnailed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
49
64
  const starts = (...heads) => (data) => heads.some(head => data.subarray(0, head.length).equals(Buffer.from(head, "latin1")));
50
- const signatures = {
65
+ const text = (data, from, to) => data.subarray(from, to).toString("latin1");
66
+ const brands = (...said) => (data) => 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));
67
+ const zip = starts("PK\x03\x04", "PK\x05\x06");
68
+ const office = (main) => (data) => zip(data) && data.includes("[Content_Types].xml") && data.includes(main);
69
+ const openDocument = (type) => (data) => zip(data) && text(data, 30, 38) === "mimetype" && text(data, 38, 38 + type.length) === type;
70
+ const recognisers = {
51
71
  "image/jpeg": starts("\xff\xd8\xff"), "image/png": starts("\x89PNG\r\n\x1a\n"), "image/gif": starts("GIF87a", "GIF89a"),
52
- "image/webp": data => data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP", "application/pdf": starts("%PDF-"),
72
+ "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"),
73
+ "image/bmp": starts("BM"), "image/tiff": starts("II*\x00", "MM\x00*"), "application/pdf": starts("%PDF-"),
74
+ "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"),
75
+ "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"),
76
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": office("word/document.xml"),
77
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": office("xl/workbook.xml"),
78
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": office("ppt/presentation.xml"),
79
+ "application/vnd.oasis.opendocument.text": openDocument("application/vnd.oasis.opendocument.text"),
80
+ "application/vnd.oasis.opendocument.spreadsheet": openDocument("application/vnd.oasis.opendocument.spreadsheet"),
81
+ "application/vnd.oasis.opendocument.presentation": openDocument("application/vnd.oasis.opendocument.presentation"),
82
+ };
83
+ // The types recognised, an office document before the archive it also is.
84
+ const precise = (type) => type.includes("officedocument") || type.includes("opendocument");
85
+ const recognised = Object.keys(recognisers).sort((a, b) => Number(precise(b)) - Number(precise(a)) || (a < b ? -1 : a > b ? 1 : 0));
86
+ const accepted = (types, type) => types.some(t => t === type || (t.endsWith("/*") && type.startsWith(t.slice(0, -1))));
87
+ const extensions = {
88
+ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", "image/heic": ".heic", "image/bmp": ".bmp", "image/tiff": ".tiff",
89
+ "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json", "application/zip": ".zip",
90
+ "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",
91
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
92
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", "application/vnd.oasis.opendocument.text": ".odt",
93
+ "application/vnd.oasis.opendocument.spreadsheet": ".ods", "application/vnd.oasis.opendocument.presentation": ".odp", "video/mp4": ".mp4", "audio/mpeg": ".mp3",
53
94
  };
54
- const extensions = { "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" };
55
95
  const newToken = () => randomBytes(18).toString("base64url") + "." + randomBytes(12).toString("base64url");
56
96
  // Its notifications: recipients and badges a call, text, quotas.
57
- const maxRecipients = 500, maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999;
58
- const recipientsPerHour = 1000, itemsPerDay = 100, badgesPerMinute = 600;
97
+ // No count bounds a call but the team's size: a body takes the texts and an
98
+ // entry for each member who has the tool. Nothing is refused for its pace:
99
+ // ten items at once to a member, one more every six minutes; beyond, the
100
+ // notices are folded into the one grouped item of that member.
101
+ const maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999, maxRoles = 16;
102
+ const textBytes = 64 << 10, memberBytes = 64;
103
+ const paceBurst = 10, paceEvery = 6 * 60_000;
59
104
  const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
60
105
  const reordering = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
61
106
  const cleanTitle = (s) => s.replace(/[\t\r\n]/gu, " ").replace(/\p{Cc}/gu, "").replace(reordering, "").trim();
@@ -77,6 +122,19 @@ function vectorOf(text, dimensions) {
77
122
  const kept = values.slice(0, dimensions), norm = Math.hypot(...kept) || 1;
78
123
  return kept.map(x => x / norm);
79
124
  }
125
+ // Its sealed values: a call's body, one value, a context, a ticket's life
126
+ // in seconds (chest/toolseal).
127
+ const sealedBody = 4 << 20, sealedValue = 512 << 10, sealedContext = 256, ticketLife = 60;
128
+ 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;
129
+ // Its events: the keys of an emit, and its envelope beyond the data and
130
+ // the identifiers of its audience.
131
+ const emitKeys = ["type", "data", "subject", "key", "occurredAt", "audience", "cause"], envelopeBytes = 4096, idBytes = 64;
132
+ // canonical is a value's JSON with the keys of each object in order: the
133
+ // content an idempotency key stands for, whatever order the tool wrote.
134
+ const canonical = (value) => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
135
+ : value !== null && typeof value === "object" ? `{${Object.keys(value).sort().map(k => JSON.stringify(k) + ":" + canonical(value[k])).join(",")}}`
136
+ : JSON.stringify(value) ?? "null";
137
+ const newId = (prefix) => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
80
138
  const fold = (s) => s.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
81
139
  function send(response, status, value, headers = {}) {
82
140
  if (value === undefined)
@@ -102,7 +160,7 @@ async function body(request, limit) {
102
160
  // chest and the modules of the SDK read is then this Chest's.
103
161
  export async function fakeChest(options = {}) {
104
162
  const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications", "ai"]);
105
- const email = capabilities.has("members.email");
163
+ const email = capabilities.has("members.email"), everyGroup = capabilities.has("members.groups");
106
164
  const tool = process.env["CHEST_TOOL"] || "tool";
107
165
  const token = randomBytes(32).toString("base64url");
108
166
  const files = new Map();
@@ -110,12 +168,29 @@ export async function fakeChest(options = {}) {
110
168
  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() });
111
169
  }
112
170
  const receives = options.receives ?? ["member.*"];
113
- // The erasures the tool was told of, by emit: those it may acknowledge.
171
+ // What the tool's chest.json declares it emits, refused as the Chest
172
+ // refuses the manifest.
173
+ const emits = options.emits ?? {};
174
+ for (const [type, declared] of Object.entries(emits)) {
175
+ const length = typeof declared?.description === "string" ? [...declared.description].length : 0;
176
+ if (!isToolEventType(type) || length < 1 || length > 80 || !object(declared.data) || !Object.entries(declared.data).every(([name, kind]) => fieldNamePattern.test(name) && fieldOf(kind) !== null)) {
177
+ 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)`);
178
+ }
179
+ }
180
+ // The erasures the tool was told of, by deliver: those it may acknowledge.
114
181
  const erasures = new Set();
115
- const chest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], ai: [], emit: async () => 0, run: async () => 0, close: async () => { } };
182
+ const realtime = fakeRealtime(options.realtime ?? {}, () => chest.api, id => {
183
+ const m = chest.members.find(x => x.id === id);
184
+ return m ? m.role : undefined;
185
+ });
186
+ const chest = { 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 () => { } };
116
187
  const former = [...(options.former ?? [])];
117
188
  let window = 0, calls = 0;
118
- const shown = (m) => ({ 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 } : {}) });
189
+ // The groups the tool sees: those that give it, or all with members.groups;
190
+ // a member's groups among them (an identifier it was not given stays).
191
+ const unseen = () => new Set(everyGroup ? [] : chest.groups.filter(g => g.grants === false).map(g => g.id));
192
+ const groupsOf = (m) => (m.groups ?? []).filter(g => !unseen().has(g));
193
+ const shown = (m) => ({ 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 } : {}) });
119
194
  const key = (m) => fold(m.name) + "\u0000" + m.id;
120
195
  const described = (name, f) => ({ name, type: f.type, size: f.data.byteLength, sha256: createHash("sha256").update(f.data).digest("hex"), updated: f.updated });
121
196
  async function members(request, response, url) {
@@ -136,7 +211,7 @@ export async function fakeChest(options = {}) {
136
211
  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)
137
212
  return send(response, 400, { error: "invalid_query" });
138
213
  const shownList = chest.members
139
- .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))))
214
+ .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))))
140
215
  .filter(m => after === "" || key(m) > after)
141
216
  .sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
142
217
  const page = shownList.slice(0, limit);
@@ -178,8 +253,17 @@ export async function fakeChest(options = {}) {
178
253
  const m = chest.members.find(x => x.id === id);
179
254
  return m ? send(response, 200, shown(m)) : send(response, 404, { error: "member_not_found" });
180
255
  }
181
- if (request.method === "GET" && url.pathname === "/groups")
182
- return send(response, 200, { groups: chest.groups });
256
+ if (request.method === "GET" && url.pathname === "/groups") {
257
+ const q = url.searchParams, keys = [...q.keys()];
258
+ const limit = q.has("limit") ? Number(q.get("limit")) : defaultLimit;
259
+ const after = q.has("after") ? Buffer.from(q.get("after"), "base64url").toString() : "";
260
+ 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)))
261
+ return send(response, 400, { error: "invalid_query" });
262
+ const groupKey = (g) => fold(g.name) + "\u0000" + g.id;
263
+ 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);
264
+ const page = listed.slice(0, limit);
265
+ 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 });
266
+ }
183
267
  send(response, 404, { error: "not_found" });
184
268
  }
185
269
  async function filesRoute(request, response, url) {
@@ -213,19 +297,23 @@ export async function fakeChest(options = {}) {
213
297
  return send(response, 200, { url: `${chest.api}${linkPath}${token}`, expires_in: linkLife });
214
298
  }
215
299
  if (url.pathname === "/files/upload-url") {
216
- const { name, max_size: maxSize, types, expires_in: expiresIn } = command;
300
+ const { name, max_size: maxSize, types, expires_in: expiresIn, public: visitor } = command;
217
301
  const folder = typeof name === "string" && name.endsWith("/");
218
- if (typeof name !== "string" || !namePattern.test(folder ? name.slice(0, -1) : name))
302
+ if (typeof name !== "string" || !namePattern.test(folder ? name.slice(0, -1) : name) || (visitor === true && !folder))
219
303
  return send(response, 400, { error: "invalid_name" });
220
304
  if (!(types === undefined || (Array.isArray(types) && types.length <= 8 && types.every(t => typeof t === "string" && mediaPattern.test(t)))))
221
305
  return send(response, 400, { error: "invalid_type" });
222
- if (!(maxSize === undefined || (typeof maxSize === "number" && Number.isSafeInteger(maxSize) && maxSize > 0)) || !(expiresIn === undefined || (typeof expiresIn === "number" && Number.isInteger(expiresIn) && expiresIn > 0 && expiresIn <= uploadLife)))
306
+ if (visitor === true && (!Array.isArray(types) || types.length === 0 || !types.every(t => recognised.some(r => accepted([t], r)))))
307
+ return send(response, 400, { error: "invalid_type" });
308
+ 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"))
223
309
  return send(response, 400, { error: "invalid_body" });
310
+ if (visitor === true && process.env["CHEST_PUBLIC_URL"] === undefined)
311
+ return send(response, 409, { error: "no_public_part" });
224
312
  if ((maxSize ?? 0) > maxObject)
225
313
  return send(response, 413, { error: "too_large" });
226
314
  const token = newToken(), life = expiresIn ?? uploadLife;
227
- uploads.set(token, { name, maxSize: maxSize ?? maxObject, types: types ?? [], until: Date.now() + life * 1000 });
228
- return send(response, 200, { url: `${chest.api}${uploadPath}${token}`, method: "PUT", expires_in: life });
315
+ uploads.set(token, { name, maxSize: maxSize ?? maxObject, types: types ?? [], until: Date.now() + life * 1000, visitor: visitor === true });
316
+ return send(response, 200, { url: visitor === true ? uploadPath + token : `${chest.api}${uploadPath}${token}`, method: "PUT", expires_in: life });
229
317
  }
230
318
  const from = command["from"], to = command["to"];
231
319
  if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to))
@@ -264,12 +352,14 @@ export async function fakeChest(options = {}) {
264
352
  }
265
353
  send(response, 404, { error: "not_found" });
266
354
  }
267
- // The team host's part of the files, on the fake's own origin: a link it
268
- // signed opens the content it was signed for, for its life, as long as
269
- // the file is that content — a thumbnail is the image itself, a fake does
270
- // not reduce it —; an upload token takes one file, once, within its life,
271
- // of the types it names and up to its size, whose first bytes are those of
272
- // its type, named by the Chest in a folder. Nobody's session is checked:
355
+ // The team host's and the public part's files, on the fake's own origin:
356
+ // a link it signed opens the content it was signed for, for its life, as
357
+ // long as the file is that content — a thumbnail is the image itself, a
358
+ // fake does not reduce it —; an upload token takes one file, once, within
359
+ // its life, up to its size, named by the Chest in a folder: a member's of
360
+ // the types it names, holding the type it says when the fake recognises
361
+ // it; a visitor's of the type its content is among those it names,
362
+ // whatever it says. Nobody's session is checked, nor a visitor's pace:
273
363
  // the Chest's own front does that.
274
364
  const links = new Map();
275
365
  const uploads = new Map();
@@ -289,13 +379,19 @@ export async function fakeChest(options = {}) {
289
379
  uploads.delete(token);
290
380
  if (!grant || grant.until < Date.now())
291
381
  return send(response, 403, { error: "invalid_token" });
292
- const type = (request.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
293
- if (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !grant.types.some(t => t === type || (t.endsWith("/*") && type.startsWith(t.slice(0, -1))))))
382
+ let type = (request.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
383
+ if (!grant.visitor && (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !accepted(grant.types, type))))
294
384
  return send(response, 415, { error: "type_refused" });
295
385
  const data = await body(request, grant.maxSize);
296
386
  if (data === null)
297
387
  return send(response, 413, { error: "too_large" });
298
- if (signatures[type] && !signatures[type](data))
388
+ if (grant.visitor) {
389
+ const found = recognised.find(r => accepted(grant.types, r) && recognisers[r](data));
390
+ if (found === undefined)
391
+ return send(response, 415, { error: "type_refused" });
392
+ type = found;
393
+ }
394
+ else if (recognisers[type] && !recognisers[type](data))
299
395
  return send(response, 400, { error: "type_mismatch" });
300
396
  const name = grant.name.endsWith("/") ? grant.name + randomBytes(10).toString("hex") + (extensions[type] ?? "") : grant.name;
301
397
  const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
@@ -304,7 +400,6 @@ export async function fakeChest(options = {}) {
304
400
  files.set(name, { data: new Uint8Array(data), type, updated: new Date().toISOString() });
305
401
  send(response, 201, { name, type, size: data.length });
306
402
  }
307
- const hour = { start: 0, count: 0 }, minute = { start: 0, count: 0 }, days = new Map();
308
403
  const live = (w, span, now) => w !== undefined && w.count > 0 && now - w.start < span;
309
404
  const wait = (w, span, now) => ({ "Retry-After": String(Math.max(1, Math.ceil((w.start + span - now) / 1000))) });
310
405
  const count = (w, span, now, n) => {
@@ -312,6 +407,18 @@ export async function fakeChest(options = {}) {
312
407
  [w.start, w.count] = [now, 0];
313
408
  w.count += n;
314
409
  };
410
+ // The pace of each member: a token bucket, as of when it was last used.
411
+ const buckets = new Map();
412
+ const paced = (id, now) => {
413
+ const b = buckets.get(id) ?? { tokens: paceBurst, at: now };
414
+ b.tokens = Math.min(paceBurst, b.tokens + (now - b.at) / paceEvery);
415
+ b.at = now;
416
+ buckets.set(id, b);
417
+ if (b.tokens < 1)
418
+ return false;
419
+ b.tokens--;
420
+ return true;
421
+ };
315
422
  const access = (id) => chest.members.some(m => m.id === id);
316
423
  // drop removes kept notifications in place: a test may hold the list.
317
424
  const drop = (gone) => {
@@ -321,7 +428,7 @@ export async function fakeChest(options = {}) {
321
428
  };
322
429
  // recipients reads 1 to 500 member identifiers, each once.
323
430
  function recipients(value) {
324
- if (!Array.isArray(value) || value.length < 1 || value.length > maxRecipients)
431
+ if (!Array.isArray(value) || value.length < 1)
325
432
  return { error: "invalid_body" };
326
433
  if (!value.every(v => typeof v === "string" && memberIdPattern.test(v)))
327
434
  return { error: "invalid_id" };
@@ -331,9 +438,9 @@ export async function fakeChest(options = {}) {
331
438
  if (!capabilities.has("notifications"))
332
439
  return send(response, 403, { error: "capability_not_granted" });
333
440
  const badge = request.method === "PUT" && url.pathname.startsWith("/badges/");
334
- if (!badge && !(request.method === "PUT" && url.pathname === "/badges") && !(request.method === "POST" && (url.pathname === "/notifications" || url.pathname === "/notifications/withdraw")))
441
+ if (!badge && !(request.method === "PUT" && url.pathname === "/badges") && !(request.method === "POST" && ["/notifications", "/notifications/broadcast", "/notifications/withdraw"].includes(url.pathname)))
335
442
  return send(response, 404, { error: "not_found" });
336
- const raw = await body(request, 64 << 10);
443
+ const raw = await body(request, textBytes + memberBytes * chest.members.length);
337
444
  let command = null;
338
445
  try {
339
446
  const value = JSON.parse(raw?.toString() ?? "");
@@ -357,7 +464,7 @@ export async function fakeChest(options = {}) {
357
464
  }
358
465
  else {
359
466
  const list = command?.["badges"];
360
- 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")))
467
+ 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")))
361
468
  return send(response, 400, { error: "invalid_body" });
362
469
  writes = list;
363
470
  if (!writes.every(b => typeof b.member === "string" && memberIdPattern.test(b.member)))
@@ -367,9 +474,6 @@ export async function fakeChest(options = {}) {
367
474
  return send(response, 400, { error: "invalid_count" });
368
475
  if (new Set(writes.map(b => b.member)).size !== writes.length)
369
476
  return send(response, 400, { error: "invalid_body" });
370
- if (live(minute, 60_000, now) && minute.count + writes.length > badgesPerMinute)
371
- return send(response, 429, { error: "quota_exceeded" }, wait(minute, 60_000, now));
372
- count(minute, 60_000, now, writes.length);
373
477
  const answer = { set: [], skipped: [] };
374
478
  for (const b of writes) {
375
479
  if (!access(b.member)) {
@@ -396,36 +500,78 @@ export async function fakeChest(options = {}) {
396
500
  drop(n => n.key === key && (named === null || named.includes(n.member)));
397
501
  return send(response, 204);
398
502
  }
399
- if (!keys("members", "title", "body", "path", "key"))
503
+ const broadcast = url.pathname === "/notifications/broadcast";
504
+ if (!keys(broadcast ? "to" : "members", ...(broadcast ? ["except"] : []), "title", "body", "path", "key", "translations"))
400
505
  return send(response, 400, { error: "invalid_body" });
401
- const { title, body: text, path, key } = command;
402
- const ids = recipients(command["members"]);
403
- if (!Array.isArray(ids))
404
- return send(response, 400, ids);
405
- if (typeof title !== "string" || [...title].length < 1 || [...title].length > maxTitle || cleanTitle(title) === "")
406
- return send(response, 400, { error: "invalid_title" });
407
- if (text !== undefined && (typeof text !== "string" || [...text].length > maxText))
408
- return send(response, 400, { error: "invalid_text" });
506
+ let ids;
507
+ if (broadcast) {
508
+ const to = command["to"], except = command["except"];
509
+ if (to !== undefined && (to === null || typeof to !== "object" || Array.isArray(to) || !Object.keys(to).every(k => k === "groups" || k === "roles")))
510
+ return send(response, 400, { error: "invalid_body" });
511
+ const groups = to?.["groups"] ?? [], roles = to?.["roles"] ?? [];
512
+ if (!Array.isArray(groups) || !Array.isArray(roles) || (to !== undefined && groups.length + roles.length === 0) || roles.length > maxRoles || (except !== undefined && !Array.isArray(except)))
513
+ return send(response, 400, { error: "invalid_body" });
514
+ if (!groups.every(g => typeof g === "string" && groupIdPattern.test(g)) || !(except ?? []).every(id => typeof id === "string" && memberIdPattern.test(id)))
515
+ return send(response, 400, { error: "invalid_id" });
516
+ if (!roles.every(r => typeof r === "string" && rolePattern.test(r)))
517
+ return send(response, 400, { error: "invalid_role" });
518
+ ids = chest.members.filter(m => !(except ?? []).includes(m.id) && (to === undefined || (m.role !== null && roles.includes(m.role)) || groupsOf(m).some(g => groups.includes(g)))).map(m => m.id);
519
+ }
520
+ else {
521
+ const named = recipients(command["members"]);
522
+ if (!Array.isArray(named))
523
+ return send(response, 400, named);
524
+ ids = named;
525
+ }
526
+ const { path, key, translations } = command;
527
+ const words = (value) => {
528
+ const { title, body: text, ...rest } = (value ?? {});
529
+ if (value === null || typeof value !== "object" || Array.isArray(value) || Object.keys(rest).length > 0 || title === undefined)
530
+ return { error: "invalid_body" };
531
+ if (typeof title !== "string" || [...title].length < 1 || [...title].length > maxTitle || cleanTitle(title) === "")
532
+ return { error: "invalid_title" };
533
+ if (text !== undefined && (typeof text !== "string" || [...text].length > maxText))
534
+ return { error: "invalid_text" };
535
+ const cleaned = typeof text === "string" ? cleanText(text) : "";
536
+ return { title: cleanTitle(title), ...(cleaned ? { body: cleaned } : {}) };
537
+ };
538
+ const own = words({ title: command["title"], ...(command["body"] !== undefined ? { body: command["body"] } : {}) });
539
+ if ("error" in own)
540
+ return send(response, 400, own);
409
541
  if (path !== undefined && !isPath(path))
410
542
  return send(response, 400, { error: "invalid_path" });
411
543
  if (key !== undefined && (typeof key !== "string" || !keyPattern.test(key)))
412
544
  return send(response, 400, { error: "invalid_key" });
545
+ const other = new Map();
546
+ if (translations !== undefined) {
547
+ if (translations === null || typeof translations !== "object" || Array.isArray(translations))
548
+ return send(response, 400, { error: "invalid_body" });
549
+ for (const [language, value] of Object.entries(translations)) {
550
+ if (!languagePattern.test(language))
551
+ return send(response, 400, { error: "invalid_language" });
552
+ const translated = words(value);
553
+ if ("error" in translated)
554
+ return send(response, 400, translated);
555
+ other.set(language, translated);
556
+ }
557
+ }
413
558
  const kept = ids.filter(access);
414
- if (live(hour, 3_600_000, now) && hour.count + kept.length > recipientsPerHour)
415
- return send(response, 429, { error: "quota_exceeded" }, wait(hour, 3_600_000, now));
416
- const full = kept.map(id => days.get(id)).filter((w) => live(w, 86_400_000, now) && w.count >= itemsPerDay);
417
- if (full.length > 0)
418
- return send(response, 429, { error: "quota_exceeded" }, wait(full.reduce((a, b) => a.start > b.start ? a : b), 86_400_000, now));
419
- count(hour, 3_600_000, now, kept.length);
420
- const cleaned = typeof text === "string" ? cleanText(text) : "";
421
559
  for (const id of kept) {
422
- if (!days.has(id))
423
- days.set(id, { start: 0, count: 0 });
424
- count(days.get(id), 86_400_000, now, 1);
425
- if (key !== undefined)
560
+ const language = chest.members.find(m => m.id === id).language;
561
+ const item = { member: id, ...(other.get(language) ?? own), path: path ?? "/chest", ...(key !== undefined ? { key: key } : {}) };
562
+ if (key !== undefined && chest.notifications.some(n => n.member === id && n.key === key))
426
563
  drop(n => n.member === id && n.key === key);
427
- chest.notifications.push({ member: id, title: cleanTitle(title), ...(cleaned ? { body: cleaned } : {}), path: path ?? "/chest", ...(key !== undefined ? { key: key } : {}) });
564
+ else if (!paced(id, now)) {
565
+ // Beyond the pace: folded into the member's grouped item, never refused.
566
+ const grouped = chest.notifications.find(n => n.member === id && n.grouped !== undefined);
567
+ drop(n => n === grouped);
568
+ delete item.key;
569
+ item.grouped = (grouped?.grouped ?? 0) + 1;
570
+ }
571
+ chest.notifications.push(item);
428
572
  }
573
+ if (broadcast)
574
+ return send(response, 204);
429
575
  send(response, 200, { delivered: kept, skipped: ids.filter(id => !access(id)) });
430
576
  }
431
577
  // Its AI: the declared aliases, the spending this month, the requests this
@@ -541,7 +687,83 @@ export async function fakeChest(options = {}) {
541
687
  response.write(part);
542
688
  response.end();
543
689
  }
544
- // The acknowledgment of an erasure the tool was told of (emit).
690
+ // Its sealed values: AES-256-GCM under a key of its own — the format and
691
+ // the rules of a Chest, not its cipher —, bound to the tool, the roles
692
+ // and the context; its tickets, HMAC under another, for 60 seconds.
693
+ const sealingKey = randomBytes(32), ticketKey = randomBytes(32);
694
+ const additional = (roles, context) => Buffer.from(`chest:sealed:1:\u0000${tool}\u0000${roles}\u0000${context}`);
695
+ const ticketMac = (id, expiry) => createHmac("sha256", ticketKey).update(`${id}\u0000${expiry}`).digest();
696
+ ticketOf = id => { const expiry = String(Math.floor(Date.now() / 1000) + ticketLife); return `1.${id}.${expiry}.${ticketMac(id, expiry).toString("base64url")}`; };
697
+ const ticketMember = (ticket) => {
698
+ const parts = typeof ticket === "string" ? ticket.split(".") : [];
699
+ if (parts.length !== 4 || parts[0] !== "1" || !memberIdPattern.test(parts[1]) || Number(parts[2]) < Date.now() / 1000)
700
+ return null;
701
+ const mac = Buffer.from(parts[3], "base64url"), expected = ticketMac(parts[1], parts[2]);
702
+ return mac.length === expected.length && timingSafeEqual(mac, expected) ? parts[1] : null;
703
+ };
704
+ const isContext = (c) => c === undefined || (typeof c === "string" && Buffer.byteLength(c) <= sealedContext);
705
+ async function sealedRoute(request, response, url) {
706
+ if (!capabilities.has("sealed"))
707
+ return send(response, 403, { error: "capability_not_granted" });
708
+ if (request.method !== "POST" || url.search || (url.pathname !== "/sealed/seal" && url.pathname !== "/sealed/open"))
709
+ return send(response, 404, { error: "not_found" });
710
+ const id = url.pathname === "/sealed/open" ? ticketMember(request.headers["chest-opener"]) : null;
711
+ if (url.pathname === "/sealed/open" && id === null)
712
+ return send(response, 401, { error: "member_required" });
713
+ const raw = await body(request, sealedBody);
714
+ if (raw === null)
715
+ return send(response, 413, { error: "too_large" });
716
+ let items;
717
+ try {
718
+ items = JSON.parse(raw.toString()).items;
719
+ }
720
+ catch {
721
+ items = undefined;
722
+ }
723
+ if (!Array.isArray(items))
724
+ return send(response, 400, { error: "invalid_body" });
725
+ if (url.pathname === "/sealed/seal") {
726
+ const sealed = [];
727
+ for (const item of items) {
728
+ if (typeof item?.value !== "string" || Buffer.byteLength(item.value) > sealedValue || !isContext(item.context))
729
+ return send(response, 400, { error: "invalid_body" });
730
+ const roles = item.roles;
731
+ 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))
732
+ return send(response, 400, { error: "invalid_role" });
733
+ const joined = roles?.join(",") ?? "", nonce = randomBytes(12);
734
+ const cipher = createCipheriv("aes-256-gcm", sealingKey, nonce).setAAD(additional(joined, item.context ?? ""));
735
+ const box = Buffer.concat([nonce, cipher.update(item.value, "utf8"), cipher.final(), cipher.getAuthTag()]);
736
+ sealed.push(`chest:sealed:1:${joined}:${box.toString("base64url")}`);
737
+ }
738
+ return send(response, 200, { sealed });
739
+ }
740
+ const holder = chest.members.find(m => m.id === id);
741
+ if (!holder)
742
+ return send(response, 403, { error: "access_removed" });
743
+ const values = [];
744
+ for (const item of items) {
745
+ if (typeof item?.sealed !== "string" || !isContext(item.context))
746
+ return send(response, 400, { error: "invalid_body" });
747
+ const found = sealedText.exec(item.sealed), box = found ? Buffer.from(found[2], "base64url") : Buffer.alloc(0);
748
+ let value = null;
749
+ if (found && box.length >= 28) {
750
+ try {
751
+ const decipher = createDecipheriv("aes-256-gcm", sealingKey, box.subarray(0, 12)).setAAD(additional(found[1], item.context ?? ""));
752
+ decipher.setAuthTag(box.subarray(box.length - 16));
753
+ value = Buffer.concat([decipher.update(box.subarray(12, box.length - 16)), decipher.final()]).toString("utf8");
754
+ }
755
+ catch {
756
+ value = null;
757
+ }
758
+ }
759
+ const roles = found?.[1] ? found[1].split(",") : [];
760
+ values.push(value === null ? { refused: "invalid" } : roles.length && !roles.includes(holder.role ?? "") ? { refused: "role" } : { value });
761
+ }
762
+ if (values.length)
763
+ chest.opens.push({ member: holder.id, opened: values.filter(v => "value" in v).length, refused: values.filter(v => "refused" in v).length });
764
+ send(response, 200, { values });
765
+ }
766
+ // The acknowledgment of an erasure the tool was told of (deliver).
545
767
  async function erasuresRoute(request, response, url) {
546
768
  if (!receives.includes("member.*"))
547
769
  return send(response, 403, { error: "capability_not_granted" });
@@ -557,14 +779,70 @@ export async function fakeChest(options = {}) {
557
779
  chest.acknowledged.push(id);
558
780
  send(response, 204);
559
781
  }
782
+ // Realtime: the tool's API, and the probe of a page that asks whether its
783
+ // member may connect.
784
+ async function realtimeRoute(request, response, url) {
785
+ if (!capabilities.has("realtime"))
786
+ return send(response, url.pathname === "/_chest/realtime" ? 404 : 403, url.pathname === "/_chest/realtime" ? undefined : { error: "capability_not_granted" });
787
+ if (url.pathname === "/_chest/realtime")
788
+ return realtime.probe(response, url, send);
789
+ return realtime.api(request, response, url, body, send);
790
+ }
791
+ // The events the tool emits (POST /events), checked against its "emits"
792
+ // as the Chest checks them: a member it never had, a group it does not
793
+ // know, are refused; an idempotency key used within 72 hours answers its
794
+ // first event for the same content, key_reused for other content.
795
+ const used = new Map();
796
+ const had = (id) => access(id) || former.some(f => f.id === id);
797
+ async function eventsRoute(request, response, url) {
798
+ if (request.method !== "POST" || url.pathname !== "/events" || url.search)
799
+ return send(response, 404, { error: "not_found" });
800
+ if (Object.keys(emits).length === 0)
801
+ return send(response, 403, { error: "capability_not_granted" });
802
+ // The data, the envelope, and an audience of as many identifiers as the Chest holds.
803
+ const raw = await body(request, maxData + envelopeBytes + idBytes * (chest.members.length + former.length + chest.groups.length + maxRoles));
804
+ if (raw === null)
805
+ return send(response, 413, { error: "too_large" });
806
+ const command = object(json(raw.toString("utf8")));
807
+ if (!command || !Object.keys(command).every(k => emitKeys.includes(k)))
808
+ return send(response, 400, { error: "invalid_event" });
809
+ const { type, data, subject, key, occurredAt, audience, cause } = command;
810
+ if (!isToolEventType(type) || !Object.hasOwn(emits, type))
811
+ return send(response, 400, { error: "invalid_type" });
812
+ const now = Date.now(), time = occurredAt === undefined ? now : instantOf(occurredAt);
813
+ const identifier = (value, pattern) => value === undefined || (typeof value === "string" && pattern.test(value));
814
+ if (!identifier(subject, itemIdPattern) || !identifier(key, itemIdPattern) || !identifier(cause, eventChannel.id) || time === null || time > now + clockSkew || time < now - eventWindow)
815
+ return send(response, 400, { error: "invalid_event" });
816
+ if (Buffer.byteLength(JSON.stringify(data) ?? "") > maxData)
817
+ return send(response, 413, { error: "too_large" });
818
+ if (!isDeclaredData(data, emits[type].data, had))
819
+ return send(response, 400, { error: "invalid_data" });
820
+ if (audience !== undefined && (!isAudience(audience) || !(audience.members ?? []).every(had) || !(audience.groups ?? []).every(g => chest.groups.some(x => x.id === g))))
821
+ return send(response, 400, { error: "invalid_audience" });
822
+ const content = canonical({ type, data, subject, occurredAt, audience });
823
+ const first = key === undefined ? undefined : used.get(key);
824
+ if (first && now - first.at < eventWindow)
825
+ return first.content === content ? send(response, 200, { id: first.id, receivers: 0 }) : send(response, 409, { error: "key_reused" });
826
+ const id = newId("evt_");
827
+ if (key !== undefined)
828
+ used.set(key, { id, content, at: now });
829
+ chest.emitted.push({
830
+ id, type, data: data, ...(subject === undefined ? {} : { subject: subject }), ...(key === undefined ? {} : { key: key }),
831
+ occurredAt: occurredAt ?? new Date(now).toISOString(), ...(audience === undefined ? {} : { audience }), ...(cause === undefined ? {} : { cause: cause }),
832
+ });
833
+ send(response, 202, { id, receivers: 0 });
834
+ }
560
835
  const server = createServer((request, response) => {
561
836
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
562
- const route = url.pathname.startsWith("/_chest/files/") ? front
563
- : url.pathname.startsWith("/erasures/") ? erasuresRoute
564
- : url.pathname.startsWith("/ai/") ? aiRoute
565
- : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
566
- : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
567
- : url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
837
+ const route = url.pathname === "/_chest/realtime" || url.pathname.startsWith("/realtime/") ? realtimeRoute
838
+ : url.pathname.startsWith("/_chest/files/") ? front
839
+ : url.pathname.startsWith("/erasures/") ? erasuresRoute
840
+ : url.pathname === "/events" ? eventsRoute
841
+ : url.pathname.startsWith("/ai/") ? aiRoute
842
+ : url.pathname.startsWith("/sealed/") ? sealedRoute
843
+ : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
844
+ : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
845
+ : url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
568
846
  if (!route)
569
847
  return send(response, 404, { error: "not_found" });
570
848
  route(request, response, url).catch(() => { if (!response.headersSent)
@@ -572,6 +850,13 @@ export async function fakeChest(options = {}) {
572
850
  else
573
851
  response.destroy(); });
574
852
  });
853
+ // A page's connection: the Chest's side of the browser client.
854
+ server.on("upgrade", (request, socket) => {
855
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
856
+ if (url.pathname !== "/_chest/realtime" || !capabilities.has("realtime"))
857
+ return void socket.end("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
858
+ realtime.upgrade(request, socket, url);
859
+ });
575
860
  await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
576
861
  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]]));
577
862
  chest.api = "http://127.0.0.1:" + server.address().port;
@@ -593,13 +878,15 @@ export async function fakeChest(options = {}) {
593
878
  await answer.body?.cancel();
594
879
  return answer.status;
595
880
  };
596
- const newId = (prefix) => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
597
- chest.emit = async (event, to) => {
598
- const id = event.id ?? newId("evt_");
599
- const body = JSON.stringify({ id, type: event.type, occurredAt: event.occurredAt ?? new Date().toISOString(), data: event.data });
881
+ chest.deliver = async (event, to) => {
882
+ const id = event.id ?? newId("evt_"), occurredAt = event.occurredAt ?? new Date().toISOString();
883
+ if ("source" in event) {
884
+ 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 });
885
+ return post("/chest-events", eventChannel, id, body, to);
886
+ }
600
887
  if (event.type === "member.erased")
601
888
  erasures.add(event.data.erasure);
602
- return post("/chest-events", eventChannel, id, body, to);
889
+ return post("/chest-events", eventChannel, id, JSON.stringify({ id, type: event.type, occurredAt, data: event.data }), to);
603
890
  };
604
891
  chest.run = async (name, to, run = {}) => {
605
892
  const id = run.id ?? newId("run_");
@@ -607,6 +894,8 @@ export async function fakeChest(options = {}) {
607
894
  return post("/chest-schedules", scheduleChannel, id, body, to);
608
895
  };
609
896
  chest.close = async () => {
897
+ realtime.close();
898
+ ticketOf = null;
610
899
  server.closeAllConnections();
611
900
  await new Promise(resolve => server.close(() => resolve()));
612
901
  for (const [name, value] of Object.entries(saved)) {