@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/client/src/testing.ts
CHANGED
|
@@ -1,34 +1,51 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
3
|
import type { AddressInfo } from "node:net";
|
|
4
|
+
import type { Duplex } from "node:stream";
|
|
4
5
|
import type { Alias, Provider } from "./ai.js";
|
|
5
6
|
import type { AiUnavailableReason } from "./errors.js";
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
7
|
+
import { clockSkew, eventWindow, fieldNamePattern, fieldOf, instantOf, isAudience, isDeclaredData, isToolEventType, itemIdPattern, maxData, rolePattern } from "./eventrules.js";
|
|
8
|
+
import type { ChestEvent, EmitAudience, ToolEvent } from "./events.js";
|
|
9
|
+
import { groupIdPattern, languagePattern, memberIdPattern, type Member } from "./member.js";
|
|
8
10
|
import { forget } from "./members.js";
|
|
9
|
-
import { eventChannel, scheduleChannel, sign, signClaims, type Channel } from "./signed.js";
|
|
11
|
+
import { eventChannel, json, object, scheduleChannel, sign, signClaims, type Channel } from "./signed.js";
|
|
12
|
+
import { fakeRealtime, type FakeRealtime, type FakeRealtimeOptions } from "./testing-realtime.js";
|
|
13
|
+
|
|
14
|
+
export type { FakeChannelRule, FakeFeed, FakePublished, FakeRealtime, FakeRealtimeOptions, FakeSent } from "./testing-realtime.js";
|
|
10
15
|
|
|
11
16
|
// For a tool's own tests, never imported by its production code: a member's
|
|
12
17
|
// assertion signed as the Chest signs it, and a Chest's API in the test's
|
|
13
18
|
// process that answers members, groups, files (stat, move, links and
|
|
14
|
-
// uploads
|
|
15
|
-
//
|
|
19
|
+
// uploads — its members' and its visitors' —, which it serves and takes
|
|
20
|
+
// itself as the team host and the public part would, on its own origin),
|
|
21
|
+
// sealed values (sealed and opened under a key of its own,
|
|
22
|
+
// for the members and roles it keeps; withMember carries the member's
|
|
23
|
+
// ticket), badges, notifications and broadcasts, AI (chat,
|
|
16
24
|
// streamed or not, embeddings, models, usage: deterministic answers, no
|
|
17
|
-
// provider)
|
|
18
|
-
//
|
|
19
|
-
// the tool
|
|
25
|
+
// provider), the acknowledgment of an erasure, the events the tool emits
|
|
26
|
+
// (checked against the "emits" of its chest.json, as the Chest checks them,
|
|
27
|
+
// and kept in order) and realtime (the tool's publishes and sends, and the
|
|
28
|
+
// Chest's side of its pages, which the browser client connects to) with the
|
|
29
|
+
// Chest's bounds, quotas and errors; and that delivers an event — of the
|
|
30
|
+
// members' lifecycle, or of another tool — or a run of a schedule to the
|
|
31
|
+
// tool, signed as the Chest signs them.
|
|
20
32
|
//
|
|
21
33
|
// import { fakeChest, withMember } from "@argentic/chest-sdk/testing";
|
|
22
|
-
// const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications"] });
|
|
34
|
+
// const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications"], emits: { "task.done": { description: "A task is done", data: { task: "id" } } } });
|
|
23
35
|
// const response = await app(withMember(new Request("http://tool/chest"), camille));
|
|
24
36
|
// assert.equal(chest.notifications[0]?.member, camille.id);
|
|
25
|
-
// assert.equal(await chest.
|
|
37
|
+
// assert.equal(await chest.deliver({ type: "access.revoked", data: { id: camille.id } }, request => app(request)), 204);
|
|
38
|
+
// assert.equal(await chest.deliver({ type: "quote.accepted", source: "quotes", data: { quote: "q-1" } }, request => app(request)), 204);
|
|
39
|
+
// assert.equal(chest.emitted[0]?.type, "task.done");
|
|
26
40
|
// assert.equal(await chest.run("morning", request => app(request)), 204);
|
|
27
41
|
// await chest.close();
|
|
28
42
|
|
|
29
|
-
// A group as a fake Chest keeps it: its identifier, its name,
|
|
30
|
-
// identifiers of
|
|
31
|
-
|
|
43
|
+
// A group as a fake Chest keeps it: its identifier, its name, the
|
|
44
|
+
// identifiers of its members, and whether it gives the tool (true by
|
|
45
|
+
// default). One that does not is seen — in groups and in each member's
|
|
46
|
+
// groups — only with the capability members.groups; a group's members are
|
|
47
|
+
// told among those who have the tool.
|
|
48
|
+
export type FakeGroup = { id: string; name: string; members: string[]; grants?: boolean };
|
|
32
49
|
// A file as a fake Chest keeps it.
|
|
33
50
|
export type FakeFile = { data: Uint8Array; type: string; updated: string };
|
|
34
51
|
// Someone the tool had who no longer has it: a member of the Chest without
|
|
@@ -36,9 +53,12 @@ export type FakeFile = { data: Uint8Array; type: string; updated: string };
|
|
|
36
53
|
// was erased (their name gone).
|
|
37
54
|
export type FakeFormer = { id: string; name?: string; status?: "no_access" | "former" | "erased" };
|
|
38
55
|
// A notification as a fake Chest keeps it: the member it went to, its text
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
56
|
+
// in that member's language — the translation of theirs, the tool's own
|
|
57
|
+
// otherwise — cleaned as the Chest cleans it, its path (/chest when not
|
|
58
|
+
// said) and its key. A broadcast keeps one for each member it reached.
|
|
59
|
+
// Beyond the pace, a member's notices are folded into one item, grouped
|
|
60
|
+
// counting them, its text and path the latest's.
|
|
61
|
+
export type FakeNotification = { member: string; title: string; body?: string; path: string; key?: string; grouped?: number };
|
|
42
62
|
// An alias a fake Chest maps: the model behind it, its provider (openrouter
|
|
43
63
|
// by default) and its prices in US dollars per million tokens (1 and 2 by
|
|
44
64
|
// default).
|
|
@@ -58,30 +78,60 @@ export type FakeAi = { models?: FakeAiModel[]; reply?: (request: Record<string,
|
|
|
58
78
|
// sent (null for a GET).
|
|
59
79
|
export type FakeAiCall = { path: string; body: unknown };
|
|
60
80
|
|
|
81
|
+
// An open of sealed values the tool asked of a fake Chest, as the Chest
|
|
82
|
+
// journals it: the member on whose behalf, how many opened, how many
|
|
83
|
+
// refused — never a value.
|
|
84
|
+
export type FakeOpen = { member: string; opened: number; refused: number };
|
|
85
|
+
|
|
61
86
|
// What a fake Chest is given: the members who have the tool, those it had
|
|
62
87
|
// who no longer have it (FakeFormer), its groups, the
|
|
63
88
|
// capabilities its version holds (a capability left out answers 403;
|
|
64
89
|
// members, files, notifications and ai by default, members.email to read the
|
|
65
|
-
// addresses
|
|
66
|
-
//
|
|
90
|
+
// addresses, members.groups to see every group, sealed to seal and open
|
|
91
|
+
// values), the roles the tool declares (any role of the grammar a value is
|
|
92
|
+
// sealed for, when not said), the events it receives (["member.*"] by default, [] to answer
|
|
93
|
+
// an acknowledgment 403), the events the tool emits (chest.json "emits":
|
|
94
|
+
// none by default, which answers an emit 403), the files it keeps, its AI, and what the Chest is
|
|
67
95
|
// (the chest module: "Test organization", UTC, English and euros by default;
|
|
68
96
|
// the tool at https://<tool>-chest.chest.test, its public part at
|
|
69
|
-
// https://<tool>.chest.test)
|
|
97
|
+
// https://<tool>.chest.test), and its realtime: the channels and feeds of
|
|
98
|
+
// its chest.json, and who is in its membership tables.
|
|
70
99
|
export type FakeChestOptions = {
|
|
71
100
|
members?: Member[];
|
|
72
101
|
former?: FakeFormer[];
|
|
73
102
|
groups?: FakeGroup[];
|
|
74
103
|
capabilities?: string[];
|
|
104
|
+
roles?: string[];
|
|
75
105
|
receives?: string[];
|
|
106
|
+
emits?: Record<string, FakeEmits>;
|
|
76
107
|
files?: Record<string, { data: Uint8Array | string; type?: string }>;
|
|
77
108
|
ai?: FakeAi;
|
|
109
|
+
realtime?: FakeRealtimeOptions;
|
|
78
110
|
chest?: { organization?: string; timeZone?: string; language?: string; currency?: string; teamUrl?: string; publicUrl?: string | null };
|
|
79
111
|
};
|
|
80
112
|
|
|
81
|
-
//
|
|
113
|
+
// A type a tool emits, as its chest.json declares it in "emits": what
|
|
114
|
+
// happened, in a sentence of 1 to 80 characters, and each field of its data
|
|
115
|
+
// with its kind (id, text, number, boolean, time, date, member, members; a
|
|
116
|
+
// trailing ? for an optional one).
|
|
117
|
+
export type FakeEmits = { description: string; data: Record<string, string> };
|
|
118
|
+
|
|
119
|
+
// An event for deliver: of the members' lifecycle, its type and data; or of
|
|
120
|
+
// another tool, told apart by its source (the publisher's name), its type,
|
|
121
|
+
// data, subject (none by default) and audience ("all" by default, or the
|
|
122
|
+
// members of this tool who may see it). Its id (a new evt_… by default) and
|
|
82
123
|
// when it happened (now by default) may be named, to deliver the same event
|
|
83
|
-
// twice.
|
|
84
|
-
|
|
124
|
+
// twice. It is signed as given: an envelope the Chest would never send
|
|
125
|
+
// makes handle() refuse it.
|
|
126
|
+
export type FakeEvent =
|
|
127
|
+
| { [K in ChestEvent["type"]]: { type: K; data: Extract<ChestEvent, { type: K }>["data"]; id?: string; occurredAt?: string } }[ChestEvent["type"]]
|
|
128
|
+
| { type: string; source: string; data: Record<string, unknown>; audience?: ToolEvent["audience"]; subject?: string; id?: string; occurredAt?: string };
|
|
129
|
+
|
|
130
|
+
// An event the tool emitted, as the fake Chest took it: its id, type and
|
|
131
|
+
// data, its subject and key when given, when it happened (the tool's time,
|
|
132
|
+
// or the Chest's), its audience when given, and the event whose handler
|
|
133
|
+
// emitted it (cause).
|
|
134
|
+
export type FakeEmitted = { id: string; type: string; data: Record<string, unknown>; subject?: string; key?: string; occurredAt: string; audience?: EmitAudience; cause?: string };
|
|
85
135
|
|
|
86
136
|
// A run for run: its id (a new run_… by default: name it to deliver the same
|
|
87
137
|
// run twice), the time it stands for (now by default) and its attempt (1 by
|
|
@@ -93,7 +143,10 @@ export type FakeRun = { id?: string; scheduledAt?: string; attempt?: number };
|
|
|
93
143
|
// the tool it set in the environment, what it keeps (members, groups and files a test
|
|
94
144
|
// changes or reads; the notifications the tool sent, in the order sent, a
|
|
95
145
|
// replaced one last; each member's badge; the erasures the tool
|
|
96
|
-
// acknowledged; its calls to AI, in order
|
|
146
|
+
// acknowledged; its calls to AI, in order; its opens of sealed values, in
|
|
147
|
+
// order; the events it emitted, in order; its realtime: what the tool
|
|
148
|
+
// published and sent, where a member's page connects, a feed's row
|
|
149
|
+
// committed, a membership row removed), deliver, which delivers an event to the tool — POST
|
|
97
150
|
// /chest-events of its address, or a handler of Web Requests — and says the
|
|
98
151
|
// status it answered, run, which delivers a run of a schedule the same way
|
|
99
152
|
// on /chest-schedules, and close, which stops it and restores the
|
|
@@ -110,7 +163,10 @@ export type FakeChest = {
|
|
|
110
163
|
badges: Map<string, number>;
|
|
111
164
|
acknowledged: string[];
|
|
112
165
|
ai: FakeAiCall[];
|
|
113
|
-
|
|
166
|
+
opens: FakeOpen[];
|
|
167
|
+
realtime: FakeRealtime;
|
|
168
|
+
emitted: FakeEmitted[];
|
|
169
|
+
deliver(event: FakeEvent, to: string | ((request: Request) => Response | Promise<Response>)): Promise<number>;
|
|
114
170
|
run(name: string, to: string | ((request: Request) => Response | Promise<Response>), run?: FakeRun): Promise<number>;
|
|
115
171
|
close(): Promise<void>;
|
|
116
172
|
};
|
|
@@ -129,30 +185,39 @@ export function signAssertion(member: Member, options: AssertionOptions = {}): s
|
|
|
129
185
|
const token = options.token ?? process.env["CHEST_TOKEN"];
|
|
130
186
|
const tool = options.tool ?? process.env["CHEST_TOOL"];
|
|
131
187
|
if (!token || !tool) throw new Error("signAssertion needs a token and a tool: start a fakeChest, or name them");
|
|
132
|
-
if (!memberIdPattern.test(member.id) || !member.groups.every(g => groupIdPattern.test(g))) throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
|
|
188
|
+
if (!memberIdPattern.test(member.id) || !(member.groups ?? []).every(g => groupIdPattern.test(g))) throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
|
|
133
189
|
const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
134
190
|
// Under the label of the assertion's shape, as the Chest signs it and
|
|
135
191
|
// member() reads it.
|
|
136
192
|
return signClaims("Chest-Member v2", {
|
|
137
193
|
iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
|
|
138
194
|
given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
|
|
139
|
-
admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, time_zone: member.timeZone, ...(member.email === undefined ? {} : { email: member.email }),
|
|
195
|
+
admin: member.isAdmin, builder: member.isBuilder, ...(member.groups === null ? { groups_overage: true } : { groups: member.groups }), time_zone: member.timeZone, ...(member.email === undefined ? {} : { email: member.email }),
|
|
140
196
|
language: member.language,
|
|
141
197
|
}, token);
|
|
142
198
|
}
|
|
143
199
|
|
|
144
200
|
|
|
201
|
+
// ticketOf makes the ticket of a member's request to open sealed values
|
|
202
|
+
// (Chest-Opener), under the key of the fake Chest running; null when none
|
|
203
|
+
// runs. The tool never holds that key: only the Chest makes tickets.
|
|
204
|
+
let ticketOf: ((id: string) => string) | null = null;
|
|
205
|
+
|
|
145
206
|
// withMember is the request carrying that member's assertion, signed with
|
|
146
|
-
// the options of signAssertion
|
|
147
|
-
//
|
|
207
|
+
// the options of signAssertion, and — while a fake Chest runs — the
|
|
208
|
+
// member's ticket to open sealed values: a new Web Request, or the same
|
|
209
|
+
// Node request with its headers set.
|
|
148
210
|
export function withMember<R extends Request | IncomingMessage>(request: R, member: Member, options: AssertionOptions = {}): R {
|
|
149
211
|
const assertion = signAssertion(member, options);
|
|
212
|
+
const ticket = ticketOf?.(member.id);
|
|
150
213
|
if (request instanceof Request) {
|
|
151
214
|
const headers = new Headers(request.headers);
|
|
152
215
|
headers.set("Chest-Member", assertion);
|
|
216
|
+
if (ticket) headers.set("Chest-Opener", ticket);
|
|
153
217
|
return new Request(request, { headers }) as R;
|
|
154
218
|
}
|
|
155
219
|
(request as IncomingMessage).headers["chest-member"] = assertion;
|
|
220
|
+
if (ticket) (request as IncomingMessage).headers["chest-opener"] = ticket;
|
|
156
221
|
return request;
|
|
157
222
|
}
|
|
158
223
|
|
|
@@ -161,21 +226,54 @@ const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
|
|
|
161
226
|
const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
|
|
162
227
|
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}(\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}){0,7}$/u;
|
|
163
228
|
// Its links and uploads: their paths on the team host, their lives in
|
|
164
|
-
// seconds, the images it makes thumbnails of, the
|
|
165
|
-
//
|
|
229
|
+
// seconds, the images it makes thumbnails of, the types it recognises by
|
|
230
|
+
// their content — a member's upload of one must hold it, a visitor's is of
|
|
231
|
+
// the one its content is —, the endings of the names it chooses
|
|
232
|
+
// (chest/toolfiles). An office document is told by the names of its parts,
|
|
233
|
+
// read in its bytes rather than in its archive's directory.
|
|
166
234
|
const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/", linkLife = 900, uploadLife = 900;
|
|
167
235
|
const mediaPattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
|
|
168
236
|
const thumbnailed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
169
237
|
const starts = (...heads: string[]) => (data: Buffer): boolean => heads.some(head => data.subarray(0, head.length).equals(Buffer.from(head, "latin1")));
|
|
170
|
-
const
|
|
238
|
+
const text = (data: Buffer, from: number, to: number): string => data.subarray(from, to).toString("latin1");
|
|
239
|
+
const brands = (...said: string[]) => (data: Buffer): boolean => text(data, 4, 8) === "ftyp" && Array.from({ length: Math.max(0, Math.min(data.readUInt32BE(0), data.length) - 8) / 4 }, (_, i) => text(data, 8 + 4 * i, 12 + 4 * i)).some((brand, i) => i !== 1 && said.includes(brand));
|
|
240
|
+
const zip = starts("PK\x03\x04", "PK\x05\x06");
|
|
241
|
+
const office = (main: string) => (data: Buffer): boolean => zip(data) && data.includes("[Content_Types].xml") && data.includes(main);
|
|
242
|
+
const openDocument = (type: string) => (data: Buffer): boolean => zip(data) && text(data, 30, 38) === "mimetype" && text(data, 38, 38 + type.length) === type;
|
|
243
|
+
const recognisers: Record<string, (data: Buffer) => boolean> = {
|
|
171
244
|
"image/jpeg": starts("\xff\xd8\xff"), "image/png": starts("\x89PNG\r\n\x1a\n"), "image/gif": starts("GIF87a", "GIF89a"),
|
|
172
|
-
"image/webp": data => data
|
|
245
|
+
"image/webp": data => text(data, 0, 4) === "RIFF" && text(data, 8, 12) === "WEBP", "image/avif": brands("avif", "avis"), "image/heic": brands("heic", "heix", "heim", "heis", "hevc", "hevx"),
|
|
246
|
+
"image/bmp": starts("BM"), "image/tiff": starts("II*\x00", "MM\x00*"), "application/pdf": starts("%PDF-"),
|
|
247
|
+
"application/zip": zip, "application/gzip": starts("\x1f\x8b"), "application/x-7z-compressed": starts("7z\xbc\xaf\x27\x1c"), "application/vnd.rar": starts("Rar!\x1a\x07"),
|
|
248
|
+
"application/x-tar": data => text(data, 257, 262) === "ustar", "application/x-bzip2": starts("BZh"), "application/x-xz": starts("\xfd7zXZ\x00"), "application/vnd.ms-cab-compressed": starts("MSCF"),
|
|
249
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": office("word/document.xml"),
|
|
250
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": office("xl/workbook.xml"),
|
|
251
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": office("ppt/presentation.xml"),
|
|
252
|
+
"application/vnd.oasis.opendocument.text": openDocument("application/vnd.oasis.opendocument.text"),
|
|
253
|
+
"application/vnd.oasis.opendocument.spreadsheet": openDocument("application/vnd.oasis.opendocument.spreadsheet"),
|
|
254
|
+
"application/vnd.oasis.opendocument.presentation": openDocument("application/vnd.oasis.opendocument.presentation"),
|
|
255
|
+
};
|
|
256
|
+
// The types recognised, an office document before the archive it also is.
|
|
257
|
+
const precise = (type: string): boolean => type.includes("officedocument") || type.includes("opendocument");
|
|
258
|
+
const recognised = Object.keys(recognisers).sort((a, b) => Number(precise(b)) - Number(precise(a)) || (a < b ? -1 : a > b ? 1 : 0));
|
|
259
|
+
const accepted = (types: string[], type: string): boolean => types.some(t => t === type || (t.endsWith("/*") && type.startsWith(t.slice(0, -1))));
|
|
260
|
+
const extensions: Record<string, string> = {
|
|
261
|
+
"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", "image/heic": ".heic", "image/bmp": ".bmp", "image/tiff": ".tiff",
|
|
262
|
+
"application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json", "application/zip": ".zip",
|
|
263
|
+
"application/gzip": ".gz", "application/x-7z-compressed": ".7z", "application/vnd.rar": ".rar", "application/x-tar": ".tar", "application/x-bzip2": ".bz2", "application/x-xz": ".xz", "application/vnd.ms-cab-compressed": ".cab",
|
|
264
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
265
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", "application/vnd.oasis.opendocument.text": ".odt",
|
|
266
|
+
"application/vnd.oasis.opendocument.spreadsheet": ".ods", "application/vnd.oasis.opendocument.presentation": ".odp", "video/mp4": ".mp4", "audio/mpeg": ".mp3",
|
|
173
267
|
};
|
|
174
|
-
const extensions: Record<string, string> = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", "image/heic": ".heic", "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json", "application/zip": ".zip", "video/mp4": ".mp4", "audio/mpeg": ".mp3" };
|
|
175
268
|
const newToken = (): string => randomBytes(18).toString("base64url") + "." + randomBytes(12).toString("base64url");
|
|
176
269
|
// Its notifications: recipients and badges a call, text, quotas.
|
|
177
|
-
|
|
178
|
-
|
|
270
|
+
// No count bounds a call but the team's size: a body takes the texts and an
|
|
271
|
+
// entry for each member who has the tool. Nothing is refused for its pace:
|
|
272
|
+
// ten items at once to a member, one more every six minutes; beyond, the
|
|
273
|
+
// notices are folded into the one grouped item of that member.
|
|
274
|
+
const maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999, maxRoles = 16;
|
|
275
|
+
const textBytes = 64 << 10, memberBytes = 64;
|
|
276
|
+
const paceBurst = 10, paceEvery = 6 * 60_000;
|
|
179
277
|
const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
|
|
180
278
|
const reordering = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
|
|
181
279
|
const cleanTitle = (s: string): string => s.replace(/[\t\r\n]/gu, " ").replace(/\p{Cc}/gu, "").replace(reordering, "").trim();
|
|
@@ -198,6 +296,20 @@ function vectorOf(text: string, dimensions: number): number[] {
|
|
|
198
296
|
return kept.map(x => x / norm);
|
|
199
297
|
}
|
|
200
298
|
|
|
299
|
+
// Its sealed values: a call's body, one value, a context, a ticket's life
|
|
300
|
+
// in seconds (chest/toolseal).
|
|
301
|
+
const sealedBody = 4 << 20, sealedValue = 512 << 10, sealedContext = 256, ticketLife = 60;
|
|
302
|
+
const sealedText = /^chest:sealed:1:((?:[a-z][a-z0-9-]{0,47}(?:,[a-z][a-z0-9-]{0,47}){0,15})?):([A-Za-z0-9_-]+)$/u;
|
|
303
|
+
// Its events: the keys of an emit, and its envelope beyond the data and
|
|
304
|
+
// the identifiers of its audience.
|
|
305
|
+
const emitKeys = ["type", "data", "subject", "key", "occurredAt", "audience", "cause"], envelopeBytes = 4096, idBytes = 64;
|
|
306
|
+
// canonical is a value's JSON with the keys of each object in order: the
|
|
307
|
+
// content an idempotency key stands for, whatever order the tool wrote.
|
|
308
|
+
const canonical = (value: unknown): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
|
|
309
|
+
: value !== null && typeof value === "object" ? `{${Object.keys(value).sort().map(k => JSON.stringify(k) + ":" + canonical((value as Record<string, unknown>)[k])).join(",")}}`
|
|
310
|
+
: JSON.stringify(value) ?? "null";
|
|
311
|
+
const newId = (prefix: string): string => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
|
|
312
|
+
|
|
201
313
|
const fold = (s: string): string => s.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
|
|
202
314
|
|
|
203
315
|
function send(response: ServerResponse, status: number, value?: unknown, headers: Record<string, string> = {}): void {
|
|
@@ -223,7 +335,7 @@ async function body(request: IncomingMessage, limit: number): Promise<Buffer | n
|
|
|
223
335
|
// chest and the modules of the SDK read is then this Chest's.
|
|
224
336
|
export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChest> {
|
|
225
337
|
const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications", "ai"]);
|
|
226
|
-
const email = capabilities.has("members.email");
|
|
338
|
+
const email = capabilities.has("members.email"), everyGroup = capabilities.has("members.groups");
|
|
227
339
|
const tool = process.env["CHEST_TOOL"] || "tool";
|
|
228
340
|
const token = randomBytes(32).toString("base64url");
|
|
229
341
|
const files = new Map<string, FakeFile>();
|
|
@@ -231,12 +343,29 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
231
343
|
files.set(name, { data: typeof file.data === "string" ? new TextEncoder().encode(file.data) : file.data, type: file.type ?? "application/octet-stream", updated: new Date().toISOString() });
|
|
232
344
|
}
|
|
233
345
|
const receives = options.receives ?? ["member.*"];
|
|
234
|
-
//
|
|
346
|
+
// What the tool's chest.json declares it emits, refused as the Chest
|
|
347
|
+
// refuses the manifest.
|
|
348
|
+
const emits = options.emits ?? {};
|
|
349
|
+
for (const [type, declared] of Object.entries(emits)) {
|
|
350
|
+
const length = typeof declared?.description === "string" ? [...declared.description].length : 0;
|
|
351
|
+
if (!isToolEventType(type) || length < 1 || length > 80 || !object(declared.data) || !Object.entries(declared.data).every(([name, kind]) => fieldNamePattern.test(name) && fieldOf(kind) !== null)) {
|
|
352
|
+
throw new Error(`fakeChest: the Chest refuses "emits" ${JSON.stringify(type)}: a type of two to four dotted words, a description of 1 to 80 characters, camelCase fields of the kinds id, text, number, boolean, time, date, member, members (? when optional)`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// The erasures the tool was told of, by deliver: those it may acknowledge.
|
|
235
356
|
const erasures = new Set<string>();
|
|
236
|
-
const
|
|
357
|
+
const realtime = fakeRealtime(options.realtime ?? {}, () => chest.api, id => {
|
|
358
|
+
const m = chest.members.find(x => x.id === id);
|
|
359
|
+
return m ? m.role : undefined;
|
|
360
|
+
});
|
|
361
|
+
const chest: FakeChest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], ai: [], opens: [], realtime: realtime.realtime, emitted: [], deliver: async () => 0, run: async () => 0, close: async () => {} };
|
|
237
362
|
const former = [...(options.former ?? [])];
|
|
238
363
|
let window = 0, calls = 0;
|
|
239
|
-
|
|
364
|
+
// The groups the tool sees: those that give it, or all with members.groups;
|
|
365
|
+
// a member's groups among them (an identifier it was not given stays).
|
|
366
|
+
const unseen = () => new Set(everyGroup ? [] : chest.groups.filter(g => g.grants === false).map(g => g.id));
|
|
367
|
+
const groupsOf = (m: Member) => (m.groups ?? []).filter(g => !unseen().has(g));
|
|
368
|
+
const shown = (m: Member) => ({ id: m.id, first_name: m.firstName, last_name: m.lastName, name: m.name, photo: m.photo, role: m.role, admin: m.isAdmin, builder: m.isBuilder, groups: groupsOf(m), language: m.language, time_zone: m.timeZone, ...(email && m.email !== undefined ? { email: m.email } : {}) });
|
|
240
369
|
const key = (m: Member) => fold(m.name) + "\u0000" + m.id;
|
|
241
370
|
const described = (name: string, f: FakeFile) => ({ name, type: f.type, size: f.data.byteLength, sha256: createHash("sha256").update(f.data).digest("hex"), updated: f.updated });
|
|
242
371
|
|
|
@@ -253,7 +382,7 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
253
382
|
const group = q.get("group"), role = q.get("role"), search = fold(q.get("q") ?? "");
|
|
254
383
|
if (keys.some(k => !["after", "limit", "q", "role", "group"].includes(k) || q.getAll(k).length !== 1) || !Number.isInteger(limit) || limit < 1 || limit > maxLimit || String(limit) !== (q.get("limit") ?? String(defaultLimit)) || (group !== null && !groupIdPattern.test(group)) || search.length > 64) return send(response, 400, { error: "invalid_query" });
|
|
255
384
|
const shownList = chest.members
|
|
256
|
-
.filter(m => (role === null || m.role === role) && (group === null || m.
|
|
385
|
+
.filter(m => (role === null || m.role === role) && (group === null || groupsOf(m).includes(group)) && (search === "" || [m.firstName, m.lastName, m.name, ...(email && m.email ? [m.email] : [])].some(n => fold(n).startsWith(search))))
|
|
257
386
|
.filter(m => after === "" || key(m) > after)
|
|
258
387
|
.sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
|
|
259
388
|
const page = shownList.slice(0, limit);
|
|
@@ -282,7 +411,16 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
282
411
|
const m = chest.members.find(x => x.id === id);
|
|
283
412
|
return m ? send(response, 200, shown(m)) : send(response, 404, { error: "member_not_found" });
|
|
284
413
|
}
|
|
285
|
-
if (request.method === "GET" && url.pathname === "/groups")
|
|
414
|
+
if (request.method === "GET" && url.pathname === "/groups") {
|
|
415
|
+
const q = url.searchParams, keys = [...q.keys()];
|
|
416
|
+
const limit = q.has("limit") ? Number(q.get("limit")) : defaultLimit;
|
|
417
|
+
const after = q.has("after") ? Buffer.from(q.get("after")!, "base64url").toString() : "";
|
|
418
|
+
if ((q.has("after") && !groupIdPattern.test(after.split("\u0000")[1] ?? "")) || keys.some(k => !["after", "limit"].includes(k) || q.getAll(k).length !== 1) || !Number.isInteger(limit) || limit < 1 || limit > maxLimit || String(limit) !== (q.get("limit") ?? String(defaultLimit))) return send(response, 400, { error: "invalid_query" });
|
|
419
|
+
const groupKey = (g: FakeGroup) => fold(g.name) + "\u0000" + g.id;
|
|
420
|
+
const listed = chest.groups.filter(g => !unseen().has(g.id) && (after === "" || groupKey(g) > after)).sort((a, b) => groupKey(a) < groupKey(b) ? -1 : groupKey(a) > groupKey(b) ? 1 : 0);
|
|
421
|
+
const page = listed.slice(0, limit);
|
|
422
|
+
return send(response, 200, { groups: page.map(g => ({ id: g.id, name: g.name, size: g.members.filter(access).length })), next: listed.length > limit ? Buffer.from(groupKey(page.at(-1)!)).toString("base64url") : null });
|
|
423
|
+
}
|
|
286
424
|
send(response, 404, { error: "not_found" });
|
|
287
425
|
}
|
|
288
426
|
|
|
@@ -308,15 +446,17 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
308
446
|
return send(response, 200, { url: `${chest.api}${linkPath}${token}`, expires_in: linkLife });
|
|
309
447
|
}
|
|
310
448
|
if (url.pathname === "/files/upload-url") {
|
|
311
|
-
const { name, max_size: maxSize, types, expires_in: expiresIn } = command;
|
|
449
|
+
const { name, max_size: maxSize, types, expires_in: expiresIn, public: visitor } = command;
|
|
312
450
|
const folder = typeof name === "string" && name.endsWith("/");
|
|
313
|
-
if (typeof name !== "string" || !namePattern.test(folder ? name.slice(0, -1) : name)) return send(response, 400, { error: "invalid_name" });
|
|
451
|
+
if (typeof name !== "string" || !namePattern.test(folder ? name.slice(0, -1) : name) || (visitor === true && !folder)) return send(response, 400, { error: "invalid_name" });
|
|
314
452
|
if (!(types === undefined || (Array.isArray(types) && types.length <= 8 && types.every(t => typeof t === "string" && mediaPattern.test(t))))) return send(response, 400, { error: "invalid_type" });
|
|
315
|
-
if (
|
|
453
|
+
if (visitor === true && (!Array.isArray(types) || types.length === 0 || !types.every(t => recognised.some(r => accepted([t as string], r))))) return send(response, 400, { error: "invalid_type" });
|
|
454
|
+
if (!(maxSize === undefined || (typeof maxSize === "number" && Number.isSafeInteger(maxSize) && maxSize > 0)) || !(expiresIn === undefined || (typeof expiresIn === "number" && Number.isInteger(expiresIn) && expiresIn > 0 && expiresIn <= uploadLife)) || !(visitor === undefined || typeof visitor === "boolean")) return send(response, 400, { error: "invalid_body" });
|
|
455
|
+
if (visitor === true && process.env["CHEST_PUBLIC_URL"] === undefined) return send(response, 409, { error: "no_public_part" });
|
|
316
456
|
if ((maxSize ?? 0) > maxObject) return send(response, 413, { error: "too_large" });
|
|
317
457
|
const token = newToken(), life = (expiresIn as number | undefined) ?? uploadLife;
|
|
318
|
-
uploads.set(token, { name, maxSize: (maxSize as number | undefined) ?? maxObject, types: (types as string[] | undefined) ?? [], until: Date.now() + life * 1000 });
|
|
319
|
-
return send(response, 200, { url: `${chest.api}${uploadPath}${token}`, method: "PUT", expires_in: life });
|
|
458
|
+
uploads.set(token, { name, maxSize: (maxSize as number | undefined) ?? maxObject, types: (types as string[] | undefined) ?? [], until: Date.now() + life * 1000, visitor: visitor === true });
|
|
459
|
+
return send(response, 200, { url: visitor === true ? uploadPath + token : `${chest.api}${uploadPath}${token}`, method: "PUT", expires_in: life });
|
|
320
460
|
}
|
|
321
461
|
const from = command["from"], to = command["to"];
|
|
322
462
|
if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to)) return send(response, 400, { error: "invalid_name" });
|
|
@@ -348,15 +488,17 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
348
488
|
send(response, 404, { error: "not_found" });
|
|
349
489
|
}
|
|
350
490
|
|
|
351
|
-
// The team host's
|
|
352
|
-
// signed opens the content it was signed for, for its life, as
|
|
353
|
-
// the file is that content — a thumbnail is the image itself, a
|
|
354
|
-
// not reduce it —; an upload token takes one file, once, within
|
|
355
|
-
//
|
|
356
|
-
//
|
|
491
|
+
// The team host's and the public part's files, on the fake's own origin:
|
|
492
|
+
// a link it signed opens the content it was signed for, for its life, as
|
|
493
|
+
// long as the file is that content — a thumbnail is the image itself, a
|
|
494
|
+
// fake does not reduce it —; an upload token takes one file, once, within
|
|
495
|
+
// its life, up to its size, named by the Chest in a folder: a member's of
|
|
496
|
+
// the types it names, holding the type it says when the fake recognises
|
|
497
|
+
// it; a visitor's of the type its content is among those it names,
|
|
498
|
+
// whatever it says. Nobody's session is checked, nor a visitor's pace:
|
|
357
499
|
// the Chest's own front does that.
|
|
358
500
|
const links = new Map<string, { name: string; object: FakeFile; until: number; download: boolean }>();
|
|
359
|
-
const uploads = new Map<string, { name: string; maxSize: number; types: string[]; until: number }>();
|
|
501
|
+
const uploads = new Map<string, { name: string; maxSize: number; types: string[]; until: number; visitor: boolean }>();
|
|
360
502
|
async function front(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
361
503
|
if (request.method === "GET" && url.pathname.startsWith(linkPath) && !url.pathname.startsWith(uploadPath)) {
|
|
362
504
|
const link = links.get(url.pathname.slice(linkPath.length));
|
|
@@ -369,11 +511,15 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
369
511
|
const token = url.pathname.slice(uploadPath.length), grant = uploads.get(token);
|
|
370
512
|
uploads.delete(token);
|
|
371
513
|
if (!grant || grant.until < Date.now()) return send(response, 403, { error: "invalid_token" });
|
|
372
|
-
|
|
373
|
-
if (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !grant.types
|
|
514
|
+
let type = (request.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
|
|
515
|
+
if (!grant.visitor && (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !accepted(grant.types, type)))) return send(response, 415, { error: "type_refused" });
|
|
374
516
|
const data = await body(request, grant.maxSize);
|
|
375
517
|
if (data === null) return send(response, 413, { error: "too_large" });
|
|
376
|
-
if (
|
|
518
|
+
if (grant.visitor) {
|
|
519
|
+
const found = recognised.find(r => accepted(grant.types, r) && recognisers[r]!(data));
|
|
520
|
+
if (found === undefined) return send(response, 415, { error: "type_refused" });
|
|
521
|
+
type = found;
|
|
522
|
+
} else if (recognisers[type] && !recognisers[type]!(data)) return send(response, 400, { error: "type_mismatch" });
|
|
377
523
|
const name = grant.name.endsWith("/") ? grant.name + randomBytes(10).toString("hex") + (extensions[type] ?? "") : grant.name;
|
|
378
524
|
const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
|
|
379
525
|
if (total + data.length > maxTotal || (!files.has(name) && files.size >= maxObjects)) return send(response, 429, { error: "quota_exceeded" });
|
|
@@ -381,17 +527,25 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
381
527
|
send(response, 201, { name, type, size: data.length });
|
|
382
528
|
}
|
|
383
529
|
|
|
384
|
-
// The windows of
|
|
385
|
-
// counts: the tool's recipients this hour, each member's items this day,
|
|
386
|
-
// the tool's badge writes this minute.
|
|
530
|
+
// The windows of a request rate (AI), each from the first call it counts.
|
|
387
531
|
type Window = { start: number; count: number };
|
|
388
|
-
const hour: Window = { start: 0, count: 0 }, minute: Window = { start: 0, count: 0 }, days = new Map<string, Window>();
|
|
389
532
|
const live = (w: Window | undefined, span: number, now: number): boolean => w !== undefined && w.count > 0 && now - w.start < span;
|
|
390
533
|
const wait = (w: Window, span: number, now: number): Record<string, string> => ({ "Retry-After": String(Math.max(1, Math.ceil((w.start + span - now) / 1000))) });
|
|
391
534
|
const count = (w: Window, span: number, now: number, n: number): void => {
|
|
392
535
|
if (!live(w, span, now)) [w.start, w.count] = [now, 0];
|
|
393
536
|
w.count += n;
|
|
394
537
|
};
|
|
538
|
+
// The pace of each member: a token bucket, as of when it was last used.
|
|
539
|
+
const buckets = new Map<string, { tokens: number; at: number }>();
|
|
540
|
+
const paced = (id: string, now: number): boolean => {
|
|
541
|
+
const b = buckets.get(id) ?? { tokens: paceBurst, at: now };
|
|
542
|
+
b.tokens = Math.min(paceBurst, b.tokens + (now - b.at) / paceEvery);
|
|
543
|
+
b.at = now;
|
|
544
|
+
buckets.set(id, b);
|
|
545
|
+
if (b.tokens < 1) return false;
|
|
546
|
+
b.tokens--;
|
|
547
|
+
return true;
|
|
548
|
+
};
|
|
395
549
|
const access = (id: string) => chest.members.some(m => m.id === id);
|
|
396
550
|
// drop removes kept notifications in place: a test may hold the list.
|
|
397
551
|
const drop = (gone: (n: FakeNotification) => boolean): void => {
|
|
@@ -399,7 +553,7 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
399
553
|
};
|
|
400
554
|
// recipients reads 1 to 500 member identifiers, each once.
|
|
401
555
|
function recipients(value: unknown): string[] | { error: string } {
|
|
402
|
-
if (!Array.isArray(value) || value.length < 1
|
|
556
|
+
if (!Array.isArray(value) || value.length < 1) return { error: "invalid_body" };
|
|
403
557
|
if (!value.every(v => typeof v === "string" && memberIdPattern.test(v))) return { error: "invalid_id" };
|
|
404
558
|
return [...new Set(value as string[])];
|
|
405
559
|
}
|
|
@@ -407,8 +561,8 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
407
561
|
async function notificationsRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
408
562
|
if (!capabilities.has("notifications")) return send(response, 403, { error: "capability_not_granted" });
|
|
409
563
|
const badge = request.method === "PUT" && url.pathname.startsWith("/badges/");
|
|
410
|
-
if (!badge && !(request.method === "PUT" && url.pathname === "/badges") && !(request.method === "POST" &&
|
|
411
|
-
const raw = await body(request,
|
|
564
|
+
if (!badge && !(request.method === "PUT" && url.pathname === "/badges") && !(request.method === "POST" && ["/notifications", "/notifications/broadcast", "/notifications/withdraw"].includes(url.pathname))) return send(response, 404, { error: "not_found" });
|
|
565
|
+
const raw = await body(request, textBytes + memberBytes * chest.members.length);
|
|
412
566
|
let command: Record<string, unknown> | null = null;
|
|
413
567
|
try {
|
|
414
568
|
const value = JSON.parse(raw?.toString() ?? "") as unknown;
|
|
@@ -427,14 +581,12 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
427
581
|
writes = [{ member: id, count: command!["count"] as number }];
|
|
428
582
|
} else {
|
|
429
583
|
const list = command?.["badges"];
|
|
430
|
-
if (!keys("badges") || !Array.isArray(list) || list.length < 1 ||
|
|
584
|
+
if (!keys("badges") || !Array.isArray(list) || list.length < 1 || !list.every(b => b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).every(k => k === "member" || k === "count"))) return send(response, 400, { error: "invalid_body" });
|
|
431
585
|
writes = list as { member: string; count: number }[];
|
|
432
586
|
if (!writes.every(b => typeof b.member === "string" && memberIdPattern.test(b.member))) return send(response, 400, { error: "invalid_id" });
|
|
433
587
|
}
|
|
434
588
|
if (!writes.every(b => typeof b.count === "number" && Number.isInteger(b.count) && b.count >= 0 && b.count <= maxCount)) return send(response, 400, { error: "invalid_count" });
|
|
435
589
|
if (new Set(writes.map(b => b.member)).size !== writes.length) return send(response, 400, { error: "invalid_body" });
|
|
436
|
-
if (live(minute, 60_000, now) && minute.count + writes.length > badgesPerMinute) return send(response, 429, { error: "quota_exceeded" }, wait(minute, 60_000, now));
|
|
437
|
-
count(minute, 60_000, now, writes.length);
|
|
438
590
|
const answer = { set: [] as string[], skipped: [] as string[] };
|
|
439
591
|
for (const b of writes) {
|
|
440
592
|
if (!access(b.member)) {
|
|
@@ -456,26 +608,60 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
456
608
|
drop(n => n.key === key && (named === null || named.includes(n.member)));
|
|
457
609
|
return send(response, 204);
|
|
458
610
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
if (
|
|
463
|
-
|
|
464
|
-
|
|
611
|
+
const broadcast = url.pathname === "/notifications/broadcast";
|
|
612
|
+
if (!keys(broadcast ? "to" : "members", ...(broadcast ? ["except"] : []), "title", "body", "path", "key", "translations")) return send(response, 400, { error: "invalid_body" });
|
|
613
|
+
let ids: string[];
|
|
614
|
+
if (broadcast) {
|
|
615
|
+
const to = command!["to"] as Record<string, unknown> | undefined, except = command!["except"];
|
|
616
|
+
if (to !== undefined && (to === null || typeof to !== "object" || Array.isArray(to) || !Object.keys(to).every(k => k === "groups" || k === "roles"))) return send(response, 400, { error: "invalid_body" });
|
|
617
|
+
const groups = to?.["groups"] ?? [], roles = to?.["roles"] ?? [];
|
|
618
|
+
if (!Array.isArray(groups) || !Array.isArray(roles) || (to !== undefined && groups.length + roles.length === 0) || roles.length > maxRoles || (except !== undefined && !Array.isArray(except))) return send(response, 400, { error: "invalid_body" });
|
|
619
|
+
if (!groups.every(g => typeof g === "string" && groupIdPattern.test(g)) || !((except ?? []) as unknown[]).every(id => typeof id === "string" && memberIdPattern.test(id))) return send(response, 400, { error: "invalid_id" });
|
|
620
|
+
if (!roles.every(r => typeof r === "string" && rolePattern.test(r))) return send(response, 400, { error: "invalid_role" });
|
|
621
|
+
ids = chest.members.filter(m => !((except ?? []) as string[]).includes(m.id) && (to === undefined || (m.role !== null && roles.includes(m.role)) || groupsOf(m).some(g => groups.includes(g)))).map(m => m.id);
|
|
622
|
+
} else {
|
|
623
|
+
const named = recipients(command!["members"]);
|
|
624
|
+
if (!Array.isArray(named)) return send(response, 400, named);
|
|
625
|
+
ids = named;
|
|
626
|
+
}
|
|
627
|
+
const { path, key, translations } = command!;
|
|
628
|
+
const words = (value: unknown): { title: string; body?: string } | { error: string } => {
|
|
629
|
+
const { title, body: text, ...rest } = (value ?? {}) as Record<string, unknown>;
|
|
630
|
+
if (value === null || typeof value !== "object" || Array.isArray(value) || Object.keys(rest).length > 0 || title === undefined) return { error: "invalid_body" };
|
|
631
|
+
if (typeof title !== "string" || [...title].length < 1 || [...title].length > maxTitle || cleanTitle(title) === "") return { error: "invalid_title" };
|
|
632
|
+
if (text !== undefined && (typeof text !== "string" || [...text].length > maxText)) return { error: "invalid_text" };
|
|
633
|
+
const cleaned = typeof text === "string" ? cleanText(text) : "";
|
|
634
|
+
return { title: cleanTitle(title), ...(cleaned ? { body: cleaned } : {}) };
|
|
635
|
+
};
|
|
636
|
+
const own = words({ title: command!["title"], ...(command!["body"] !== undefined ? { body: command!["body"] } : {}) });
|
|
637
|
+
if ("error" in own) return send(response, 400, own);
|
|
465
638
|
if (path !== undefined && !isPath(path)) return send(response, 400, { error: "invalid_path" });
|
|
466
639
|
if (key !== undefined && (typeof key !== "string" || !keyPattern.test(key))) return send(response, 400, { error: "invalid_key" });
|
|
640
|
+
const other = new Map<string, { title: string; body?: string }>();
|
|
641
|
+
if (translations !== undefined) {
|
|
642
|
+
if (translations === null || typeof translations !== "object" || Array.isArray(translations)) return send(response, 400, { error: "invalid_body" });
|
|
643
|
+
for (const [language, value] of Object.entries(translations)) {
|
|
644
|
+
if (!languagePattern.test(language)) return send(response, 400, { error: "invalid_language" });
|
|
645
|
+
const translated = words(value);
|
|
646
|
+
if ("error" in translated) return send(response, 400, translated);
|
|
647
|
+
other.set(language, translated);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
467
650
|
const kept = ids.filter(access);
|
|
468
|
-
if (live(hour, 3_600_000, now) && hour.count + kept.length > recipientsPerHour) return send(response, 429, { error: "quota_exceeded" }, wait(hour, 3_600_000, now));
|
|
469
|
-
const full = kept.map(id => days.get(id)).filter((w): w is Window => live(w, 86_400_000, now) && w!.count >= itemsPerDay);
|
|
470
|
-
if (full.length > 0) return send(response, 429, { error: "quota_exceeded" }, wait(full.reduce((a, b) => a.start > b.start ? a : b), 86_400_000, now));
|
|
471
|
-
count(hour, 3_600_000, now, kept.length);
|
|
472
|
-
const cleaned = typeof text === "string" ? cleanText(text) : "";
|
|
473
651
|
for (const id of kept) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (key !== undefined) drop(n => n.member === id && n.key === key);
|
|
477
|
-
|
|
652
|
+
const language = chest.members.find(m => m.id === id)!.language;
|
|
653
|
+
const item: FakeNotification = { member: id, ...(other.get(language) ?? own), path: (path as string | undefined) ?? "/chest", ...(key !== undefined ? { key: key as string } : {}) };
|
|
654
|
+
if (key !== undefined && chest.notifications.some(n => n.member === id && n.key === key)) drop(n => n.member === id && n.key === key);
|
|
655
|
+
else if (!paced(id, now)) {
|
|
656
|
+
// Beyond the pace: folded into the member's grouped item, never refused.
|
|
657
|
+
const grouped = chest.notifications.find(n => n.member === id && n.grouped !== undefined);
|
|
658
|
+
drop(n => n === grouped);
|
|
659
|
+
delete item.key;
|
|
660
|
+
item.grouped = (grouped?.grouped ?? 0) + 1;
|
|
661
|
+
}
|
|
662
|
+
chest.notifications.push(item);
|
|
478
663
|
}
|
|
664
|
+
if (broadcast) return send(response, 204);
|
|
479
665
|
send(response, 200, { delivered: kept, skipped: ids.filter(id => !access(id)) });
|
|
480
666
|
}
|
|
481
667
|
|
|
@@ -573,7 +759,65 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
573
759
|
response.end();
|
|
574
760
|
}
|
|
575
761
|
|
|
576
|
-
//
|
|
762
|
+
// Its sealed values: AES-256-GCM under a key of its own — the format and
|
|
763
|
+
// the rules of a Chest, not its cipher —, bound to the tool, the roles
|
|
764
|
+
// and the context; its tickets, HMAC under another, for 60 seconds.
|
|
765
|
+
const sealingKey = randomBytes(32), ticketKey = randomBytes(32);
|
|
766
|
+
const additional = (roles: string, context: string) => Buffer.from(`chest:sealed:1:\u0000${tool}\u0000${roles}\u0000${context}`);
|
|
767
|
+
const ticketMac = (id: string, expiry: string) => createHmac("sha256", ticketKey).update(`${id}\u0000${expiry}`).digest();
|
|
768
|
+
ticketOf = id => { const expiry = String(Math.floor(Date.now() / 1000) + ticketLife); return `1.${id}.${expiry}.${ticketMac(id, expiry).toString("base64url")}`; };
|
|
769
|
+
const ticketMember = (ticket: string | string[] | undefined): string | null => {
|
|
770
|
+
const parts = typeof ticket === "string" ? ticket.split(".") : [];
|
|
771
|
+
if (parts.length !== 4 || parts[0] !== "1" || !memberIdPattern.test(parts[1]!) || Number(parts[2]) < Date.now() / 1000) return null;
|
|
772
|
+
const mac = Buffer.from(parts[3]!, "base64url"), expected = ticketMac(parts[1]!, parts[2]!);
|
|
773
|
+
return mac.length === expected.length && timingSafeEqual(mac, expected) ? parts[1]! : null;
|
|
774
|
+
};
|
|
775
|
+
const isContext = (c: unknown): c is string | undefined => c === undefined || (typeof c === "string" && Buffer.byteLength(c) <= sealedContext);
|
|
776
|
+
async function sealedRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
777
|
+
if (!capabilities.has("sealed")) return send(response, 403, { error: "capability_not_granted" });
|
|
778
|
+
if (request.method !== "POST" || url.search || (url.pathname !== "/sealed/seal" && url.pathname !== "/sealed/open")) return send(response, 404, { error: "not_found" });
|
|
779
|
+
const id = url.pathname === "/sealed/open" ? ticketMember(request.headers["chest-opener"]) : null;
|
|
780
|
+
if (url.pathname === "/sealed/open" && id === null) return send(response, 401, { error: "member_required" });
|
|
781
|
+
const raw = await body(request, sealedBody);
|
|
782
|
+
if (raw === null) return send(response, 413, { error: "too_large" });
|
|
783
|
+
let items: unknown;
|
|
784
|
+
try { items = (JSON.parse(raw.toString()) as { items?: unknown }).items; } catch { items = undefined; }
|
|
785
|
+
if (!Array.isArray(items)) return send(response, 400, { error: "invalid_body" });
|
|
786
|
+
if (url.pathname === "/sealed/seal") {
|
|
787
|
+
const sealed: string[] = [];
|
|
788
|
+
for (const item of items as { value?: unknown; roles?: unknown; context?: unknown }[]) {
|
|
789
|
+
if (typeof item?.value !== "string" || Buffer.byteLength(item.value) > sealedValue || !isContext(item.context)) return send(response, 400, { error: "invalid_body" });
|
|
790
|
+
const roles = item.roles;
|
|
791
|
+
if (roles !== undefined && (!Array.isArray(roles) || roles.length === 0 || roles.length > maxRoles || !roles.every(r => typeof r === "string" && rolePattern.test(r) && (!options.roles || options.roles.includes(r))) || new Set(roles).size !== roles.length)) return send(response, 400, { error: "invalid_role" });
|
|
792
|
+
const joined = (roles as string[] | undefined)?.join(",") ?? "", nonce = randomBytes(12);
|
|
793
|
+
const cipher = createCipheriv("aes-256-gcm", sealingKey, nonce).setAAD(additional(joined, item.context ?? ""));
|
|
794
|
+
const box = Buffer.concat([nonce, cipher.update(item.value, "utf8"), cipher.final(), cipher.getAuthTag()]);
|
|
795
|
+
sealed.push(`chest:sealed:1:${joined}:${box.toString("base64url")}`);
|
|
796
|
+
}
|
|
797
|
+
return send(response, 200, { sealed });
|
|
798
|
+
}
|
|
799
|
+
const holder = chest.members.find(m => m.id === id);
|
|
800
|
+
if (!holder) return send(response, 403, { error: "access_removed" });
|
|
801
|
+
const values: ({ value: string } | { refused: "role" | "invalid" })[] = [];
|
|
802
|
+
for (const item of items as { sealed?: unknown; context?: unknown }[]) {
|
|
803
|
+
if (typeof item?.sealed !== "string" || !isContext(item.context)) return send(response, 400, { error: "invalid_body" });
|
|
804
|
+
const found = sealedText.exec(item.sealed), box = found ? Buffer.from(found[2]!, "base64url") : Buffer.alloc(0);
|
|
805
|
+
let value: string | null = null;
|
|
806
|
+
if (found && box.length >= 28) {
|
|
807
|
+
try {
|
|
808
|
+
const decipher = createDecipheriv("aes-256-gcm", sealingKey, box.subarray(0, 12)).setAAD(additional(found[1]!, item.context ?? ""));
|
|
809
|
+
decipher.setAuthTag(box.subarray(box.length - 16));
|
|
810
|
+
value = Buffer.concat([decipher.update(box.subarray(12, box.length - 16)), decipher.final()]).toString("utf8");
|
|
811
|
+
} catch { value = null; }
|
|
812
|
+
}
|
|
813
|
+
const roles = found?.[1] ? found[1].split(",") : [];
|
|
814
|
+
values.push(value === null ? { refused: "invalid" } : roles.length && !roles.includes(holder.role ?? "") ? { refused: "role" } : { value });
|
|
815
|
+
}
|
|
816
|
+
if (values.length) chest.opens.push({ member: holder.id, opened: values.filter(v => "value" in v).length, refused: values.filter(v => "refused" in v).length });
|
|
817
|
+
send(response, 200, { values });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// The acknowledgment of an erasure the tool was told of (deliver).
|
|
577
821
|
async function erasuresRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
578
822
|
if (!receives.includes("member.*")) return send(response, 403, { error: "capability_not_granted" });
|
|
579
823
|
const done = /^\/erasures\/([^/]+)\/done$/u.exec(url.pathname);
|
|
@@ -585,17 +829,68 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
585
829
|
send(response, 204);
|
|
586
830
|
}
|
|
587
831
|
|
|
832
|
+
// Realtime: the tool's API, and the probe of a page that asks whether its
|
|
833
|
+
// member may connect.
|
|
834
|
+
async function realtimeRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
835
|
+
if (!capabilities.has("realtime")) return send(response, url.pathname === "/_chest/realtime" ? 404 : 403, url.pathname === "/_chest/realtime" ? undefined : { error: "capability_not_granted" });
|
|
836
|
+
if (url.pathname === "/_chest/realtime") return realtime.probe(response, url, send);
|
|
837
|
+
return realtime.api(request, response, url, body, send);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// The events the tool emits (POST /events), checked against its "emits"
|
|
841
|
+
// as the Chest checks them: a member it never had, a group it does not
|
|
842
|
+
// know, are refused; an idempotency key used within 72 hours answers its
|
|
843
|
+
// first event for the same content, key_reused for other content.
|
|
844
|
+
const used = new Map<string, { id: string; content: string; at: number }>();
|
|
845
|
+
const had = (id: string): boolean => access(id) || former.some(f => f.id === id);
|
|
846
|
+
async function eventsRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
847
|
+
if (request.method !== "POST" || url.pathname !== "/events" || url.search) return send(response, 404, { error: "not_found" });
|
|
848
|
+
if (Object.keys(emits).length === 0) return send(response, 403, { error: "capability_not_granted" });
|
|
849
|
+
// The data, the envelope, and an audience of as many identifiers as the Chest holds.
|
|
850
|
+
const raw = await body(request, maxData + envelopeBytes + idBytes * (chest.members.length + former.length + chest.groups.length + maxRoles));
|
|
851
|
+
if (raw === null) return send(response, 413, { error: "too_large" });
|
|
852
|
+
const command = object(json(raw.toString("utf8")));
|
|
853
|
+
if (!command || !Object.keys(command).every(k => emitKeys.includes(k))) return send(response, 400, { error: "invalid_event" });
|
|
854
|
+
const { type, data, subject, key, occurredAt, audience, cause } = command;
|
|
855
|
+
if (!isToolEventType(type) || !Object.hasOwn(emits, type)) return send(response, 400, { error: "invalid_type" });
|
|
856
|
+
const now = Date.now(), time = occurredAt === undefined ? now : instantOf(occurredAt);
|
|
857
|
+
const identifier = (value: unknown, pattern: RegExp): boolean => value === undefined || (typeof value === "string" && pattern.test(value));
|
|
858
|
+
if (!identifier(subject, itemIdPattern) || !identifier(key, itemIdPattern) || !identifier(cause, eventChannel.id) || time === null || time > now + clockSkew || time < now - eventWindow) return send(response, 400, { error: "invalid_event" });
|
|
859
|
+
if (Buffer.byteLength(JSON.stringify(data) ?? "") > maxData) return send(response, 413, { error: "too_large" });
|
|
860
|
+
if (!isDeclaredData(data, emits[type]!.data, had)) return send(response, 400, { error: "invalid_data" });
|
|
861
|
+
if (audience !== undefined && (!isAudience(audience) || !(audience.members ?? []).every(had) || !(audience.groups ?? []).every(g => chest.groups.some(x => x.id === g)))) return send(response, 400, { error: "invalid_audience" });
|
|
862
|
+
const content = canonical({ type, data, subject, occurredAt, audience });
|
|
863
|
+
const first = key === undefined ? undefined : used.get(key as string);
|
|
864
|
+
if (first && now - first.at < eventWindow) return first.content === content ? send(response, 200, { id: first.id, receivers: 0 }) : send(response, 409, { error: "key_reused" });
|
|
865
|
+
const id = newId("evt_");
|
|
866
|
+
if (key !== undefined) used.set(key as string, { id, content, at: now });
|
|
867
|
+
chest.emitted.push({
|
|
868
|
+
id, type, data: data as Record<string, unknown>, ...(subject === undefined ? {} : { subject: subject as string }), ...(key === undefined ? {} : { key: key as string }),
|
|
869
|
+
occurredAt: (occurredAt as string | undefined) ?? new Date(now).toISOString(), ...(audience === undefined ? {} : { audience }), ...(cause === undefined ? {} : { cause: cause as string }),
|
|
870
|
+
});
|
|
871
|
+
send(response, 202, { id, receivers: 0 });
|
|
872
|
+
}
|
|
873
|
+
|
|
588
874
|
const server = createServer((request, response) => {
|
|
589
875
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
590
|
-
const route = url.pathname.startsWith("/
|
|
876
|
+
const route = url.pathname === "/_chest/realtime" || url.pathname.startsWith("/realtime/") ? realtimeRoute
|
|
877
|
+
: url.pathname.startsWith("/_chest/files/") ? front
|
|
591
878
|
: url.pathname.startsWith("/erasures/") ? erasuresRoute
|
|
879
|
+
: url.pathname === "/events" ? eventsRoute
|
|
592
880
|
: url.pathname.startsWith("/ai/") ? aiRoute
|
|
881
|
+
: url.pathname.startsWith("/sealed/") ? sealedRoute
|
|
593
882
|
: url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
|
|
594
883
|
: url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
|
|
595
884
|
: url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
|
|
596
885
|
if (!route) return send(response, 404, { error: "not_found" });
|
|
597
886
|
route(request, response, url).catch(() => { if (!response.headersSent) send(response, 503, { error: "unavailable" }); else response.destroy(); });
|
|
598
887
|
});
|
|
888
|
+
// A page's connection: the Chest's side of the browser client.
|
|
889
|
+
server.on("upgrade", (request: IncomingMessage, socket: Duplex) => {
|
|
890
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
891
|
+
if (url.pathname !== "/_chest/realtime" || !capabilities.has("realtime")) return void socket.end("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
|
|
892
|
+
realtime.upgrade(request, socket, url);
|
|
893
|
+
});
|
|
599
894
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
600
895
|
const saved = Object.fromEntries(["CHEST_API", "CHEST_TOKEN", "CHEST_TOOL", "CHEST_ORGANIZATION", "CHEST_TIME_ZONE", "CHEST_LANGUAGE", "CHEST_CURRENCY", "CHEST_TEAM_URL", "CHEST_PUBLIC_URL"].map(name => [name, process.env[name]]));
|
|
601
896
|
chest.api = "http://127.0.0.1:" + (server.address() as AddressInfo).port;
|
|
@@ -615,12 +910,14 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
615
910
|
await answer.body?.cancel();
|
|
616
911
|
return answer.status;
|
|
617
912
|
};
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
913
|
+
chest.deliver = async (event, to) => {
|
|
914
|
+
const id = event.id ?? newId("evt_"), occurredAt = event.occurredAt ?? new Date().toISOString();
|
|
915
|
+
if ("source" in event) {
|
|
916
|
+
const body = JSON.stringify({ id, type: event.type, source: event.source, occurredAt, ...(event.subject === undefined ? {} : { subject: event.subject }), audience: event.audience ?? "all", data: event.data });
|
|
917
|
+
return post("/chest-events", eventChannel, id, body, to);
|
|
918
|
+
}
|
|
622
919
|
if (event.type === "member.erased") erasures.add(event.data.erasure);
|
|
623
|
-
return post("/chest-events", eventChannel, id,
|
|
920
|
+
return post("/chest-events", eventChannel, id, JSON.stringify({ id, type: event.type, occurredAt, data: event.data }), to);
|
|
624
921
|
};
|
|
625
922
|
chest.run = async (name, to, run = {}) => {
|
|
626
923
|
const id = run.id ?? newId("run_");
|
|
@@ -628,6 +925,8 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
628
925
|
return post("/chest-schedules", scheduleChannel, id, body, to);
|
|
629
926
|
};
|
|
630
927
|
chest.close = async () => {
|
|
928
|
+
realtime.close();
|
|
929
|
+
ticketOf = null;
|
|
631
930
|
server.closeAllConnections();
|
|
632
931
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
633
932
|
for (const [name, value] of Object.entries(saved)) {
|