@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.
- package/README.md +208 -43
- package/client/index.ts +4 -3
- package/client/src/api.ts +18 -0
- package/client/src/chest.ts +30 -6
- package/client/src/events.ts +12 -104
- package/client/src/files.ts +9 -9
- package/client/src/members.ts +18 -13
- package/client/src/schedules.ts +87 -0
- package/client/src/signed.ts +166 -0
- package/client/src/testing.ts +132 -54
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/src/api.d.ts +2 -0
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +18 -0
- package/dist/src/api.js.map +1 -1
- package/dist/src/chest.d.ts +5 -0
- package/dist/src/chest.d.ts.map +1 -1
- package/dist/src/chest.js +13 -1
- package/dist/src/chest.js.map +1 -1
- package/dist/src/events.d.ts +3 -6
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +8 -102
- 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 +6 -7
- package/dist/src/files.js.map +1 -1
- package/dist/src/members.d.ts +1 -1
- package/dist/src/members.d.ts.map +1 -1
- package/dist/src/members.js +6 -5
- package/dist/src/members.js.map +1 -1
- package/dist/src/schedules.d.ts +15 -0
- package/dist/src/schedules.d.ts.map +1 -0
- package/dist/src/schedules.js +45 -0
- package/dist/src/schedules.js.map +1 -0
- package/dist/src/signed.d.ts +29 -0
- package/dist/src/signed.d.ts.map +1 -0
- package/dist/src/signed.js +139 -0
- package/dist/src/signed.js.map +1 -0
- package/dist/src/testing.d.ts +15 -5
- package/dist/src/testing.d.ts.map +1 -1
- package/dist/src/testing.js +124 -52
- package/dist/src/testing.js.map +1 -1
- 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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"}
|
package/dist/src/testing.d.ts
CHANGED
|
@@ -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":"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;
|
|
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;AAyB3E,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,CAialF"}
|
package/dist/src/testing.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { createHash,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
if (typeof
|
|
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
|
-
|
|
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("/
|
|
513
|
-
: url.pathname.startsWith("/
|
|
514
|
-
: url.pathname
|
|
515
|
-
: url.pathname === "/
|
|
516
|
-
: url.pathname === "/
|
|
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
|
-
|
|
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_"
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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)) {
|