@argentic/chest-sdk 0.3.0 → 0.4.1

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 (51) hide show
  1. package/README.md +210 -44
  2. package/client/index.ts +4 -3
  3. package/client/src/api.ts +12 -0
  4. package/client/src/chest.ts +30 -6
  5. package/client/src/database.ts +6 -1
  6. package/client/src/events.ts +12 -104
  7. package/client/src/files.ts +9 -9
  8. package/client/src/members.ts +18 -13
  9. package/client/src/schedules.ts +87 -0
  10. package/client/src/signed.ts +166 -0
  11. package/client/src/testing.ts +130 -54
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +4 -3
  15. package/dist/index.js.map +1 -1
  16. package/dist/src/api.d.ts +1 -0
  17. package/dist/src/api.d.ts.map +1 -1
  18. package/dist/src/api.js +13 -0
  19. package/dist/src/api.js.map +1 -1
  20. package/dist/src/chest.d.ts +5 -0
  21. package/dist/src/chest.d.ts.map +1 -1
  22. package/dist/src/chest.js +13 -1
  23. package/dist/src/chest.js.map +1 -1
  24. package/dist/src/database.d.ts.map +1 -1
  25. package/dist/src/database.js +5 -1
  26. package/dist/src/database.js.map +1 -1
  27. package/dist/src/events.d.ts +3 -6
  28. package/dist/src/events.d.ts.map +1 -1
  29. package/dist/src/events.js +8 -102
  30. package/dist/src/events.js.map +1 -1
  31. package/dist/src/files.d.ts +1 -0
  32. package/dist/src/files.d.ts.map +1 -1
  33. package/dist/src/files.js +6 -7
  34. package/dist/src/files.js.map +1 -1
  35. package/dist/src/members.d.ts +1 -1
  36. package/dist/src/members.d.ts.map +1 -1
  37. package/dist/src/members.js +6 -5
  38. package/dist/src/members.js.map +1 -1
  39. package/dist/src/schedules.d.ts +15 -0
  40. package/dist/src/schedules.d.ts.map +1 -0
  41. package/dist/src/schedules.js +45 -0
  42. package/dist/src/schedules.js.map +1 -0
  43. package/dist/src/signed.d.ts +29 -0
  44. package/dist/src/signed.d.ts.map +1 -0
  45. package/dist/src/signed.js +139 -0
  46. package/dist/src/signed.js.map +1 -0
  47. package/dist/src/testing.d.ts +15 -5
  48. package/dist/src/testing.d.ts.map +1 -1
  49. package/dist/src/testing.js +122 -52
  50. package/dist/src/testing.js.map +1 -1
  51. 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":"AACA,OAAO,EAAgB,KAAK,eAAe,EAAuB,MAAM,WAAW,CAAC;AAEpF,OAAO,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAmC,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AAwB3E,MAAM,MAAM,SAAS,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAExE,MAAM,MAAM,QAAQ,GAAG;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAI3E,MAAM,MAAM,UAAU,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,GAAG,QAAQ,GAAG,QAAQ,CAAA;CAAE,CAAC;AAInG,MAAM,MAAM,gBAAgB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAI5G,MAAM,MAAM,WAAW,GAAG;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAGhH,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAAE,CAAC;AAQrH,MAAM,MAAM,MAAM,GAAG;IAAE,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,WAAW,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAAC;AAG5J,MAAM,MAAM,UAAU,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAWzD,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,CAAC,EAAE,UAAU,EAAE,CAAC;IACtB,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACrE,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CACzI,CAAC;AAKF,MAAM,MAAM,SAAS,GAAG;KAAG,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC,GAAG;QAAE,IAAI,EAAE,CAAC,CAAC;QAAC,IAAI,EAAE,OAAO,CAAC,UAAU,EAAE;YAAE,IAAI,EAAE,CAAC,CAAA;SAAE,CAAC,CAAC,MAAM,CAAC,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE;CAAE,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;AAKzK,MAAM,MAAM,OAAO,GAAG;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAa9E,MAAM,MAAM,SAAS,GAAG;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAC7B,aAAa,EAAE,gBAAgB,EAAE,CAAC;IAClC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,EAAE,EAAE,UAAU,EAAE,CAAC;IACjB,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3G,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACrH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB,CAAC;AAOF,MAAM,MAAM,gBAAgB,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,IAAI,CAAA;CAAE,CAAC;AAK7E,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAcpF;AAMD,wBAAgB,UAAU,CAAC,CAAC,SAAS,OAAO,GAAG,eAAe,EAAE,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,CAAC,CAS7H;AAmED,wBAAsB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC,CAgalF"}
@@ -1,8 +1,8 @@
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 { eventChannel, scheduleChannel, sign, signClaims } from "./signed.js";
6
6
  // signAssertion is the Chest-Member value the Chest's front would send for
7
7
  // that member: HS256 under the key of the token, for the tool, valid 60
8
8
  // seconds from when it is issued.
@@ -14,25 +14,14 @@ export function signAssertion(member, options = {}) {
14
14
  if (!memberIdPattern.test(member.id) || !member.groups.every(g => groupIdPattern.test(g)))
15
15
  throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
16
16
  const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
17
- const body = encode({ alg: "HS256", typ: "JWT" }) + "." + encode({
17
+ // Under the label of the assertion's shape, as the Chest signs it and
18
+ // member() reads it.
19
+ return signClaims("Chest-Member v2", {
18
20
  iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
19
21
  given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
20
22
  admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, time_zone: member.timeZone, ...(member.email === undefined ? {} : { email: member.email }),
21
23
  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");
24
+ }, token);
36
25
  }
37
26
  // withMember is the request carrying that member's assertion, signed with
38
27
  // the options of signAssertion: a new Web Request, or the same Node request
@@ -51,6 +40,19 @@ export function withMember(request, member, options = {}) {
51
40
  const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
52
41
  const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
53
42
  const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}(\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}){0,7}$/u;
43
+ // Its links and uploads: their paths on the team host, their lives in
44
+ // seconds, the images it makes thumbnails of, the first bytes an upload of
45
+ // a type must start with, the endings of the names it chooses (chest/toolfiles).
46
+ const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/", linkLife = 900, uploadLife = 900;
47
+ const mediaPattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
48
+ const thumbnailed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
49
+ const starts = (...heads) => (data) => heads.some(head => data.subarray(0, head.length).equals(Buffer.from(head, "latin1")));
50
+ const signatures = {
51
+ "image/jpeg": starts("\xff\xd8\xff"), "image/png": starts("\x89PNG\r\n\x1a\n"), "image/gif": starts("GIF87a", "GIF89a"),
52
+ "image/webp": data => data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP", "application/pdf": starts("%PDF-"),
53
+ };
54
+ const extensions = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", "image/heic": ".heic", "application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json", "application/zip": ".zip", "video/mp4": ".mp4", "audio/mpeg": ".mp3" };
55
+ const newToken = () => randomBytes(18).toString("base64url") + "." + randomBytes(12).toString("base64url");
54
56
  // Its notifications: recipients and badges a call, text, quotas.
55
57
  const maxRecipients = 500, maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999;
56
58
  const recipientsPerHour = 1000, itemsPerDay = 100, badgesPerMinute = 600;
@@ -110,12 +112,12 @@ export async function fakeChest(options = {}) {
110
112
  const receives = options.receives ?? ["member.*"];
111
113
  // The erasures the tool was told of, by emit: those it may acknowledge.
112
114
  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 () => { } };
115
+ 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
116
  const former = [...(options.former ?? [])];
115
117
  let window = 0, calls = 0;
116
118
  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
119
  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 });
120
+ const described = (name, f) => ({ name, type: f.type, size: f.data.byteLength, sha256: createHash("sha256").update(f.data).digest("hex"), updated: f.updated });
119
121
  async function members(request, response, url) {
120
122
  if (!capabilities.has("members"))
121
123
  return send(response, 403, { error: "capability_not_granted" });
@@ -158,8 +160,10 @@ export async function fakeChest(options = {}) {
158
160
  const m = chest.members.find(x => x.id === id), f = former.find(x => x.id === id);
159
161
  if (m)
160
162
  answer.members.push(shown(m));
161
- else if (f?.erased)
163
+ else if (f?.status === "erased")
162
164
  answer.former.push({ id, status: "erased" });
165
+ else if (f?.status === "no_access")
166
+ answer.former.push({ id, name: f.name ?? "Test member", status: "no_access" });
163
167
  else if (f)
164
168
  answer.former.push({ id, ...(f.name ? { name: f.name } : {}), status: "former" });
165
169
  else
@@ -186,22 +190,7 @@ export async function fakeChest(options = {}) {
186
190
  const names = [...files.keys()].filter(n => n.startsWith(prefix) && n > after).sort();
187
191
  return send(response, 200, { files: names.slice(0, 1000).map(n => described(n, files.get(n))), next: names.length > 1000 ? names[999] : null });
188
192
  }
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")) {
193
+ if (request.method === "POST" && (url.pathname === "/files/url" || url.pathname === "/files/move" || url.pathname === "/files/upload-url")) {
205
194
  const raw = await body(request, 4096);
206
195
  let command = {};
207
196
  try {
@@ -210,12 +199,33 @@ export async function fakeChest(options = {}) {
210
199
  catch {
211
200
  command = {};
212
201
  }
202
+ if (url.pathname === "/files/url") {
203
+ const { name, thumbnail, download } = command;
204
+ if (typeof name !== "string" || !(thumbnail === undefined || thumbnail === 256 || thumbnail === 1024) || !(download === undefined || typeof download === "boolean"))
205
+ return send(response, 400, { error: "invalid_body" });
206
+ const object = files.get(name);
207
+ if (!object)
208
+ return send(response, 404, { error: "not_found" });
209
+ if (thumbnail !== undefined && !thumbnailed.includes(object.type))
210
+ return send(response, 400, { error: "no_thumbnail" });
211
+ const token = newToken();
212
+ links.set(token, { name, object, until: Date.now() + linkLife * 1000, download: download === true });
213
+ return send(response, 200, { url: `${chest.api}${linkPath}${token}`, expires_in: linkLife });
214
+ }
213
215
  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")
216
+ const { name, max_size: maxSize, types, expires_in: expiresIn } = command;
217
+ const folder = typeof name === "string" && name.endsWith("/");
218
+ if (typeof name !== "string" || !namePattern.test(folder ? name.slice(0, -1) : name))
219
+ return send(response, 400, { error: "invalid_name" });
220
+ if (!(types === undefined || (Array.isArray(types) && types.length <= 8 && types.every(t => typeof t === "string" && mediaPattern.test(t)))))
221
+ return send(response, 400, { error: "invalid_type" });
222
+ if (!(maxSize === undefined || (typeof maxSize === "number" && Number.isSafeInteger(maxSize) && maxSize > 0)) || !(expiresIn === undefined || (typeof expiresIn === "number" && Number.isInteger(expiresIn) && expiresIn > 0 && expiresIn <= uploadLife)))
217
223
  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 });
224
+ if ((maxSize ?? 0) > maxObject)
225
+ return send(response, 413, { error: "too_large" });
226
+ const token = newToken(), life = expiresIn ?? uploadLife;
227
+ uploads.set(token, { name, maxSize: maxSize ?? maxObject, types: types ?? [], until: Date.now() + life * 1000 });
228
+ return send(response, 200, { url: `${chest.api}${uploadPath}${token}`, method: "PUT", expires_in: life });
219
229
  }
220
230
  const from = command["from"], to = command["to"];
221
231
  if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to))
@@ -254,6 +264,46 @@ export async function fakeChest(options = {}) {
254
264
  }
255
265
  send(response, 404, { error: "not_found" });
256
266
  }
267
+ // The team host's part of the files, on the fake's own origin: a link it
268
+ // signed opens the content it was signed for, for its life, as long as
269
+ // the file is that content — a thumbnail is the image itself, a fake does
270
+ // not reduce it —; an upload token takes one file, once, within its life,
271
+ // of the types it names and up to its size, whose first bytes are those of
272
+ // its type, named by the Chest in a folder. Nobody's session is checked:
273
+ // the Chest's own front does that.
274
+ const links = new Map();
275
+ const uploads = new Map();
276
+ async function front(request, response, url) {
277
+ if (request.method === "GET" && url.pathname.startsWith(linkPath) && !url.pathname.startsWith(uploadPath)) {
278
+ const link = links.get(url.pathname.slice(linkPath.length));
279
+ if (!link || link.until < Date.now() || files.get(link.name) !== link.object)
280
+ return send(response, 404, { error: "not_found" });
281
+ const headers = { "Content-Type": link.object.type, "Content-Length": String(link.object.data.byteLength), "X-Content-Type-Options": "nosniff" };
282
+ if (link.download)
283
+ headers["Content-Disposition"] = `attachment; filename="${link.name.split("/").pop()}"`;
284
+ return void response.writeHead(200, headers).end(link.object.data);
285
+ }
286
+ if (request.method !== "PUT" || !url.pathname.startsWith(uploadPath))
287
+ return send(response, 404, { error: "not_found" });
288
+ const token = url.pathname.slice(uploadPath.length), grant = uploads.get(token);
289
+ uploads.delete(token);
290
+ if (!grant || grant.until < Date.now())
291
+ return send(response, 403, { error: "invalid_token" });
292
+ const type = (request.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
293
+ if (!mediaPattern.test(type) || type.endsWith("/*") || (grant.types.length > 0 && !grant.types.some(t => t === type || (t.endsWith("/*") && type.startsWith(t.slice(0, -1))))))
294
+ return send(response, 415, { error: "type_refused" });
295
+ const data = await body(request, grant.maxSize);
296
+ if (data === null)
297
+ return send(response, 413, { error: "too_large" });
298
+ if (signatures[type] && !signatures[type](data))
299
+ return send(response, 400, { error: "type_mismatch" });
300
+ const name = grant.name.endsWith("/") ? grant.name + randomBytes(10).toString("hex") + (extensions[type] ?? "") : grant.name;
301
+ const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
302
+ if (total + data.length > maxTotal || (!files.has(name) && files.size >= maxObjects))
303
+ return send(response, 429, { error: "quota_exceeded" });
304
+ files.set(name, { data: new Uint8Array(data), type, updated: new Date().toISOString() });
305
+ send(response, 201, { name, type, size: data.length });
306
+ }
257
307
  const hour = { start: 0, count: 0 }, minute = { start: 0, count: 0 }, days = new Map();
258
308
  const live = (w, span, now) => w !== undefined && w.count > 0 && now - w.start < span;
259
309
  const wait = (w, span, now) => ({ "Retry-After": String(Math.max(1, Math.ceil((w.start + span - now) / 1000))) });
@@ -509,11 +559,12 @@ export async function fakeChest(options = {}) {
509
559
  }
510
560
  const server = createServer((request, response) => {
511
561
  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;
562
+ const route = url.pathname.startsWith("/_chest/files/") ? front
563
+ : url.pathname.startsWith("/erasures/") ? erasuresRoute
564
+ : url.pathname.startsWith("/ai/") ? aiRoute
565
+ : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
566
+ : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
567
+ : url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
517
568
  if (!route)
518
569
  return send(response, 404, { error: "not_found" });
519
570
  route(request, response, url).catch(() => { if (!response.headersSent)
@@ -522,19 +573,38 @@ export async function fakeChest(options = {}) {
522
573
  response.destroy(); });
523
574
  });
524
575
  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]]));
576
+ 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
577
  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" });
578
+ // Its links are on its own origin, the CHEST_API it sets: the files
579
+ // module takes them there (api.ts, chestLink), in this process or in a
580
+ // tool it starts with this environment.
581
+ const publicUrl = options.chest?.publicUrl === undefined ? `https://${tool}.chest.test` : options.chest.publicUrl;
582
+ 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` });
583
+ if (publicUrl === null)
584
+ delete process.env["CHEST_PUBLIC_URL"];
585
+ else
586
+ process.env["CHEST_PUBLIC_URL"] = publicUrl;
528
587
  forget();
588
+ // post delivers what the Chest posts to the tool on a channel — at its
589
+ // address, or to a handler — and says the status it answered.
590
+ const post = async (path, channel, id, body, to) => {
591
+ 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 });
592
+ const answer = typeof to === "string" ? await fetch(request, { redirect: "manual" }) : await to(request);
593
+ await answer.body?.cancel();
594
+ return answer.status;
595
+ };
596
+ const newId = (prefix) => prefix + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
529
597
  chest.emit = async (event, to) => {
530
- const id = event.id ?? "evt_" + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
598
+ const id = event.id ?? newId("evt_");
531
599
  const body = JSON.stringify({ id, type: event.type, occurredAt: event.occurredAt ?? new Date().toISOString(), data: event.data });
532
600
  if (event.type === "member.erased")
533
601
  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;
602
+ return post("/chest-events", eventChannel, id, body, to);
603
+ };
604
+ chest.run = async (name, to, run = {}) => {
605
+ const id = run.id ?? newId("run_");
606
+ const body = JSON.stringify({ id, name, scheduledAt: run.scheduledAt ?? new Date().toISOString(), attempt: run.attempt ?? 1 });
607
+ return post("/chest-schedules", scheduleChannel, id, body, to);
538
608
  };
539
609
  chest.close = async () => {
540
610
  server.closeAllConnections();