@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.
- package/README.md +508 -80
- package/client/index.ts +11 -7
- package/client/src/api.ts +19 -18
- package/client/src/database.ts +6 -1
- 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 +391 -94
- 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 -1
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +18 -16
- package/dist/src/api.js.map +1 -1
- package/dist/src/database.d.ts.map +1 -1
- package/dist/src/database.js +5 -1
- package/dist/src/database.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 +368 -81
- package/dist/src/testing.js.map +1 -1
- package/package.json +23 -4
package/dist/src/testing.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
6
|
-
import {
|
|
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
|
|
29
|
-
//
|
|
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
|
|
46
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
|
269
|
-
// signed opens the content it was signed for, for its life, as
|
|
270
|
-
// the file is that content — a thumbnail is the image itself, a
|
|
271
|
-
// not reduce it —; an upload token takes one file, once, within
|
|
272
|
-
//
|
|
273
|
-
//
|
|
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
|
-
|
|
294
|
-
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))))
|
|
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 (
|
|
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
|
|
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" &&
|
|
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,
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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("/
|
|
564
|
-
: url.pathname.startsWith("/
|
|
565
|
-
: url.pathname.startsWith("/
|
|
566
|
-
: url.pathname === "/
|
|
567
|
-
: url.pathname
|
|
568
|
-
: 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;
|
|
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
|
|
580
|
-
//
|
|
581
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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,
|
|
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
|
-
|
|
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)) {
|