@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.
- package/README.md +500 -73
- package/client/index.ts +11 -7
- package/client/src/api.ts +15 -8
- package/client/src/errors.ts +50 -0
- package/client/src/eventrules.ts +117 -0
- package/client/src/events.ts +181 -39
- package/client/src/files.ts +37 -7
- package/client/src/member.ts +15 -9
- package/client/src/members.ts +34 -16
- package/client/src/notifications.ts +84 -27
- package/client/src/realtime-client.ts +516 -0
- package/client/src/realtime.ts +118 -0
- package/client/src/sealed.ts +158 -0
- package/client/src/signed.ts +8 -4
- package/client/src/testing-realtime.ts +361 -0
- package/client/src/testing.ts +388 -89
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/src/api.d.ts +2 -0
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +14 -7
- package/dist/src/api.js.map +1 -1
- package/dist/src/errors.d.ts +18 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +44 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/eventrules.d.ts +23 -0
- package/dist/src/eventrules.d.ts.map +1 -0
- package/dist/src/eventrules.js +107 -0
- package/dist/src/eventrules.js.map +1 -0
- package/dist/src/events.d.ts +31 -6
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +130 -26
- package/dist/src/events.js.map +1 -1
- package/dist/src/files.d.ts +1 -0
- package/dist/src/files.d.ts.map +1 -1
- package/dist/src/files.js +34 -3
- package/dist/src/files.js.map +1 -1
- package/dist/src/member.d.ts +1 -1
- package/dist/src/member.d.ts.map +1 -1
- package/dist/src/member.js +9 -6
- package/dist/src/member.js.map +1 -1
- package/dist/src/members.d.ts +9 -2
- package/dist/src/members.d.ts.map +1 -1
- package/dist/src/members.js +29 -13
- package/dist/src/members.js.map +1 -1
- package/dist/src/notifications.d.ts +12 -1
- package/dist/src/notifications.d.ts.map +1 -1
- package/dist/src/notifications.js +60 -19
- package/dist/src/notifications.js.map +1 -1
- package/dist/src/realtime-client.d.ts +45 -0
- package/dist/src/realtime-client.d.ts.map +1 -0
- package/dist/src/realtime-client.js +453 -0
- package/dist/src/realtime-client.js.map +1 -0
- package/dist/src/realtime.d.ts +22 -0
- package/dist/src/realtime.d.ts.map +1 -0
- package/dist/src/realtime.js +99 -0
- package/dist/src/realtime.js.map +1 -0
- package/dist/src/sealed.d.ts +20 -0
- package/dist/src/sealed.d.ts.map +1 -0
- package/dist/src/sealed.js +121 -0
- package/dist/src/sealed.js.map +1 -0
- package/dist/src/signed.d.ts.map +1 -1
- package/dist/src/signed.js +6 -2
- package/dist/src/signed.js.map +1 -1
- package/dist/src/testing-realtime.d.ts +54 -0
- package/dist/src/testing-realtime.d.ts.map +1 -0
- package/dist/src/testing-realtime.js +376 -0
- package/dist/src/testing-realtime.js.map +1 -0
- package/dist/src/testing.d.ts +40 -3
- package/dist/src/testing.d.ts.map +1 -1
- package/dist/src/testing.js +365 -76
- package/dist/src/testing.js.map +1 -1
- package/package.json +23 -4
package/dist/src/testing.js
CHANGED
|
@@ -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 {
|
|
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
|
|
28
|
-
//
|
|
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
|
|
45
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
|
268
|
-
// signed opens the content it was signed for, for its life, as
|
|
269
|
-
// the file is that content — a thumbnail is the image itself, a
|
|
270
|
-
// not reduce it —; an upload token takes one file, once, within
|
|
271
|
-
//
|
|
272
|
-
//
|
|
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
|
-
|
|
293
|
-
if (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !grant.types
|
|
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 (
|
|
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
|
|
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" &&
|
|
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,
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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("/
|
|
563
|
-
: url.pathname.startsWith("/
|
|
564
|
-
: url.pathname.startsWith("/
|
|
565
|
-
: url.pathname === "/
|
|
566
|
-
: url.pathname
|
|
567
|
-
: url.pathname
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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,
|
|
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)) {
|