@argentic/chest-sdk 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +508 -80
  2. package/client/index.ts +11 -7
  3. package/client/src/api.ts +19 -18
  4. package/client/src/database.ts +6 -1
  5. package/client/src/errors.ts +50 -0
  6. package/client/src/eventrules.ts +117 -0
  7. package/client/src/events.ts +181 -39
  8. package/client/src/files.ts +37 -7
  9. package/client/src/member.ts +15 -9
  10. package/client/src/members.ts +34 -16
  11. package/client/src/notifications.ts +84 -27
  12. package/client/src/realtime-client.ts +516 -0
  13. package/client/src/realtime.ts +118 -0
  14. package/client/src/sealed.ts +158 -0
  15. package/client/src/signed.ts +8 -4
  16. package/client/src/testing-realtime.ts +361 -0
  17. package/client/src/testing.ts +391 -94
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +11 -7
  21. package/dist/index.js.map +1 -1
  22. package/dist/src/api.d.ts +2 -1
  23. package/dist/src/api.d.ts.map +1 -1
  24. package/dist/src/api.js +18 -16
  25. package/dist/src/api.js.map +1 -1
  26. package/dist/src/database.d.ts.map +1 -1
  27. package/dist/src/database.js +5 -1
  28. package/dist/src/database.js.map +1 -1
  29. package/dist/src/errors.d.ts +18 -0
  30. package/dist/src/errors.d.ts.map +1 -1
  31. package/dist/src/errors.js +44 -0
  32. package/dist/src/errors.js.map +1 -1
  33. package/dist/src/eventrules.d.ts +23 -0
  34. package/dist/src/eventrules.d.ts.map +1 -0
  35. package/dist/src/eventrules.js +107 -0
  36. package/dist/src/eventrules.js.map +1 -0
  37. package/dist/src/events.d.ts +31 -6
  38. package/dist/src/events.d.ts.map +1 -1
  39. package/dist/src/events.js +130 -26
  40. package/dist/src/events.js.map +1 -1
  41. package/dist/src/files.d.ts +1 -0
  42. package/dist/src/files.d.ts.map +1 -1
  43. package/dist/src/files.js +34 -3
  44. package/dist/src/files.js.map +1 -1
  45. package/dist/src/member.d.ts +1 -1
  46. package/dist/src/member.d.ts.map +1 -1
  47. package/dist/src/member.js +9 -6
  48. package/dist/src/member.js.map +1 -1
  49. package/dist/src/members.d.ts +9 -2
  50. package/dist/src/members.d.ts.map +1 -1
  51. package/dist/src/members.js +29 -13
  52. package/dist/src/members.js.map +1 -1
  53. package/dist/src/notifications.d.ts +12 -1
  54. package/dist/src/notifications.d.ts.map +1 -1
  55. package/dist/src/notifications.js +60 -19
  56. package/dist/src/notifications.js.map +1 -1
  57. package/dist/src/realtime-client.d.ts +45 -0
  58. package/dist/src/realtime-client.d.ts.map +1 -0
  59. package/dist/src/realtime-client.js +453 -0
  60. package/dist/src/realtime-client.js.map +1 -0
  61. package/dist/src/realtime.d.ts +22 -0
  62. package/dist/src/realtime.d.ts.map +1 -0
  63. package/dist/src/realtime.js +99 -0
  64. package/dist/src/realtime.js.map +1 -0
  65. package/dist/src/sealed.d.ts +20 -0
  66. package/dist/src/sealed.d.ts.map +1 -0
  67. package/dist/src/sealed.js +121 -0
  68. package/dist/src/sealed.js.map +1 -0
  69. package/dist/src/signed.d.ts.map +1 -1
  70. package/dist/src/signed.js +6 -2
  71. package/dist/src/signed.js.map +1 -1
  72. package/dist/src/testing-realtime.d.ts +54 -0
  73. package/dist/src/testing-realtime.d.ts.map +1 -0
  74. package/dist/src/testing-realtime.js +376 -0
  75. package/dist/src/testing-realtime.js.map +1 -0
  76. package/dist/src/testing.d.ts +40 -3
  77. package/dist/src/testing.d.ts.map +1 -1
  78. package/dist/src/testing.js +368 -81
  79. package/dist/src/testing.js.map +1 -1
  80. package/package.json +23 -4
@@ -1,9 +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 { fakeOrigins } from "./api.js";
6
- 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";
7
8
  // signAssertion is the Chest-Member value the Chest's front would send for
8
9
  // that member: HS256 under the key of the token, for the tool, valid 60
9
10
  // seconds from when it is issued.
@@ -12,7 +13,7 @@ export function signAssertion(member, options = {}) {
12
13
  const tool = options.tool ?? process.env["CHEST_TOOL"];
13
14
  if (!token || !tool)
14
15
  throw new Error("signAssertion needs a token and a tool: start a fakeChest, or name them");
15
- 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)))
16
17
  throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
17
18
  const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
18
19
  // Under the label of the assertion's shape, as the Chest signs it and
@@ -20,21 +21,31 @@ export function signAssertion(member, options = {}) {
20
21
  return signClaims("Chest-Member v2", {
21
22
  iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
22
23
  given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
23
- 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 }),
24
25
  language: member.language,
25
26
  }, token);
26
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;
27
32
  // withMember is the request carrying that member's assertion, signed with
28
- // the options of signAssertion: a new Web Request, or the same Node request
29
- // 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.
30
36
  export function withMember(request, member, options = {}) {
31
37
  const assertion = signAssertion(member, options);
38
+ const ticket = ticketOf?.(member.id);
32
39
  if (request instanceof Request) {
33
40
  const headers = new Headers(request.headers);
34
41
  headers.set("Chest-Member", assertion);
42
+ if (ticket)
43
+ headers.set("Chest-Opener", ticket);
35
44
  return new Request(request, { headers });
36
45
  }
37
46
  request.headers["chest-member"] = assertion;
47
+ if (ticket)
48
+ request.headers["chest-opener"] = ticket;
38
49
  return request;
39
50
  }
40
51
  // The bounds of a Chest (chest/toolmembers, chest/toolfiles).
@@ -42,21 +53,54 @@ const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
42
53
  const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
43
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;
44
55
  // Its links and uploads: their paths on the team host, their lives in
45
- // seconds, the images it makes thumbnails of, the first bytes an upload of
46
- // 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.
47
61
  const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/", linkLife = 900, uploadLife = 900;
48
62
  const mediaPattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
49
63
  const thumbnailed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
50
64
  const starts = (...heads) => (data) => heads.some(head => data.subarray(0, head.length).equals(Buffer.from(head, "latin1")));
51
- 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 = {
52
71
  "image/jpeg": starts("\xff\xd8\xff"), "image/png": starts("\x89PNG\r\n\x1a\n"), "image/gif": starts("GIF87a", "GIF89a"),
53
- "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",
54
94
  };
55
- 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" };
56
95
  const newToken = () => randomBytes(18).toString("base64url") + "." + randomBytes(12).toString("base64url");
57
96
  // Its notifications: recipients and badges a call, text, quotas.
58
- const maxRecipients = 500, maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999;
59
- 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;
60
104
  const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
61
105
  const reordering = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
62
106
  const cleanTitle = (s) => s.replace(/[\t\r\n]/gu, " ").replace(/\p{Cc}/gu, "").replace(reordering, "").trim();
@@ -78,6 +122,19 @@ function vectorOf(text, dimensions) {
78
122
  const kept = values.slice(0, dimensions), norm = Math.hypot(...kept) || 1;
79
123
  return kept.map(x => x / norm);
80
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("");
81
138
  const fold = (s) => s.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
82
139
  function send(response, status, value, headers = {}) {
83
140
  if (value === undefined)
@@ -103,7 +160,7 @@ async function body(request, limit) {
103
160
  // chest and the modules of the SDK read is then this Chest's.
104
161
  export async function fakeChest(options = {}) {
105
162
  const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications", "ai"]);
106
- const email = capabilities.has("members.email");
163
+ const email = capabilities.has("members.email"), everyGroup = capabilities.has("members.groups");
107
164
  const tool = process.env["CHEST_TOOL"] || "tool";
108
165
  const token = randomBytes(32).toString("base64url");
109
166
  const files = new Map();
@@ -111,12 +168,29 @@ export async function fakeChest(options = {}) {
111
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() });
112
169
  }
113
170
  const receives = options.receives ?? ["member.*"];
114
- // 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.
115
181
  const erasures = new Set();
116
- 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 () => { } };
117
187
  const former = [...(options.former ?? [])];
118
188
  let window = 0, calls = 0;
119
- 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 } : {}) });
120
194
  const key = (m) => fold(m.name) + "\u0000" + m.id;
121
195
  const described = (name, f) => ({ name, type: f.type, size: f.data.byteLength, sha256: createHash("sha256").update(f.data).digest("hex"), updated: f.updated });
122
196
  async function members(request, response, url) {
@@ -137,7 +211,7 @@ export async function fakeChest(options = {}) {
137
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)
138
212
  return send(response, 400, { error: "invalid_query" });
139
213
  const shownList = chest.members
140
- .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))))
141
215
  .filter(m => after === "" || key(m) > after)
142
216
  .sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
143
217
  const page = shownList.slice(0, limit);
@@ -179,8 +253,17 @@ export async function fakeChest(options = {}) {
179
253
  const m = chest.members.find(x => x.id === id);
180
254
  return m ? send(response, 200, shown(m)) : send(response, 404, { error: "member_not_found" });
181
255
  }
182
- if (request.method === "GET" && url.pathname === "/groups")
183
- 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
+ }
184
267
  send(response, 404, { error: "not_found" });
185
268
  }
186
269
  async function filesRoute(request, response, url) {
@@ -214,19 +297,23 @@ export async function fakeChest(options = {}) {
214
297
  return send(response, 200, { url: `${chest.api}${linkPath}${token}`, expires_in: linkLife });
215
298
  }
216
299
  if (url.pathname === "/files/upload-url") {
217
- const { name, max_size: maxSize, types, expires_in: expiresIn } = command;
300
+ const { name, max_size: maxSize, types, expires_in: expiresIn, public: visitor } = command;
218
301
  const folder = typeof name === "string" && name.endsWith("/");
219
- 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))
220
303
  return send(response, 400, { error: "invalid_name" });
221
304
  if (!(types === undefined || (Array.isArray(types) && types.length <= 8 && types.every(t => typeof t === "string" && mediaPattern.test(t)))))
222
305
  return send(response, 400, { error: "invalid_type" });
223
- 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"))
224
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" });
225
312
  if ((maxSize ?? 0) > maxObject)
226
313
  return send(response, 413, { error: "too_large" });
227
314
  const token = newToken(), life = expiresIn ?? uploadLife;
228
- uploads.set(token, { name, maxSize: maxSize ?? maxObject, types: types ?? [], until: Date.now() + life * 1000 });
229
- 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 });
230
317
  }
231
318
  const from = command["from"], to = command["to"];
232
319
  if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to))
@@ -265,12 +352,14 @@ export async function fakeChest(options = {}) {
265
352
  }
266
353
  send(response, 404, { error: "not_found" });
267
354
  }
268
- // The team host's part of the files, on the fake's own origin: a link it
269
- // signed opens the content it was signed for, for its life, as long as
270
- // the file is that content — a thumbnail is the image itself, a fake does
271
- // not reduce it —; an upload token takes one file, once, within its life,
272
- // of the types it names and up to its size, whose first bytes are those of
273
- // 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:
274
363
  // the Chest's own front does that.
275
364
  const links = new Map();
276
365
  const uploads = new Map();
@@ -290,13 +379,19 @@ export async function fakeChest(options = {}) {
290
379
  uploads.delete(token);
291
380
  if (!grant || grant.until < Date.now())
292
381
  return send(response, 403, { error: "invalid_token" });
293
- const type = (request.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
294
- 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))))
295
384
  return send(response, 415, { error: "type_refused" });
296
385
  const data = await body(request, grant.maxSize);
297
386
  if (data === null)
298
387
  return send(response, 413, { error: "too_large" });
299
- 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))
300
395
  return send(response, 400, { error: "type_mismatch" });
301
396
  const name = grant.name.endsWith("/") ? grant.name + randomBytes(10).toString("hex") + (extensions[type] ?? "") : grant.name;
302
397
  const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
@@ -305,7 +400,6 @@ export async function fakeChest(options = {}) {
305
400
  files.set(name, { data: new Uint8Array(data), type, updated: new Date().toISOString() });
306
401
  send(response, 201, { name, type, size: data.length });
307
402
  }
308
- const hour = { start: 0, count: 0 }, minute = { start: 0, count: 0 }, days = new Map();
309
403
  const live = (w, span, now) => w !== undefined && w.count > 0 && now - w.start < span;
310
404
  const wait = (w, span, now) => ({ "Retry-After": String(Math.max(1, Math.ceil((w.start + span - now) / 1000))) });
311
405
  const count = (w, span, now, n) => {
@@ -313,6 +407,18 @@ export async function fakeChest(options = {}) {
313
407
  [w.start, w.count] = [now, 0];
314
408
  w.count += n;
315
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
+ };
316
422
  const access = (id) => chest.members.some(m => m.id === id);
317
423
  // drop removes kept notifications in place: a test may hold the list.
318
424
  const drop = (gone) => {
@@ -322,7 +428,7 @@ export async function fakeChest(options = {}) {
322
428
  };
323
429
  // recipients reads 1 to 500 member identifiers, each once.
324
430
  function recipients(value) {
325
- if (!Array.isArray(value) || value.length < 1 || value.length > maxRecipients)
431
+ if (!Array.isArray(value) || value.length < 1)
326
432
  return { error: "invalid_body" };
327
433
  if (!value.every(v => typeof v === "string" && memberIdPattern.test(v)))
328
434
  return { error: "invalid_id" };
@@ -332,9 +438,9 @@ export async function fakeChest(options = {}) {
332
438
  if (!capabilities.has("notifications"))
333
439
  return send(response, 403, { error: "capability_not_granted" });
334
440
  const badge = request.method === "PUT" && url.pathname.startsWith("/badges/");
335
- 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)))
336
442
  return send(response, 404, { error: "not_found" });
337
- const raw = await body(request, 64 << 10);
443
+ const raw = await body(request, textBytes + memberBytes * chest.members.length);
338
444
  let command = null;
339
445
  try {
340
446
  const value = JSON.parse(raw?.toString() ?? "");
@@ -358,7 +464,7 @@ export async function fakeChest(options = {}) {
358
464
  }
359
465
  else {
360
466
  const list = command?.["badges"];
361
- 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")))
362
468
  return send(response, 400, { error: "invalid_body" });
363
469
  writes = list;
364
470
  if (!writes.every(b => typeof b.member === "string" && memberIdPattern.test(b.member)))
@@ -368,9 +474,6 @@ export async function fakeChest(options = {}) {
368
474
  return send(response, 400, { error: "invalid_count" });
369
475
  if (new Set(writes.map(b => b.member)).size !== writes.length)
370
476
  return send(response, 400, { error: "invalid_body" });
371
- if (live(minute, 60_000, now) && minute.count + writes.length > badgesPerMinute)
372
- return send(response, 429, { error: "quota_exceeded" }, wait(minute, 60_000, now));
373
- count(minute, 60_000, now, writes.length);
374
477
  const answer = { set: [], skipped: [] };
375
478
  for (const b of writes) {
376
479
  if (!access(b.member)) {
@@ -397,36 +500,78 @@ export async function fakeChest(options = {}) {
397
500
  drop(n => n.key === key && (named === null || named.includes(n.member)));
398
501
  return send(response, 204);
399
502
  }
400
- 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"))
401
505
  return send(response, 400, { error: "invalid_body" });
402
- const { title, body: text, path, key } = command;
403
- const ids = recipients(command["members"]);
404
- if (!Array.isArray(ids))
405
- return send(response, 400, ids);
406
- if (typeof title !== "string" || [...title].length < 1 || [...title].length > maxTitle || cleanTitle(title) === "")
407
- return send(response, 400, { error: "invalid_title" });
408
- if (text !== undefined && (typeof text !== "string" || [...text].length > maxText))
409
- 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);
410
541
  if (path !== undefined && !isPath(path))
411
542
  return send(response, 400, { error: "invalid_path" });
412
543
  if (key !== undefined && (typeof key !== "string" || !keyPattern.test(key)))
413
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
+ }
414
558
  const kept = ids.filter(access);
415
- if (live(hour, 3_600_000, now) && hour.count + kept.length > recipientsPerHour)
416
- return send(response, 429, { error: "quota_exceeded" }, wait(hour, 3_600_000, now));
417
- const full = kept.map(id => days.get(id)).filter((w) => live(w, 86_400_000, now) && w.count >= itemsPerDay);
418
- if (full.length > 0)
419
- return send(response, 429, { error: "quota_exceeded" }, wait(full.reduce((a, b) => a.start > b.start ? a : b), 86_400_000, now));
420
- count(hour, 3_600_000, now, kept.length);
421
- const cleaned = typeof text === "string" ? cleanText(text) : "";
422
559
  for (const id of kept) {
423
- if (!days.has(id))
424
- days.set(id, { start: 0, count: 0 });
425
- count(days.get(id), 86_400_000, now, 1);
426
- 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))
427
563
  drop(n => n.member === id && n.key === key);
428
- 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);
429
572
  }
573
+ if (broadcast)
574
+ return send(response, 204);
430
575
  send(response, 200, { delivered: kept, skipped: ids.filter(id => !access(id)) });
431
576
  }
432
577
  // Its AI: the declared aliases, the spending this month, the requests this
@@ -542,7 +687,83 @@ export async function fakeChest(options = {}) {
542
687
  response.write(part);
543
688
  response.end();
544
689
  }
545
- // 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).
546
767
  async function erasuresRoute(request, response, url) {
547
768
  if (!receives.includes("member.*"))
548
769
  return send(response, 403, { error: "capability_not_granted" });
@@ -558,14 +779,70 @@ export async function fakeChest(options = {}) {
558
779
  chest.acknowledged.push(id);
559
780
  send(response, 204);
560
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
+ }
561
835
  const server = createServer((request, response) => {
562
836
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
563
- const route = url.pathname.startsWith("/_chest/files/") ? front
564
- : url.pathname.startsWith("/erasures/") ? erasuresRoute
565
- : url.pathname.startsWith("/ai/") ? aiRoute
566
- : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
567
- : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
568
- : 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;
569
846
  if (!route)
570
847
  return send(response, 404, { error: "not_found" });
571
848
  route(request, response, url).catch(() => { if (!response.headersSent)
@@ -573,12 +850,19 @@ export async function fakeChest(options = {}) {
573
850
  else
574
851
  response.destroy(); });
575
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
+ });
576
860
  await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
577
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]]));
578
862
  chest.api = "http://127.0.0.1:" + server.address().port;
579
- // Its links are taken by the files module of this process, for as long as
580
- // it runs (api.ts, fakeOrigins).
581
- fakeOrigins.add(chest.api);
863
+ // Its links are on its own origin, the CHEST_API it sets: the files
864
+ // module takes them there (api.ts, chestLink), in this process or in a
865
+ // tool it starts with this environment.
582
866
  const publicUrl = options.chest?.publicUrl === undefined ? `https://${tool}.chest.test` : options.chest.publicUrl;
583
867
  Object.assign(process.env, { CHEST_API: chest.api, CHEST_TOKEN: token, CHEST_TOOL: tool, CHEST_ORGANIZATION: options.chest?.organization ?? "Test organization", CHEST_TIME_ZONE: options.chest?.timeZone ?? "UTC", CHEST_LANGUAGE: options.chest?.language ?? "en", CHEST_CURRENCY: options.chest?.currency ?? "EUR", CHEST_TEAM_URL: options.chest?.teamUrl ?? `https://${tool}-chest.chest.test` });
584
868
  if (publicUrl === null)
@@ -594,13 +878,15 @@ export async function fakeChest(options = {}) {
594
878
  await answer.body?.cancel();
595
879
  return answer.status;
596
880
  };
597
- const newId = (prefix) => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
598
- chest.emit = async (event, to) => {
599
- const id = event.id ?? newId("evt_");
600
- 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
+ }
601
887
  if (event.type === "member.erased")
602
888
  erasures.add(event.data.erasure);
603
- 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);
604
890
  };
605
891
  chest.run = async (name, to, run = {}) => {
606
892
  const id = run.id ?? newId("run_");
@@ -608,7 +894,8 @@ export async function fakeChest(options = {}) {
608
894
  return post("/chest-schedules", scheduleChannel, id, body, to);
609
895
  };
610
896
  chest.close = async () => {
611
- fakeOrigins.delete(chest.api);
897
+ realtime.close();
898
+ ticketOf = null;
612
899
  server.closeAllConnections();
613
900
  await new Promise(resolve => server.close(() => resolve()));
614
901
  for (const [name, value] of Object.entries(saved)) {