@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/client/src/testing.ts
CHANGED
|
@@ -1,35 +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 {
|
|
10
|
-
import {
|
|
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";
|
|
11
15
|
|
|
12
16
|
// For a tool's own tests, never imported by its production code: a member's
|
|
13
17
|
// assertion signed as the Chest signs it, and a Chest's API in the test's
|
|
14
18
|
// process that answers members, groups, files (stat, move, links and
|
|
15
|
-
// uploads
|
|
16
|
-
//
|
|
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,
|
|
17
24
|
// streamed or not, embeddings, models, usage: deterministic answers, no
|
|
18
|
-
// provider)
|
|
19
|
-
//
|
|
20
|
-
// 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.
|
|
21
32
|
//
|
|
22
33
|
// import { fakeChest, withMember } from "@argentic/chest-sdk/testing";
|
|
23
|
-
// 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" } } } });
|
|
24
35
|
// const response = await app(withMember(new Request("http://tool/chest"), camille));
|
|
25
36
|
// assert.equal(chest.notifications[0]?.member, camille.id);
|
|
26
|
-
// 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");
|
|
27
40
|
// assert.equal(await chest.run("morning", request => app(request)), 204);
|
|
28
41
|
// await chest.close();
|
|
29
42
|
|
|
30
|
-
// A group as a fake Chest keeps it: its identifier, its name,
|
|
31
|
-
// identifiers of
|
|
32
|
-
|
|
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 };
|
|
33
49
|
// A file as a fake Chest keeps it.
|
|
34
50
|
export type FakeFile = { data: Uint8Array; type: string; updated: string };
|
|
35
51
|
// Someone the tool had who no longer has it: a member of the Chest without
|
|
@@ -37,9 +53,12 @@ export type FakeFile = { data: Uint8Array; type: string; updated: string };
|
|
|
37
53
|
// was erased (their name gone).
|
|
38
54
|
export type FakeFormer = { id: string; name?: string; status?: "no_access" | "former" | "erased" };
|
|
39
55
|
// A notification as a fake Chest keeps it: the member it went to, its text
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
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 };
|
|
43
62
|
// An alias a fake Chest maps: the model behind it, its provider (openrouter
|
|
44
63
|
// by default) and its prices in US dollars per million tokens (1 and 2 by
|
|
45
64
|
// default).
|
|
@@ -59,30 +78,60 @@ export type FakeAi = { models?: FakeAiModel[]; reply?: (request: Record<string,
|
|
|
59
78
|
// sent (null for a GET).
|
|
60
79
|
export type FakeAiCall = { path: string; body: unknown };
|
|
61
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
|
+
|
|
62
86
|
// What a fake Chest is given: the members who have the tool, those it had
|
|
63
87
|
// who no longer have it (FakeFormer), its groups, the
|
|
64
88
|
// capabilities its version holds (a capability left out answers 403;
|
|
65
89
|
// members, files, notifications and ai by default, members.email to read the
|
|
66
|
-
// addresses
|
|
67
|
-
//
|
|
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
|
|
68
95
|
// (the chest module: "Test organization", UTC, English and euros by default;
|
|
69
96
|
// the tool at https://<tool>-chest.chest.test, its public part at
|
|
70
|
-
// 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.
|
|
71
99
|
export type FakeChestOptions = {
|
|
72
100
|
members?: Member[];
|
|
73
101
|
former?: FakeFormer[];
|
|
74
102
|
groups?: FakeGroup[];
|
|
75
103
|
capabilities?: string[];
|
|
104
|
+
roles?: string[];
|
|
76
105
|
receives?: string[];
|
|
106
|
+
emits?: Record<string, FakeEmits>;
|
|
77
107
|
files?: Record<string, { data: Uint8Array | string; type?: string }>;
|
|
78
108
|
ai?: FakeAi;
|
|
109
|
+
realtime?: FakeRealtimeOptions;
|
|
79
110
|
chest?: { organization?: string; timeZone?: string; language?: string; currency?: string; teamUrl?: string; publicUrl?: string | null };
|
|
80
111
|
};
|
|
81
112
|
|
|
82
|
-
//
|
|
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
|
|
83
123
|
// when it happened (now by default) may be named, to deliver the same event
|
|
84
|
-
// twice.
|
|
85
|
-
|
|
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 };
|
|
86
135
|
|
|
87
136
|
// A run for run: its id (a new run_… by default: name it to deliver the same
|
|
88
137
|
// run twice), the time it stands for (now by default) and its attempt (1 by
|
|
@@ -94,7 +143,10 @@ export type FakeRun = { id?: string; scheduledAt?: string; attempt?: number };
|
|
|
94
143
|
// the tool it set in the environment, what it keeps (members, groups and files a test
|
|
95
144
|
// changes or reads; the notifications the tool sent, in the order sent, a
|
|
96
145
|
// replaced one last; each member's badge; the erasures the tool
|
|
97
|
-
// 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
|
|
98
150
|
// /chest-events of its address, or a handler of Web Requests — and says the
|
|
99
151
|
// status it answered, run, which delivers a run of a schedule the same way
|
|
100
152
|
// on /chest-schedules, and close, which stops it and restores the
|
|
@@ -111,7 +163,10 @@ export type FakeChest = {
|
|
|
111
163
|
badges: Map<string, number>;
|
|
112
164
|
acknowledged: string[];
|
|
113
165
|
ai: FakeAiCall[];
|
|
114
|
-
|
|
166
|
+
opens: FakeOpen[];
|
|
167
|
+
realtime: FakeRealtime;
|
|
168
|
+
emitted: FakeEmitted[];
|
|
169
|
+
deliver(event: FakeEvent, to: string | ((request: Request) => Response | Promise<Response>)): Promise<number>;
|
|
115
170
|
run(name: string, to: string | ((request: Request) => Response | Promise<Response>), run?: FakeRun): Promise<number>;
|
|
116
171
|
close(): Promise<void>;
|
|
117
172
|
};
|
|
@@ -130,30 +185,39 @@ export function signAssertion(member: Member, options: AssertionOptions = {}): s
|
|
|
130
185
|
const token = options.token ?? process.env["CHEST_TOKEN"];
|
|
131
186
|
const tool = options.tool ?? process.env["CHEST_TOOL"];
|
|
132
187
|
if (!token || !tool) throw new Error("signAssertion needs a token and a tool: start a fakeChest, or name them");
|
|
133
|
-
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_…)");
|
|
134
189
|
const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
135
190
|
// Under the label of the assertion's shape, as the Chest signs it and
|
|
136
191
|
// member() reads it.
|
|
137
192
|
return signClaims("Chest-Member v2", {
|
|
138
193
|
iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
|
|
139
194
|
given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
|
|
140
|
-
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 }),
|
|
141
196
|
language: member.language,
|
|
142
197
|
}, token);
|
|
143
198
|
}
|
|
144
199
|
|
|
145
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
|
+
|
|
146
206
|
// withMember is the request carrying that member's assertion, signed with
|
|
147
|
-
// the options of signAssertion
|
|
148
|
-
//
|
|
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.
|
|
149
210
|
export function withMember<R extends Request | IncomingMessage>(request: R, member: Member, options: AssertionOptions = {}): R {
|
|
150
211
|
const assertion = signAssertion(member, options);
|
|
212
|
+
const ticket = ticketOf?.(member.id);
|
|
151
213
|
if (request instanceof Request) {
|
|
152
214
|
const headers = new Headers(request.headers);
|
|
153
215
|
headers.set("Chest-Member", assertion);
|
|
216
|
+
if (ticket) headers.set("Chest-Opener", ticket);
|
|
154
217
|
return new Request(request, { headers }) as R;
|
|
155
218
|
}
|
|
156
219
|
(request as IncomingMessage).headers["chest-member"] = assertion;
|
|
220
|
+
if (ticket) (request as IncomingMessage).headers["chest-opener"] = ticket;
|
|
157
221
|
return request;
|
|
158
222
|
}
|
|
159
223
|
|
|
@@ -162,21 +226,54 @@ const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
|
|
|
162
226
|
const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
|
|
163
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;
|
|
164
228
|
// Its links and uploads: their paths on the team host, their lives in
|
|
165
|
-
// seconds, the images it makes thumbnails of, the
|
|
166
|
-
//
|
|
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.
|
|
167
234
|
const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/", linkLife = 900, uploadLife = 900;
|
|
168
235
|
const mediaPattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
|
|
169
236
|
const thumbnailed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
170
237
|
const starts = (...heads: string[]) => (data: Buffer): boolean => heads.some(head => data.subarray(0, head.length).equals(Buffer.from(head, "latin1")));
|
|
171
|
-
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> = {
|
|
172
244
|
"image/jpeg": starts("\xff\xd8\xff"), "image/png": starts("\x89PNG\r\n\x1a\n"), "image/gif": starts("GIF87a", "GIF89a"),
|
|
173
|
-
"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",
|
|
174
267
|
};
|
|
175
|
-
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" };
|
|
176
268
|
const newToken = (): string => randomBytes(18).toString("base64url") + "." + randomBytes(12).toString("base64url");
|
|
177
269
|
// Its notifications: recipients and badges a call, text, quotas.
|
|
178
|
-
|
|
179
|
-
|
|
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;
|
|
180
277
|
const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
|
|
181
278
|
const reordering = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
|
|
182
279
|
const cleanTitle = (s: string): string => s.replace(/[\t\r\n]/gu, " ").replace(/\p{Cc}/gu, "").replace(reordering, "").trim();
|
|
@@ -199,6 +296,20 @@ function vectorOf(text: string, dimensions: number): number[] {
|
|
|
199
296
|
return kept.map(x => x / norm);
|
|
200
297
|
}
|
|
201
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
|
+
|
|
202
313
|
const fold = (s: string): string => s.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
|
|
203
314
|
|
|
204
315
|
function send(response: ServerResponse, status: number, value?: unknown, headers: Record<string, string> = {}): void {
|
|
@@ -224,7 +335,7 @@ async function body(request: IncomingMessage, limit: number): Promise<Buffer | n
|
|
|
224
335
|
// chest and the modules of the SDK read is then this Chest's.
|
|
225
336
|
export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChest> {
|
|
226
337
|
const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications", "ai"]);
|
|
227
|
-
const email = capabilities.has("members.email");
|
|
338
|
+
const email = capabilities.has("members.email"), everyGroup = capabilities.has("members.groups");
|
|
228
339
|
const tool = process.env["CHEST_TOOL"] || "tool";
|
|
229
340
|
const token = randomBytes(32).toString("base64url");
|
|
230
341
|
const files = new Map<string, FakeFile>();
|
|
@@ -232,12 +343,29 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
232
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() });
|
|
233
344
|
}
|
|
234
345
|
const receives = options.receives ?? ["member.*"];
|
|
235
|
-
//
|
|
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.
|
|
236
356
|
const erasures = new Set<string>();
|
|
237
|
-
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 () => {} };
|
|
238
362
|
const former = [...(options.former ?? [])];
|
|
239
363
|
let window = 0, calls = 0;
|
|
240
|
-
|
|
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 } : {}) });
|
|
241
369
|
const key = (m: Member) => fold(m.name) + "\u0000" + m.id;
|
|
242
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 });
|
|
243
371
|
|
|
@@ -254,7 +382,7 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
254
382
|
const group = q.get("group"), role = q.get("role"), search = fold(q.get("q") ?? "");
|
|
255
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" });
|
|
256
384
|
const shownList = chest.members
|
|
257
|
-
.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))))
|
|
258
386
|
.filter(m => after === "" || key(m) > after)
|
|
259
387
|
.sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
|
|
260
388
|
const page = shownList.slice(0, limit);
|
|
@@ -283,7 +411,16 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
283
411
|
const m = chest.members.find(x => x.id === id);
|
|
284
412
|
return m ? send(response, 200, shown(m)) : send(response, 404, { error: "member_not_found" });
|
|
285
413
|
}
|
|
286
|
-
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
|
+
}
|
|
287
424
|
send(response, 404, { error: "not_found" });
|
|
288
425
|
}
|
|
289
426
|
|
|
@@ -309,15 +446,17 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
309
446
|
return send(response, 200, { url: `${chest.api}${linkPath}${token}`, expires_in: linkLife });
|
|
310
447
|
}
|
|
311
448
|
if (url.pathname === "/files/upload-url") {
|
|
312
|
-
const { name, max_size: maxSize, types, expires_in: expiresIn } = command;
|
|
449
|
+
const { name, max_size: maxSize, types, expires_in: expiresIn, public: visitor } = command;
|
|
313
450
|
const folder = typeof name === "string" && name.endsWith("/");
|
|
314
|
-
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" });
|
|
315
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" });
|
|
316
|
-
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" });
|
|
317
456
|
if ((maxSize ?? 0) > maxObject) return send(response, 413, { error: "too_large" });
|
|
318
457
|
const token = newToken(), life = (expiresIn as number | undefined) ?? uploadLife;
|
|
319
|
-
uploads.set(token, { name, maxSize: (maxSize as number | undefined) ?? maxObject, types: (types as string[] | undefined) ?? [], until: Date.now() + life * 1000 });
|
|
320
|
-
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 });
|
|
321
460
|
}
|
|
322
461
|
const from = command["from"], to = command["to"];
|
|
323
462
|
if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to)) return send(response, 400, { error: "invalid_name" });
|
|
@@ -349,15 +488,17 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
349
488
|
send(response, 404, { error: "not_found" });
|
|
350
489
|
}
|
|
351
490
|
|
|
352
|
-
// The team host's
|
|
353
|
-
// signed opens the content it was signed for, for its life, as
|
|
354
|
-
// the file is that content — a thumbnail is the image itself, a
|
|
355
|
-
// not reduce it —; an upload token takes one file, once, within
|
|
356
|
-
//
|
|
357
|
-
//
|
|
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:
|
|
358
499
|
// the Chest's own front does that.
|
|
359
500
|
const links = new Map<string, { name: string; object: FakeFile; until: number; download: boolean }>();
|
|
360
|
-
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 }>();
|
|
361
502
|
async function front(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
362
503
|
if (request.method === "GET" && url.pathname.startsWith(linkPath) && !url.pathname.startsWith(uploadPath)) {
|
|
363
504
|
const link = links.get(url.pathname.slice(linkPath.length));
|
|
@@ -370,11 +511,15 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
370
511
|
const token = url.pathname.slice(uploadPath.length), grant = uploads.get(token);
|
|
371
512
|
uploads.delete(token);
|
|
372
513
|
if (!grant || grant.until < Date.now()) return send(response, 403, { error: "invalid_token" });
|
|
373
|
-
|
|
374
|
-
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" });
|
|
375
516
|
const data = await body(request, grant.maxSize);
|
|
376
517
|
if (data === null) return send(response, 413, { error: "too_large" });
|
|
377
|
-
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" });
|
|
378
523
|
const name = grant.name.endsWith("/") ? grant.name + randomBytes(10).toString("hex") + (extensions[type] ?? "") : grant.name;
|
|
379
524
|
const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
|
|
380
525
|
if (total + data.length > maxTotal || (!files.has(name) && files.size >= maxObjects)) return send(response, 429, { error: "quota_exceeded" });
|
|
@@ -382,17 +527,25 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
382
527
|
send(response, 201, { name, type, size: data.length });
|
|
383
528
|
}
|
|
384
529
|
|
|
385
|
-
// The windows of
|
|
386
|
-
// counts: the tool's recipients this hour, each member's items this day,
|
|
387
|
-
// the tool's badge writes this minute.
|
|
530
|
+
// The windows of a request rate (AI), each from the first call it counts.
|
|
388
531
|
type Window = { start: number; count: number };
|
|
389
|
-
const hour: Window = { start: 0, count: 0 }, minute: Window = { start: 0, count: 0 }, days = new Map<string, Window>();
|
|
390
532
|
const live = (w: Window | undefined, span: number, now: number): boolean => w !== undefined && w.count > 0 && now - w.start < span;
|
|
391
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))) });
|
|
392
534
|
const count = (w: Window, span: number, now: number, n: number): void => {
|
|
393
535
|
if (!live(w, span, now)) [w.start, w.count] = [now, 0];
|
|
394
536
|
w.count += n;
|
|
395
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
|
+
};
|
|
396
549
|
const access = (id: string) => chest.members.some(m => m.id === id);
|
|
397
550
|
// drop removes kept notifications in place: a test may hold the list.
|
|
398
551
|
const drop = (gone: (n: FakeNotification) => boolean): void => {
|
|
@@ -400,7 +553,7 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
400
553
|
};
|
|
401
554
|
// recipients reads 1 to 500 member identifiers, each once.
|
|
402
555
|
function recipients(value: unknown): string[] | { error: string } {
|
|
403
|
-
if (!Array.isArray(value) || value.length < 1
|
|
556
|
+
if (!Array.isArray(value) || value.length < 1) return { error: "invalid_body" };
|
|
404
557
|
if (!value.every(v => typeof v === "string" && memberIdPattern.test(v))) return { error: "invalid_id" };
|
|
405
558
|
return [...new Set(value as string[])];
|
|
406
559
|
}
|
|
@@ -408,8 +561,8 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
408
561
|
async function notificationsRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
409
562
|
if (!capabilities.has("notifications")) return send(response, 403, { error: "capability_not_granted" });
|
|
410
563
|
const badge = request.method === "PUT" && url.pathname.startsWith("/badges/");
|
|
411
|
-
if (!badge && !(request.method === "PUT" && url.pathname === "/badges") && !(request.method === "POST" &&
|
|
412
|
-
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);
|
|
413
566
|
let command: Record<string, unknown> | null = null;
|
|
414
567
|
try {
|
|
415
568
|
const value = JSON.parse(raw?.toString() ?? "") as unknown;
|
|
@@ -428,14 +581,12 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
428
581
|
writes = [{ member: id, count: command!["count"] as number }];
|
|
429
582
|
} else {
|
|
430
583
|
const list = command?.["badges"];
|
|
431
|
-
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" });
|
|
432
585
|
writes = list as { member: string; count: number }[];
|
|
433
586
|
if (!writes.every(b => typeof b.member === "string" && memberIdPattern.test(b.member))) return send(response, 400, { error: "invalid_id" });
|
|
434
587
|
}
|
|
435
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" });
|
|
436
589
|
if (new Set(writes.map(b => b.member)).size !== writes.length) return send(response, 400, { error: "invalid_body" });
|
|
437
|
-
if (live(minute, 60_000, now) && minute.count + writes.length > badgesPerMinute) return send(response, 429, { error: "quota_exceeded" }, wait(minute, 60_000, now));
|
|
438
|
-
count(minute, 60_000, now, writes.length);
|
|
439
590
|
const answer = { set: [] as string[], skipped: [] as string[] };
|
|
440
591
|
for (const b of writes) {
|
|
441
592
|
if (!access(b.member)) {
|
|
@@ -457,26 +608,60 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
457
608
|
drop(n => n.key === key && (named === null || named.includes(n.member)));
|
|
458
609
|
return send(response, 204);
|
|
459
610
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
if (
|
|
464
|
-
|
|
465
|
-
|
|
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);
|
|
466
638
|
if (path !== undefined && !isPath(path)) return send(response, 400, { error: "invalid_path" });
|
|
467
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
|
+
}
|
|
468
650
|
const kept = ids.filter(access);
|
|
469
|
-
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));
|
|
470
|
-
const full = kept.map(id => days.get(id)).filter((w): w is Window => live(w, 86_400_000, now) && w!.count >= itemsPerDay);
|
|
471
|
-
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));
|
|
472
|
-
count(hour, 3_600_000, now, kept.length);
|
|
473
|
-
const cleaned = typeof text === "string" ? cleanText(text) : "";
|
|
474
651
|
for (const id of kept) {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (key !== undefined) drop(n => n.member === id && n.key === key);
|
|
478
|
-
|
|
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);
|
|
479
663
|
}
|
|
664
|
+
if (broadcast) return send(response, 204);
|
|
480
665
|
send(response, 200, { delivered: kept, skipped: ids.filter(id => !access(id)) });
|
|
481
666
|
}
|
|
482
667
|
|
|
@@ -574,7 +759,65 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
574
759
|
response.end();
|
|
575
760
|
}
|
|
576
761
|
|
|
577
|
-
//
|
|
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).
|
|
578
821
|
async function erasuresRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
579
822
|
if (!receives.includes("member.*")) return send(response, 403, { error: "capability_not_granted" });
|
|
580
823
|
const done = /^\/erasures\/([^/]+)\/done$/u.exec(url.pathname);
|
|
@@ -586,23 +829,74 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
586
829
|
send(response, 204);
|
|
587
830
|
}
|
|
588
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
|
+
|
|
589
874
|
const server = createServer((request, response) => {
|
|
590
875
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
591
|
-
const route = url.pathname.startsWith("/
|
|
876
|
+
const route = url.pathname === "/_chest/realtime" || url.pathname.startsWith("/realtime/") ? realtimeRoute
|
|
877
|
+
: url.pathname.startsWith("/_chest/files/") ? front
|
|
592
878
|
: url.pathname.startsWith("/erasures/") ? erasuresRoute
|
|
879
|
+
: url.pathname === "/events" ? eventsRoute
|
|
593
880
|
: url.pathname.startsWith("/ai/") ? aiRoute
|
|
881
|
+
: url.pathname.startsWith("/sealed/") ? sealedRoute
|
|
594
882
|
: url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
|
|
595
883
|
: url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
|
|
596
884
|
: url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
|
|
597
885
|
if (!route) return send(response, 404, { error: "not_found" });
|
|
598
886
|
route(request, response, url).catch(() => { if (!response.headersSent) send(response, 503, { error: "unavailable" }); else response.destroy(); });
|
|
599
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
|
+
});
|
|
600
894
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
601
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]]));
|
|
602
896
|
chest.api = "http://127.0.0.1:" + (server.address() as AddressInfo).port;
|
|
603
|
-
// Its links are
|
|
604
|
-
//
|
|
605
|
-
|
|
897
|
+
// Its links are on its own origin, the CHEST_API it sets: the files
|
|
898
|
+
// module takes them there (api.ts, chestLink), in this process or in a
|
|
899
|
+
// tool it starts with this environment.
|
|
606
900
|
const publicUrl = options.chest?.publicUrl === undefined ? `https://${tool}.chest.test` : options.chest.publicUrl;
|
|
607
901
|
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` });
|
|
608
902
|
if (publicUrl === null) delete process.env["CHEST_PUBLIC_URL"];
|
|
@@ -616,12 +910,14 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
616
910
|
await answer.body?.cancel();
|
|
617
911
|
return answer.status;
|
|
618
912
|
};
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
+
}
|
|
623
919
|
if (event.type === "member.erased") erasures.add(event.data.erasure);
|
|
624
|
-
return post("/chest-events", eventChannel, id,
|
|
920
|
+
return post("/chest-events", eventChannel, id, JSON.stringify({ id, type: event.type, occurredAt, data: event.data }), to);
|
|
625
921
|
};
|
|
626
922
|
chest.run = async (name, to, run = {}) => {
|
|
627
923
|
const id = run.id ?? newId("run_");
|
|
@@ -629,7 +925,8 @@ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChe
|
|
|
629
925
|
return post("/chest-schedules", scheduleChannel, id, body, to);
|
|
630
926
|
};
|
|
631
927
|
chest.close = async () => {
|
|
632
|
-
|
|
928
|
+
realtime.close();
|
|
929
|
+
ticketOf = null;
|
|
633
930
|
server.closeAllConnections();
|
|
634
931
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
635
932
|
for (const [name, value] of Object.entries(saved)) {
|