@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.
- package/README.md +210 -44
- package/client/index.ts +4 -3
- package/client/src/api.ts +12 -0
- package/client/src/chest.ts +30 -6
- package/client/src/database.ts +6 -1
- 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 +130 -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 +1 -0
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +13 -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/database.d.ts.map +1 -1
- package/dist/src/database.js +5 -1
- package/dist/src/database.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 +122 -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":"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"}
|
package/dist/src/testing.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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 { 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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
if (typeof
|
|
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
|
-
|
|
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("/
|
|
513
|
-
: url.pathname.startsWith("/
|
|
514
|
-
: url.pathname
|
|
515
|
-
: url.pathname === "/
|
|
516
|
-
: url.pathname === "/
|
|
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
|
-
|
|
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_"
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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();
|