@argentic/chest-sdk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +208 -43
  2. package/client/index.ts +4 -3
  3. package/client/src/api.ts +18 -0
  4. package/client/src/chest.ts +30 -6
  5. package/client/src/events.ts +12 -104
  6. package/client/src/files.ts +9 -9
  7. package/client/src/members.ts +18 -13
  8. package/client/src/schedules.ts +87 -0
  9. package/client/src/signed.ts +166 -0
  10. package/client/src/testing.ts +132 -54
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +4 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/src/api.d.ts +2 -0
  16. package/dist/src/api.d.ts.map +1 -1
  17. package/dist/src/api.js +18 -0
  18. package/dist/src/api.js.map +1 -1
  19. package/dist/src/chest.d.ts +5 -0
  20. package/dist/src/chest.d.ts.map +1 -1
  21. package/dist/src/chest.js +13 -1
  22. package/dist/src/chest.js.map +1 -1
  23. package/dist/src/events.d.ts +3 -6
  24. package/dist/src/events.d.ts.map +1 -1
  25. package/dist/src/events.js +8 -102
  26. package/dist/src/events.js.map +1 -1
  27. package/dist/src/files.d.ts +1 -0
  28. package/dist/src/files.d.ts.map +1 -1
  29. package/dist/src/files.js +6 -7
  30. package/dist/src/files.js.map +1 -1
  31. package/dist/src/members.d.ts +1 -1
  32. package/dist/src/members.d.ts.map +1 -1
  33. package/dist/src/members.js +6 -5
  34. package/dist/src/members.js.map +1 -1
  35. package/dist/src/schedules.d.ts +15 -0
  36. package/dist/src/schedules.d.ts.map +1 -0
  37. package/dist/src/schedules.js +45 -0
  38. package/dist/src/schedules.js.map +1 -0
  39. package/dist/src/signed.d.ts +29 -0
  40. package/dist/src/signed.d.ts.map +1 -0
  41. package/dist/src/signed.js +139 -0
  42. package/dist/src/signed.js.map +1 -0
  43. package/dist/src/testing.d.ts +15 -5
  44. package/dist/src/testing.d.ts.map +1 -1
  45. package/dist/src/testing.js +124 -52
  46. package/dist/src/testing.js.map +1 -1
  47. package/package.json +14 -3
@@ -0,0 +1,139 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ // The events of the members (events): POST /chest-events.
3
+ export const eventChannel = { header: "Chest-Event", label: "Chest-Event v1", id: /^evt_[a-z2-7]{26}$/u, maxBody: 64 << 10 };
4
+ // The runs of the schedules (schedules): POST /chest-schedules.
5
+ export const scheduleChannel = { header: "Chest-Schedule", label: "Chest-Schedule v1", id: /^run_[a-z2-7]{26}$/u, maxBody: 1024 };
6
+ const claims = ["aud", "iat", "exp", "jti", "digest"];
7
+ // Clocks of the Chest and of the container may differ by this much, in seconds.
8
+ const skew = 5;
9
+ const maxSignature = 2048;
10
+ const compact = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/u;
11
+ export function object(value) {
12
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
13
+ }
14
+ export function json(raw) {
15
+ try {
16
+ return JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ // An instant as the Chest writes it (RFC 3339).
23
+ export const instant = (v) => typeof v === "string" && v.length <= 40 && !Number.isNaN(Date.parse(v));
24
+ // headerValue is the value of a header, maxLength at most; null for none,
25
+ // longer, or repeated (a Web Request joins repeated values with ", ", which
26
+ // no signature contains; a Node request keeps them as an array).
27
+ export function headerValue(request, name, maxLength) {
28
+ const headers = request.headers;
29
+ const value = typeof headers.get === "function" ? headers.get(name) : headers[name];
30
+ return typeof value === "string" && value.length <= maxLength ? value : null;
31
+ }
32
+ // signedClaims reads what the Chest signed for this tool under a label: the
33
+ // claims of a compact JWS, HS256, typ JWT, under HMAC-SHA256 of the label
34
+ // keyed by the text of CHEST_TOKEN, when the signature holds, its audience
35
+ // is CHEST_TOOL and now is within iat and exp (5 s of skew each way); null
36
+ // otherwise, or outside a Chest. It never throws for what it is given.
37
+ export function signedClaims(value, label) {
38
+ const token = process.env["CHEST_TOKEN"];
39
+ const tool = process.env["CHEST_TOOL"];
40
+ if (!token || !/^[A-Za-z0-9_-]{43,512}$/u.test(token) || !tool)
41
+ return null;
42
+ const parts = value === null ? null : compact.exec(value);
43
+ if (!parts)
44
+ return null;
45
+ const [, encodedHeader = "", encodedPayload = "", encodedSignature = ""] = parts;
46
+ const header = object(json(Buffer.from(encodedHeader, "base64url").toString("utf8")));
47
+ if (!header || Object.keys(header).length !== 2 || header["alg"] !== "HS256" || header["typ"] !== "JWT")
48
+ return null;
49
+ const key = createHmac("sha256", Buffer.from(token, "utf8")).update(label).digest();
50
+ const expected = createHmac("sha256", key).update(encodedHeader + "." + encodedPayload).digest();
51
+ const given = Buffer.from(encodedSignature, "base64url");
52
+ if (given.length !== expected.length || !timingSafeEqual(given, expected))
53
+ return null;
54
+ const payload = object(json(Buffer.from(encodedPayload, "base64url").toString("utf8")));
55
+ if (!payload || payload["aud"] !== tool)
56
+ return null;
57
+ const { iat, exp } = payload;
58
+ if (typeof iat !== "number" || !Number.isSafeInteger(iat) || typeof exp !== "number" || !Number.isSafeInteger(exp) || exp <= iat)
59
+ return null;
60
+ const now = Math.floor(Date.now() / 1000);
61
+ return iat > now + skew || exp <= now - skew ? null : payload;
62
+ }
63
+ // signClaims is what the Chest would sign under a label for these claims
64
+ // (their aud, iat and exp included), with that token: for the fake Chest of
65
+ // a tool's tests (testing).
66
+ export function signClaims(label, claims, token) {
67
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
68
+ const signed = encode({ alg: "HS256", typ: "JWT" }) + "." + encode(claims);
69
+ const key = createHmac("sha256", Buffer.from(token, "utf8")).update(label).digest();
70
+ return signed + "." + createHmac("sha256", key).update(signed).digest("base64url");
71
+ }
72
+ // bodyOf reads the body, limit bytes at most; null beyond, or when it was
73
+ // read already.
74
+ async function bodyOf(request, limit) {
75
+ try {
76
+ if (request instanceof Request) {
77
+ if (request.bodyUsed || Number(request.headers.get("content-length") ?? "0") > limit)
78
+ return null;
79
+ const raw = Buffer.from(await request.arrayBuffer());
80
+ return raw.length <= limit ? raw : null;
81
+ }
82
+ const chunks = [];
83
+ let size = 0;
84
+ for await (const chunk of request) {
85
+ size += chunk.length;
86
+ if (size > limit)
87
+ return null;
88
+ chunks.push(chunk);
89
+ }
90
+ return Buffer.concat(chunks);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ // delivery reads a request of a channel: the identifier it was signed for
97
+ // and its body, when the signature, the tool, the time and the digest of
98
+ // the body hold; null otherwise — not a POST, no or another signature,
99
+ // another tool, expired, a body other than the one signed. It reads the
100
+ // body. It never throws for what a request carries.
101
+ export async function delivery(request, channel) {
102
+ if (request.method !== "POST")
103
+ return null;
104
+ const payload = signedClaims(headerValue(request, channel.header.toLowerCase(), maxSignature), channel.label);
105
+ if (!payload || Object.keys(payload).length !== claims.length || !claims.every(name => Object.hasOwn(payload, name)))
106
+ return null;
107
+ const { jti, digest } = payload;
108
+ if (typeof jti !== "string" || !channel.id.test(jti) || typeof digest !== "string")
109
+ return null;
110
+ const body = await bodyOf(request, channel.maxBody);
111
+ if (body === null)
112
+ return null;
113
+ const sum = createHash("sha256").update(body).digest();
114
+ const told = Buffer.from(digest, "base64url");
115
+ if (told.length !== sum.length || !timingSafeEqual(told, sum))
116
+ return null;
117
+ return { id: jti, body };
118
+ }
119
+ // sign is the value of a channel's header the Chest would send with that
120
+ // body: for the fake Chest of a tool's tests (testing).
121
+ export function sign(channel, id, body, options) {
122
+ const iat = Math.floor(Date.now() / 1000);
123
+ return signClaims(channel.label, { aud: options.tool, iat, exp: iat + 60, jti: id, digest: createHash("sha256").update(body).digest("base64url") }, options.token);
124
+ }
125
+ // memorySeen keeps the last limit ids in this process: lost at a restart,
126
+ // enough for a tool whose handlers are idempotent anyway.
127
+ export function memorySeen(limit = 10000) {
128
+ const ids = new Set();
129
+ return {
130
+ has: id => ids.has(id),
131
+ add: id => {
132
+ ids.delete(id);
133
+ ids.add(id);
134
+ while (ids.size > limit)
135
+ ids.delete(ids.values().next().value);
136
+ },
137
+ };
138
+ }
139
+ //# sourceMappingURL=signed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signed.js","sourceRoot":"","sources":["../../client/src/signed.ts"],"names":[],"mappings":"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"}
@@ -13,6 +13,11 @@ export type FakeFile = {
13
13
  type: string;
14
14
  updated: string;
15
15
  };
16
+ export type FakeFormer = {
17
+ id: string;
18
+ name?: string;
19
+ status?: "no_access" | "former" | "erased";
20
+ };
16
21
  export type FakeNotification = {
17
22
  member: string;
18
23
  title: string;
@@ -47,11 +52,7 @@ export type FakeAiCall = {
47
52
  };
48
53
  export type FakeChestOptions = {
49
54
  members?: Member[];
50
- former?: {
51
- id: string;
52
- name?: string;
53
- erased?: boolean;
54
- }[];
55
+ former?: FakeFormer[];
55
56
  groups?: FakeGroup[];
56
57
  capabilities?: string[];
57
58
  receives?: string[];
@@ -64,6 +65,9 @@ export type FakeChestOptions = {
64
65
  organization?: string;
65
66
  timeZone?: string;
66
67
  language?: string;
68
+ currency?: string;
69
+ teamUrl?: string;
70
+ publicUrl?: string | null;
67
71
  };
68
72
  };
69
73
  export type FakeEvent = {
@@ -76,6 +80,11 @@ export type FakeEvent = {
76
80
  occurredAt?: string;
77
81
  };
78
82
  }[ChestEvent["type"]];
83
+ export type FakeRun = {
84
+ id?: string;
85
+ scheduledAt?: string;
86
+ attempt?: number;
87
+ };
79
88
  export type FakeChest = {
80
89
  api: string;
81
90
  token: string;
@@ -88,6 +97,7 @@ export type FakeChest = {
88
97
  acknowledged: string[];
89
98
  ai: FakeAiCall[];
90
99
  emit(event: FakeEvent, to: string | ((request: Request) => Response | Promise<Response>)): Promise<number>;
100
+ run(name: string, to: string | ((request: Request) => Response | Promise<Response>), run?: FakeRun): Promise<number>;
91
101
  close(): Promise<void>;
92
102
  };
93
103
  export type AssertionOptions = {
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../../client/src/testing.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../../client/src/testing.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,9 @@
1
- import { createHash, createHmac, randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import { groupIdPattern, memberIdPattern } from "./member.js";
4
4
  import { forget } from "./members.js";
5
- const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
5
+ import { fakeOrigins } from "./api.js";
6
+ import { eventChannel, scheduleChannel, sign, signClaims } from "./signed.js";
6
7
  // signAssertion is the Chest-Member value the Chest's front would send for
7
8
  // that member: HS256 under the key of the token, for the tool, valid 60
8
9
  // seconds from when it is issued.
@@ -14,25 +15,14 @@ export function signAssertion(member, options = {}) {
14
15
  if (!memberIdPattern.test(member.id) || !member.groups.every(g => groupIdPattern.test(g)))
15
16
  throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
16
17
  const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
17
- const body = encode({ alg: "HS256", typ: "JWT" }) + "." + encode({
18
+ // Under the label of the assertion's shape, as the Chest signs it and
19
+ // member() reads it.
20
+ return signClaims("Chest-Member v2", {
18
21
  iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
19
22
  given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
20
23
  admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, time_zone: member.timeZone, ...(member.email === undefined ? {} : { email: member.email }),
21
24
  language: member.language,
22
- });
23
- // The key as the Chest derives it, and member() reads it: HMAC-SHA256 of
24
- // the label of the assertion's shape under the text of the token.
25
- const key = createHmac("sha256", Buffer.from(token, "utf8")).update("Chest-Member v2").digest();
26
- return body + "." + createHmac("sha256", key).update(body).digest("base64url");
27
- }
28
- // signEvent is the Chest-Event value the Chest would send with that body:
29
- // HS256 under the key the token derives for events, for the tool, naming the
30
- // event and the digest of the body, valid 60 seconds.
31
- function signEvent(id, body, options) {
32
- const iat = Math.floor(Date.now() / 1000);
33
- const signed = encode({ alg: "HS256", typ: "JWT" }) + "." + encode({ aud: options.tool, iat, exp: iat + 60, jti: id, digest: createHash("sha256").update(body).digest("base64url") });
34
- const key = createHmac("sha256", Buffer.from(options.token, "utf8")).update("Chest-Event v1").digest();
35
- return signed + "." + createHmac("sha256", key).update(signed).digest("base64url");
25
+ }, token);
36
26
  }
37
27
  // withMember is the request carrying that member's assertion, signed with
38
28
  // the options of signAssertion: a new Web Request, or the same Node request
@@ -51,6 +41,19 @@ export function withMember(request, member, options = {}) {
51
41
  const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
52
42
  const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
53
43
  const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}(\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}){0,7}$/u;
44
+ // Its links and uploads: their paths on the team host, their lives in
45
+ // seconds, the images it makes thumbnails of, the first bytes an upload of
46
+ // a type must start with, the endings of the names it chooses (chest/toolfiles).
47
+ const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/", linkLife = 900, uploadLife = 900;
48
+ const mediaPattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
49
+ const thumbnailed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
50
+ const starts = (...heads) => (data) => heads.some(head => data.subarray(0, head.length).equals(Buffer.from(head, "latin1")));
51
+ const signatures = {
52
+ "image/jpeg": starts("\xff\xd8\xff"), "image/png": starts("\x89PNG\r\n\x1a\n"), "image/gif": starts("GIF87a", "GIF89a"),
53
+ "image/webp": data => data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP", "application/pdf": starts("%PDF-"),
54
+ };
55
+ const extensions = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", "image/heic": ".heic", "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json", "application/zip": ".zip", "video/mp4": ".mp4", "audio/mpeg": ".mp3" };
56
+ const newToken = () => randomBytes(18).toString("base64url") + "." + randomBytes(12).toString("base64url");
54
57
  // Its notifications: recipients and badges a call, text, quotas.
55
58
  const maxRecipients = 500, maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999;
56
59
  const recipientsPerHour = 1000, itemsPerDay = 100, badgesPerMinute = 600;
@@ -110,12 +113,12 @@ export async function fakeChest(options = {}) {
110
113
  const receives = options.receives ?? ["member.*"];
111
114
  // The erasures the tool was told of, by emit: those it may acknowledge.
112
115
  const erasures = new Set();
113
- const chest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], ai: [], emit: async () => 0, close: async () => { } };
116
+ const chest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], ai: [], emit: async () => 0, run: async () => 0, close: async () => { } };
114
117
  const former = [...(options.former ?? [])];
115
118
  let window = 0, calls = 0;
116
119
  const shown = (m) => ({ id: m.id, first_name: m.firstName, last_name: m.lastName, name: m.name, photo: m.photo, role: m.role, admin: m.isAdmin, builder: m.isBuilder, groups: m.groups, language: m.language, time_zone: m.timeZone, ...(email && m.email !== undefined ? { email: m.email } : {}) });
117
120
  const key = (m) => fold(m.name) + "\u0000" + m.id;
118
- const described = (name, f) => ({ name, type: f.type, size: f.data.byteLength, updated: f.updated });
121
+ const described = (name, f) => ({ name, type: f.type, size: f.data.byteLength, sha256: createHash("sha256").update(f.data).digest("hex"), updated: f.updated });
119
122
  async function members(request, response, url) {
120
123
  if (!capabilities.has("members"))
121
124
  return send(response, 403, { error: "capability_not_granted" });
@@ -158,8 +161,10 @@ export async function fakeChest(options = {}) {
158
161
  const m = chest.members.find(x => x.id === id), f = former.find(x => x.id === id);
159
162
  if (m)
160
163
  answer.members.push(shown(m));
161
- else if (f?.erased)
164
+ else if (f?.status === "erased")
162
165
  answer.former.push({ id, status: "erased" });
166
+ else if (f?.status === "no_access")
167
+ answer.former.push({ id, name: f.name ?? "Test member", status: "no_access" });
163
168
  else if (f)
164
169
  answer.former.push({ id, ...(f.name ? { name: f.name } : {}), status: "former" });
165
170
  else
@@ -186,22 +191,7 @@ export async function fakeChest(options = {}) {
186
191
  const names = [...files.keys()].filter(n => n.startsWith(prefix) && n > after).sort();
187
192
  return send(response, 200, { files: names.slice(0, 1000).map(n => described(n, files.get(n))), next: names.length > 1000 ? names[999] : null });
188
193
  }
189
- if (request.method === "POST" && url.pathname === "/files/url") {
190
- const raw = await body(request, 4096);
191
- let name;
192
- try {
193
- name = JSON.parse(raw?.toString() ?? "").name;
194
- }
195
- catch {
196
- name = undefined;
197
- }
198
- if (typeof name !== "string")
199
- return send(response, 400, { error: "invalid_body" });
200
- if (!files.has(name))
201
- return send(response, 404, { error: "not_found" });
202
- return send(response, 200, { url: `https://${tool}-chest.chest.test/_chest/files/${Buffer.from(name).toString("base64url")}.fake`, expires_in: 900 });
203
- }
204
- if (request.method === "POST" && (url.pathname === "/files/move" || url.pathname === "/files/upload-url")) {
194
+ if (request.method === "POST" && (url.pathname === "/files/url" || url.pathname === "/files/move" || url.pathname === "/files/upload-url")) {
205
195
  const raw = await body(request, 4096);
206
196
  let command = {};
207
197
  try {
@@ -210,12 +200,33 @@ export async function fakeChest(options = {}) {
210
200
  catch {
211
201
  command = {};
212
202
  }
203
+ if (url.pathname === "/files/url") {
204
+ const { name, thumbnail, download } = command;
205
+ if (typeof name !== "string" || !(thumbnail === undefined || thumbnail === 256 || thumbnail === 1024) || !(download === undefined || typeof download === "boolean"))
206
+ return send(response, 400, { error: "invalid_body" });
207
+ const object = files.get(name);
208
+ if (!object)
209
+ return send(response, 404, { error: "not_found" });
210
+ if (thumbnail !== undefined && !thumbnailed.includes(object.type))
211
+ return send(response, 400, { error: "no_thumbnail" });
212
+ const token = newToken();
213
+ links.set(token, { name, object, until: Date.now() + linkLife * 1000, download: download === true });
214
+ return send(response, 200, { url: `${chest.api}${linkPath}${token}`, expires_in: linkLife });
215
+ }
213
216
  if (url.pathname === "/files/upload-url") {
214
- // The upload itself goes from a member's browser to the team host,
215
- // which a fake Chest does not play: it only authorises it.
216
- if (typeof command["name"] !== "string")
217
+ const { name, max_size: maxSize, types, expires_in: expiresIn } = command;
218
+ const folder = typeof name === "string" && name.endsWith("/");
219
+ if (typeof name !== "string" || !namePattern.test(folder ? name.slice(0, -1) : name))
220
+ return send(response, 400, { error: "invalid_name" });
221
+ if (!(types === undefined || (Array.isArray(types) && types.length <= 8 && types.every(t => typeof t === "string" && mediaPattern.test(t)))))
222
+ return send(response, 400, { error: "invalid_type" });
223
+ if (!(maxSize === undefined || (typeof maxSize === "number" && Number.isSafeInteger(maxSize) && maxSize > 0)) || !(expiresIn === undefined || (typeof expiresIn === "number" && Number.isInteger(expiresIn) && expiresIn > 0 && expiresIn <= uploadLife)))
217
224
  return send(response, 400, { error: "invalid_body" });
218
- return send(response, 200, { url: `https://${tool}-chest.chest.test/_chest/files/upload/${Buffer.from(command["name"]).toString("base64url")}.fake`, method: "PUT", expires_in: typeof command["expires_in"] === "number" ? command["expires_in"] : 900 });
225
+ if ((maxSize ?? 0) > maxObject)
226
+ return send(response, 413, { error: "too_large" });
227
+ const token = newToken(), life = expiresIn ?? uploadLife;
228
+ uploads.set(token, { name, maxSize: maxSize ?? maxObject, types: types ?? [], until: Date.now() + life * 1000 });
229
+ return send(response, 200, { url: `${chest.api}${uploadPath}${token}`, method: "PUT", expires_in: life });
219
230
  }
220
231
  const from = command["from"], to = command["to"];
221
232
  if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to))
@@ -254,6 +265,46 @@ export async function fakeChest(options = {}) {
254
265
  }
255
266
  send(response, 404, { error: "not_found" });
256
267
  }
268
+ // The team host's part of the files, on the fake's own origin: a link it
269
+ // signed opens the content it was signed for, for its life, as long as
270
+ // the file is that content — a thumbnail is the image itself, a fake does
271
+ // not reduce it —; an upload token takes one file, once, within its life,
272
+ // of the types it names and up to its size, whose first bytes are those of
273
+ // its type, named by the Chest in a folder. Nobody's session is checked:
274
+ // the Chest's own front does that.
275
+ const links = new Map();
276
+ const uploads = new Map();
277
+ async function front(request, response, url) {
278
+ if (request.method === "GET" && url.pathname.startsWith(linkPath) && !url.pathname.startsWith(uploadPath)) {
279
+ const link = links.get(url.pathname.slice(linkPath.length));
280
+ if (!link || link.until < Date.now() || files.get(link.name) !== link.object)
281
+ return send(response, 404, { error: "not_found" });
282
+ const headers = { "Content-Type": link.object.type, "Content-Length": String(link.object.data.byteLength), "X-Content-Type-Options": "nosniff" };
283
+ if (link.download)
284
+ headers["Content-Disposition"] = `attachment; filename="${link.name.split("/").pop()}"`;
285
+ return void response.writeHead(200, headers).end(link.object.data);
286
+ }
287
+ if (request.method !== "PUT" || !url.pathname.startsWith(uploadPath))
288
+ return send(response, 404, { error: "not_found" });
289
+ const token = url.pathname.slice(uploadPath.length), grant = uploads.get(token);
290
+ uploads.delete(token);
291
+ if (!grant || grant.until < Date.now())
292
+ return send(response, 403, { error: "invalid_token" });
293
+ const type = (request.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
294
+ if (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !grant.types.some(t => t === type || (t.endsWith("/*") && type.startsWith(t.slice(0, -1))))))
295
+ return send(response, 415, { error: "type_refused" });
296
+ const data = await body(request, grant.maxSize);
297
+ if (data === null)
298
+ return send(response, 413, { error: "too_large" });
299
+ if (signatures[type] && !signatures[type](data))
300
+ return send(response, 400, { error: "type_mismatch" });
301
+ const name = grant.name.endsWith("/") ? grant.name + randomBytes(10).toString("hex") + (extensions[type] ?? "") : grant.name;
302
+ const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
303
+ if (total + data.length > maxTotal || (!files.has(name) && files.size >= maxObjects))
304
+ return send(response, 429, { error: "quota_exceeded" });
305
+ files.set(name, { data: new Uint8Array(data), type, updated: new Date().toISOString() });
306
+ send(response, 201, { name, type, size: data.length });
307
+ }
257
308
  const hour = { start: 0, count: 0 }, minute = { start: 0, count: 0 }, days = new Map();
258
309
  const live = (w, span, now) => w !== undefined && w.count > 0 && now - w.start < span;
259
310
  const wait = (w, span, now) => ({ "Retry-After": String(Math.max(1, Math.ceil((w.start + span - now) / 1000))) });
@@ -509,11 +560,12 @@ export async function fakeChest(options = {}) {
509
560
  }
510
561
  const server = createServer((request, response) => {
511
562
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
512
- const route = url.pathname.startsWith("/erasures/") ? erasuresRoute
513
- : url.pathname.startsWith("/ai/") ? aiRoute
514
- : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
515
- : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
516
- : url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
563
+ const route = url.pathname.startsWith("/_chest/files/") ? front
564
+ : url.pathname.startsWith("/erasures/") ? erasuresRoute
565
+ : url.pathname.startsWith("/ai/") ? aiRoute
566
+ : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
567
+ : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
568
+ : url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
517
569
  if (!route)
518
570
  return send(response, 404, { error: "not_found" });
519
571
  route(request, response, url).catch(() => { if (!response.headersSent)
@@ -522,21 +574,41 @@ export async function fakeChest(options = {}) {
522
574
  response.destroy(); });
523
575
  });
524
576
  await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
525
- const saved = Object.fromEntries(["CHEST_API", "CHEST_TOKEN", "CHEST_TOOL", "CHEST_ORGANIZATION", "CHEST_TIME_ZONE", "CHEST_LANGUAGE"].map(name => [name, process.env[name]]));
577
+ 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]]));
526
578
  chest.api = "http://127.0.0.1:" + server.address().port;
527
- 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" });
579
+ // Its links are taken by the files module of this process, for as long as
580
+ // it runs (api.ts, fakeOrigins).
581
+ fakeOrigins.add(chest.api);
582
+ const publicUrl = options.chest?.publicUrl === undefined ? `https://${tool}.chest.test` : options.chest.publicUrl;
583
+ Object.assign(process.env, { CHEST_API: chest.api, CHEST_TOKEN: token, CHEST_TOOL: tool, CHEST_ORGANIZATION: options.chest?.organization ?? "Test organization", CHEST_TIME_ZONE: options.chest?.timeZone ?? "UTC", CHEST_LANGUAGE: options.chest?.language ?? "en", CHEST_CURRENCY: options.chest?.currency ?? "EUR", CHEST_TEAM_URL: options.chest?.teamUrl ?? `https://${tool}-chest.chest.test` });
584
+ if (publicUrl === null)
585
+ delete process.env["CHEST_PUBLIC_URL"];
586
+ else
587
+ process.env["CHEST_PUBLIC_URL"] = publicUrl;
528
588
  forget();
589
+ // post delivers what the Chest posts to the tool on a channel — at its
590
+ // address, or to a handler — and says the status it answered.
591
+ const post = async (path, channel, id, body, to) => {
592
+ const request = new Request((typeof to === "string" ? to.replace(/\/$/u, "") : "http://tool.test") + path, { method: "POST", headers: { "Content-Type": "application/json", [channel.header]: sign(channel, id, body, { token, tool }) }, body });
593
+ const answer = typeof to === "string" ? await fetch(request, { redirect: "manual" }) : await to(request);
594
+ await answer.body?.cancel();
595
+ return answer.status;
596
+ };
597
+ const newId = (prefix) => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
529
598
  chest.emit = async (event, to) => {
530
- const id = event.id ?? "evt_" + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
599
+ const id = event.id ?? newId("evt_");
531
600
  const body = JSON.stringify({ id, type: event.type, occurredAt: event.occurredAt ?? new Date().toISOString(), data: event.data });
532
601
  if (event.type === "member.erased")
533
602
  erasures.add(event.data.erasure);
534
- const request = new Request(typeof to === "string" ? to.replace(/\/$/u, "") + "/chest-events" : "http://tool.test/chest-events", { method: "POST", headers: { "Content-Type": "application/json", "Chest-Event": signEvent(id, body, { token, tool }) }, body });
535
- const answer = typeof to === "string" ? await fetch(request, { redirect: "manual" }) : await to(request);
536
- await answer.body?.cancel();
537
- return answer.status;
603
+ return post("/chest-events", eventChannel, id, body, to);
604
+ };
605
+ chest.run = async (name, to, run = {}) => {
606
+ const id = run.id ?? newId("run_");
607
+ const body = JSON.stringify({ id, name, scheduledAt: run.scheduledAt ?? new Date().toISOString(), attempt: run.attempt ?? 1 });
608
+ return post("/chest-schedules", scheduleChannel, id, body, to);
538
609
  };
539
610
  chest.close = async () => {
611
+ fakeOrigins.delete(chest.api);
540
612
  server.closeAllConnections();
541
613
  await new Promise(resolve => server.close(() => resolve()));
542
614
  for (const [name, value] of Object.entries(saved)) {