@argentic/chest-sdk 0.1.0 → 0.2.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 +391 -48
- package/client/index.ts +10 -7
- package/client/src/api.ts +81 -0
- package/client/src/errors.ts +14 -3
- package/client/src/events.ts +228 -0
- package/client/src/files.ts +95 -89
- package/client/src/member.ts +40 -17
- package/client/src/members.ts +167 -0
- package/client/src/notifications.ts +148 -0
- package/client/src/testing.ts +417 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -7
- package/dist/index.js.map +1 -1
- package/dist/src/api.d.ts +9 -0
- package/dist/src/api.d.ts.map +1 -0
- package/dist/src/api.js +88 -0
- package/dist/src/api.js.map +1 -0
- package/dist/src/errors.d.ts +3 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +13 -3
- package/dist/src/errors.js.map +1 -1
- package/dist/src/events.d.ts +56 -0
- package/dist/src/events.d.ts.map +1 -0
- package/dist/src/events.js +193 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/files.d.ts +17 -1
- package/dist/src/files.d.ts.map +1 -1
- package/dist/src/files.js +96 -92
- package/dist/src/files.js.map +1 -1
- package/dist/src/member.d.ts +6 -3
- package/dist/src/member.d.ts.map +1 -1
- package/dist/src/member.js +22 -11
- package/dist/src/member.js.map +1 -1
- package/dist/src/members.d.ts +34 -0
- package/dist/src/members.d.ts.map +1 -0
- package/dist/src/members.js +146 -0
- package/dist/src/members.js.map +1 -0
- package/dist/src/notifications.d.ts +25 -0
- package/dist/src/notifications.d.ts.map +1 -0
- package/dist/src/notifications.js +121 -0
- package/dist/src/notifications.js.map +1 -0
- package/dist/src/testing.d.ts +70 -0
- package/dist/src/testing.d.ts.map +1 -0
- package/dist/src/testing.js +419 -0
- package/dist/src/testing.js.map +1 -0
- package/package.json +28 -4
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import type { ChestEvent } from "./events.js";
|
|
5
|
+
import { groupIdPattern, memberIdPattern, type Member } from "./member.js";
|
|
6
|
+
import { forget } from "./members.js";
|
|
7
|
+
|
|
8
|
+
// For a tool's own tests, never imported by its production code: a member's
|
|
9
|
+
// assertion signed as the Chest signs it, and a Chest's API in the test's
|
|
10
|
+
// process that answers members, groups, files (stat, move, links; an upload
|
|
11
|
+
// it authorises but does not receive), badges, notifications and the
|
|
12
|
+
// acknowledgment of an erasure with the Chest's bounds, quotas and errors;
|
|
13
|
+
// and that delivers an event to the tool, signed as the Chest signs it.
|
|
14
|
+
//
|
|
15
|
+
// import { fakeChest, withMember } from "@argentic/chest-sdk/testing";
|
|
16
|
+
// const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications"] });
|
|
17
|
+
// const response = await app(withMember(new Request("http://tool/chest"), camille));
|
|
18
|
+
// assert.equal(chest.notifications[0]?.member, camille.id);
|
|
19
|
+
// assert.equal(await chest.emit({ type: "access.revoked", data: { id: camille.id } }, request => app(request)), 204);
|
|
20
|
+
// await chest.close();
|
|
21
|
+
|
|
22
|
+
// A group as a fake Chest keeps it: its identifier, its name, and the
|
|
23
|
+
// identifiers of the members it gives the tool to.
|
|
24
|
+
export type FakeGroup = { id: string; name: string; members: string[] };
|
|
25
|
+
// A file as a fake Chest keeps it.
|
|
26
|
+
export type FakeFile = { data: Uint8Array; type: string; updated: string };
|
|
27
|
+
// A notification as a fake Chest keeps it: the member it went to, its text
|
|
28
|
+
// cleaned as the Chest cleans it, its path (/chest when not said) and its
|
|
29
|
+
// key.
|
|
30
|
+
export type FakeNotification = { member: string; title: string; body?: string; path: string; key?: string };
|
|
31
|
+
|
|
32
|
+
// What a fake Chest is given: the members who have the tool, those who left
|
|
33
|
+
// it (erased: their data was erased, the name gone), its groups, the
|
|
34
|
+
// capabilities its version holds (a capability left out answers 403;
|
|
35
|
+
// members, files and notifications by default, members.email to read the
|
|
36
|
+
// addresses), the events it receives (["member.*"] by default, [] to answer
|
|
37
|
+
// an acknowledgment 403) and the files it keeps.
|
|
38
|
+
export type FakeChestOptions = {
|
|
39
|
+
members?: Member[];
|
|
40
|
+
former?: { id: string; name?: string; erased?: boolean }[];
|
|
41
|
+
groups?: FakeGroup[];
|
|
42
|
+
capabilities?: string[];
|
|
43
|
+
receives?: string[];
|
|
44
|
+
files?: Record<string, { data: Uint8Array | string; type?: string }>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// An event for emit: its type and data; its id (a new evt_… by default) and
|
|
48
|
+
// when it happened (now by default) may be named, to deliver the same event
|
|
49
|
+
// twice.
|
|
50
|
+
export type FakeEvent = { [K in ChestEvent["type"]]: { type: K; data: Extract<ChestEvent, { type: K }>["data"]; id?: string; occurredAt?: string } }[ChestEvent["type"]];
|
|
51
|
+
|
|
52
|
+
// A fake Chest in the test's process: its address, the token and the tool it
|
|
53
|
+
// set in the environment, what it keeps (members, groups and files a test
|
|
54
|
+
// changes or reads; the notifications the tool sent, in the order sent, a
|
|
55
|
+
// replaced one last; each member's badge; the erasures the tool
|
|
56
|
+
// acknowledged), emit, which delivers an event to the tool — POST
|
|
57
|
+
// /chest-events of its address, or a handler of Web Requests — and says the
|
|
58
|
+
// status it answered, and close, which stops it and restores the
|
|
59
|
+
// environment. Its members are those who have the tool: the others are
|
|
60
|
+
// skipped.
|
|
61
|
+
export type FakeChest = {
|
|
62
|
+
api: string;
|
|
63
|
+
token: string;
|
|
64
|
+
tool: string;
|
|
65
|
+
members: Member[];
|
|
66
|
+
groups: FakeGroup[];
|
|
67
|
+
files: Map<string, FakeFile>;
|
|
68
|
+
notifications: FakeNotification[];
|
|
69
|
+
badges: Map<string, number>;
|
|
70
|
+
acknowledged: string[];
|
|
71
|
+
emit(event: FakeEvent, to: string | ((request: Request) => Response | Promise<Response>)): Promise<number>;
|
|
72
|
+
close(): Promise<void>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
76
|
+
|
|
77
|
+
// signAssertion is the Chest-Member value the Chest's front would send for
|
|
78
|
+
// that member: HS256 under the key of the token (CHEST_TOKEN by default), for
|
|
79
|
+
// the tool (CHEST_TOOL by default), valid 60 seconds from now.
|
|
80
|
+
export function signAssertion(member: Member, options: { token?: string; tool?: string; now?: Date } = {}): string {
|
|
81
|
+
const token = options.token ?? process.env["CHEST_TOKEN"];
|
|
82
|
+
const tool = options.tool ?? process.env["CHEST_TOOL"];
|
|
83
|
+
if (!token || !tool) throw new Error("signAssertion needs a token and a tool: start a fakeChest, or name them");
|
|
84
|
+
if (!memberIdPattern.test(member.id) || !member.groups.every(g => groupIdPattern.test(g))) throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
|
|
85
|
+
const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
86
|
+
const body = encode({ alg: "HS256", typ: "JWT" }) + "." + encode({
|
|
87
|
+
iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
|
|
88
|
+
given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
|
|
89
|
+
admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, ...(member.email === undefined ? {} : { email: member.email }),
|
|
90
|
+
});
|
|
91
|
+
// The key as the Chest derives it, and member() reads it: HMAC-SHA256 of
|
|
92
|
+
// the label of the assertion's shape under the text of the token.
|
|
93
|
+
const key = createHmac("sha256", Buffer.from(token, "utf8")).update("Chest-Member v2").digest();
|
|
94
|
+
return body + "." + createHmac("sha256", key).update(body).digest("base64url");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// signEvent is the Chest-Event value the Chest would send with that body:
|
|
98
|
+
// HS256 under the key the token derives for events, for the tool, naming the
|
|
99
|
+
// event and the digest of the body, valid 60 seconds.
|
|
100
|
+
function signEvent(id: string, body: string, options: { token: string; tool: string }): string {
|
|
101
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
102
|
+
const signed = encode({ alg: "HS256", typ: "JWT" }) + "." + encode({ aud: options.tool, iat, exp: iat + 60, jti: id, digest: createHash("sha256").update(body).digest("base64url") });
|
|
103
|
+
const key = createHmac("sha256", Buffer.from(options.token, "utf8")).update("Chest-Event v1").digest();
|
|
104
|
+
return signed + "." + createHmac("sha256", key).update(signed).digest("base64url");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// withMember is the request carrying that member's assertion: a new Web
|
|
108
|
+
// Request, or the same Node request with its header set.
|
|
109
|
+
export function withMember<R extends Request | IncomingMessage>(request: R, member: Member, options: { token?: string; tool?: string; now?: Date } = {}): R {
|
|
110
|
+
const assertion = signAssertion(member, options);
|
|
111
|
+
if (request instanceof Request) {
|
|
112
|
+
const headers = new Headers(request.headers);
|
|
113
|
+
headers.set("Chest-Member", assertion);
|
|
114
|
+
return new Request(request, { headers }) as R;
|
|
115
|
+
}
|
|
116
|
+
(request as IncomingMessage).headers["chest-member"] = assertion;
|
|
117
|
+
return request;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The bounds of a Chest (chest/toolmembers, chest/toolfiles).
|
|
121
|
+
const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
|
|
122
|
+
const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
|
|
123
|
+
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;
|
|
124
|
+
// Its notifications: recipients and badges a call, text, quotas.
|
|
125
|
+
const maxRecipients = 500, maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999;
|
|
126
|
+
const recipientsPerHour = 1000, itemsPerDay = 100, badgesPerMinute = 600;
|
|
127
|
+
const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
|
|
128
|
+
const reordering = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
|
|
129
|
+
const cleanTitle = (s: string): string => s.replace(/[\t\r\n]/gu, " ").replace(/\p{Cc}/gu, "").replace(reordering, "").trim();
|
|
130
|
+
const cleanText = (s: string): string => s.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[^\P{Cc}\n]/gu, "").replace(reordering, "").trim();
|
|
131
|
+
const isPath = (p: unknown): boolean => typeof p === "string" && p.length <= maxPath && /^\/chest([/?#][\x21-\x5b\x5d-\x7e]*)?$/u.test(p) && !p.includes("//") && !p.split(/[?#]/u)[0]!.split("/").some(x => /^(\.|%2e){1,2}$/iu.test(x));
|
|
132
|
+
|
|
133
|
+
const fold = (s: string): string => s.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
|
|
134
|
+
|
|
135
|
+
function send(response: ServerResponse, status: number, value?: unknown, headers: Record<string, string> = {}): void {
|
|
136
|
+
if (value === undefined) return void response.writeHead(status, headers).end();
|
|
137
|
+
const raw = JSON.stringify(value);
|
|
138
|
+
response.writeHead(status, { ...headers, "Content-Type": "application/json", "Content-Length": String(Buffer.byteLength(raw)) }).end(raw);
|
|
139
|
+
}
|
|
140
|
+
async function body(request: IncomingMessage, limit: number): Promise<Buffer | null> {
|
|
141
|
+
const chunks: Buffer[] = [];
|
|
142
|
+
let size = 0;
|
|
143
|
+
for await (const chunk of request) {
|
|
144
|
+
size += (chunk as Buffer).length;
|
|
145
|
+
if (size > limit) return null;
|
|
146
|
+
chunks.push(chunk as Buffer);
|
|
147
|
+
}
|
|
148
|
+
return Buffer.concat(chunks);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// fakeChest starts a Chest's API on 127.0.0.1 and points the environment at
|
|
152
|
+
// it: CHEST_API, CHEST_TOKEN (a new one) and CHEST_TOOL ("tool" unless the
|
|
153
|
+
// environment names one). What member() and the modules of the SDK read is
|
|
154
|
+
// then this Chest's.
|
|
155
|
+
export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChest> {
|
|
156
|
+
const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications"]);
|
|
157
|
+
const email = capabilities.has("members.email");
|
|
158
|
+
const tool = process.env["CHEST_TOOL"] || "tool";
|
|
159
|
+
const token = randomBytes(32).toString("base64url");
|
|
160
|
+
const files = new Map<string, FakeFile>();
|
|
161
|
+
for (const [name, file] of Object.entries(options.files ?? {})) {
|
|
162
|
+
files.set(name, { data: typeof file.data === "string" ? new TextEncoder().encode(file.data) : file.data, type: file.type ?? "application/octet-stream", updated: new Date().toISOString() });
|
|
163
|
+
}
|
|
164
|
+
const receives = options.receives ?? ["member.*"];
|
|
165
|
+
// The erasures the tool was told of, by emit: those it may acknowledge.
|
|
166
|
+
const erasures = new Set<string>();
|
|
167
|
+
const chest: FakeChest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], emit: async () => 0, close: async () => {} };
|
|
168
|
+
const former = [...(options.former ?? [])];
|
|
169
|
+
let window = 0, calls = 0;
|
|
170
|
+
const shown = (m: Member) => ({ id: m.id, first_name: m.firstName, last_name: m.lastName, name: m.name, photo: m.photo, role: m.role, admin: m.isAdmin, builder: m.isBuilder, groups: m.groups, ...(email && m.email !== undefined ? { email: m.email } : {}) });
|
|
171
|
+
const key = (m: Member) => fold(m.name) + "\u0000" + m.id;
|
|
172
|
+
const described = (name: string, f: FakeFile) => ({ name, type: f.type, size: f.data.byteLength, updated: f.updated });
|
|
173
|
+
|
|
174
|
+
async function members(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
175
|
+
if (!capabilities.has("members")) return send(response, 403, { error: "capability_not_granted" });
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
if (now - window >= 60_000) [window, calls] = [now, 0];
|
|
178
|
+
if (++calls > callsPerMinute) return send(response, 429, { error: "rate_limited" }, { "Retry-After": "60" });
|
|
179
|
+
if (request.method === "GET" && url.pathname === "/members") {
|
|
180
|
+
const q = url.searchParams, keys = [...q.keys()];
|
|
181
|
+
const limit = q.has("limit") ? Number(q.get("limit")) : defaultLimit;
|
|
182
|
+
const after = q.has("after") ? Buffer.from(q.get("after")!, "base64url").toString() : "";
|
|
183
|
+
if (q.has("after") && !memberIdPattern.test(after.split("\u0000")[1] ?? "")) return send(response, 400, { error: "invalid_query" });
|
|
184
|
+
const group = q.get("group"), role = q.get("role"), search = fold(q.get("q") ?? "");
|
|
185
|
+
if (keys.some(k => !["after", "limit", "q", "role", "group"].includes(k) || q.getAll(k).length !== 1) || !Number.isInteger(limit) || limit < 1 || limit > maxLimit || String(limit) !== (q.get("limit") ?? String(defaultLimit)) || (group !== null && !groupIdPattern.test(group)) || search.length > 64) return send(response, 400, { error: "invalid_query" });
|
|
186
|
+
const shownList = chest.members
|
|
187
|
+
.filter(m => (role === null || m.role === role) && (group === null || m.groups.includes(group)) && (search === "" || [m.firstName, m.lastName, m.name, ...(email && m.email ? [m.email] : [])].some(n => fold(n).startsWith(search))))
|
|
188
|
+
.filter(m => after === "" || key(m) > after)
|
|
189
|
+
.sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
|
|
190
|
+
const page = shownList.slice(0, limit);
|
|
191
|
+
return send(response, 200, { members: page.map(shown), next: shownList.length > limit ? Buffer.from(key(page.at(-1)!)).toString("base64url") : null });
|
|
192
|
+
}
|
|
193
|
+
if (request.method === "POST" && url.pathname === "/members/lookup") {
|
|
194
|
+
const raw = await body(request, 64 << 10);
|
|
195
|
+
let ids: unknown;
|
|
196
|
+
try { ids = (JSON.parse(raw?.toString() ?? "") as { ids?: unknown }).ids; } catch { ids = undefined; }
|
|
197
|
+
if (!Array.isArray(ids) || ids.length > maxLookup) return send(response, 400, { error: "invalid_body" });
|
|
198
|
+
if (!ids.every(id => typeof id === "string" && memberIdPattern.test(id))) return send(response, 400, { error: "invalid_id" });
|
|
199
|
+
const answer = { members: [] as unknown[], former: [] as unknown[], unknown: [] as string[] };
|
|
200
|
+
for (const id of new Set(ids as string[])) {
|
|
201
|
+
const m = chest.members.find(x => x.id === id), f = former.find(x => x.id === id);
|
|
202
|
+
if (m) answer.members.push(shown(m));
|
|
203
|
+
else if (f?.erased) answer.former.push({ id, status: "erased" });
|
|
204
|
+
else if (f) answer.former.push({ id, ...(f.name ? { name: f.name } : {}), status: "former" });
|
|
205
|
+
else answer.unknown.push(id);
|
|
206
|
+
}
|
|
207
|
+
return send(response, 200, answer);
|
|
208
|
+
}
|
|
209
|
+
if (request.method === "GET" && url.pathname.startsWith("/members/")) {
|
|
210
|
+
const id = url.pathname.slice("/members/".length);
|
|
211
|
+
if (!memberIdPattern.test(id)) return send(response, 400, { error: "invalid_id" });
|
|
212
|
+
const m = chest.members.find(x => x.id === id);
|
|
213
|
+
return m ? send(response, 200, shown(m)) : send(response, 404, { error: "member_not_found" });
|
|
214
|
+
}
|
|
215
|
+
if (request.method === "GET" && url.pathname === "/groups") return send(response, 200, { groups: chest.groups });
|
|
216
|
+
send(response, 404, { error: "not_found" });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function filesRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
220
|
+
if (!capabilities.has("files")) return send(response, 403, { error: "capability_not_granted" });
|
|
221
|
+
if (request.method === "GET" && url.pathname === "/files") {
|
|
222
|
+
const prefix = url.searchParams.get("prefix") ?? "", after = url.searchParams.get("after") ?? "";
|
|
223
|
+
const names = [...files.keys()].filter(n => n.startsWith(prefix) && n > after).sort();
|
|
224
|
+
return send(response, 200, { files: names.slice(0, 1000).map(n => described(n, files.get(n)!)), next: names.length > 1000 ? names[999] : null });
|
|
225
|
+
}
|
|
226
|
+
if (request.method === "POST" && url.pathname === "/files/url") {
|
|
227
|
+
const raw = await body(request, 4096);
|
|
228
|
+
let name: unknown;
|
|
229
|
+
try { name = (JSON.parse(raw?.toString() ?? "") as { name?: unknown }).name; } catch { name = undefined; }
|
|
230
|
+
if (typeof name !== "string") return send(response, 400, { error: "invalid_body" });
|
|
231
|
+
if (!files.has(name)) return send(response, 404, { error: "not_found" });
|
|
232
|
+
return send(response, 200, { url: `https://${tool}-chest.chest.test/_chest/files/${Buffer.from(name).toString("base64url")}.fake`, expires_in: 900 });
|
|
233
|
+
}
|
|
234
|
+
if (request.method === "POST" && (url.pathname === "/files/move" || url.pathname === "/files/upload-url")) {
|
|
235
|
+
const raw = await body(request, 4096);
|
|
236
|
+
let command: Record<string, unknown> = {};
|
|
237
|
+
try { command = JSON.parse(raw?.toString() ?? "") as Record<string, unknown>; } catch { command = {}; }
|
|
238
|
+
if (url.pathname === "/files/upload-url") {
|
|
239
|
+
// The upload itself goes from a member's browser to the team host,
|
|
240
|
+
// which a fake Chest does not play: it only authorises it.
|
|
241
|
+
if (typeof command["name"] !== "string") return send(response, 400, { error: "invalid_body" });
|
|
242
|
+
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 });
|
|
243
|
+
}
|
|
244
|
+
const from = command["from"], to = command["to"];
|
|
245
|
+
if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to)) return send(response, 400, { error: "invalid_name" });
|
|
246
|
+
const moving = files.get(from);
|
|
247
|
+
if (!moving) return send(response, 404, { error: "not_found" });
|
|
248
|
+
files.delete(from);
|
|
249
|
+
files.set(to, moving);
|
|
250
|
+
return send(response, 200, described(to, moving));
|
|
251
|
+
}
|
|
252
|
+
const name = decodeURIComponent(url.pathname.slice("/files/".length));
|
|
253
|
+
if (!namePattern.test(name)) return send(response, 400, { error: "invalid_name" });
|
|
254
|
+
const object = files.get(name);
|
|
255
|
+
if (request.method === "PUT") {
|
|
256
|
+
const data = await body(request, maxObject);
|
|
257
|
+
if (data === null) return send(response, 413, { error: "too_large" });
|
|
258
|
+
const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
|
|
259
|
+
if (total + data.length > maxTotal || (!object && files.size >= maxObjects)) return send(response, 429, { error: "quota_exceeded" });
|
|
260
|
+
const kept = { data: new Uint8Array(data), type: request.headers["content-type"] ?? "application/octet-stream", updated: new Date().toISOString() };
|
|
261
|
+
files.set(name, kept);
|
|
262
|
+
return send(response, 201, described(name, kept));
|
|
263
|
+
}
|
|
264
|
+
if (!object) return send(response, 404, { error: "not_found" });
|
|
265
|
+
if (request.method === "GET" && url.searchParams.has("stat")) return send(response, 200, described(name, object));
|
|
266
|
+
if (request.method === "GET") return void response.writeHead(200, { "Content-Type": object.type, "Content-Length": String(object.data.byteLength) }).end(object.data);
|
|
267
|
+
if (request.method === "DELETE") {
|
|
268
|
+
files.delete(name);
|
|
269
|
+
return send(response, 204);
|
|
270
|
+
}
|
|
271
|
+
send(response, 404, { error: "not_found" });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The windows of the notification quotas, each from the first call it
|
|
275
|
+
// counts: the tool's recipients this hour, each member's items this day,
|
|
276
|
+
// the tool's badge writes this minute.
|
|
277
|
+
type Window = { start: number; count: number };
|
|
278
|
+
const hour: Window = { start: 0, count: 0 }, minute: Window = { start: 0, count: 0 }, days = new Map<string, Window>();
|
|
279
|
+
const live = (w: Window | undefined, span: number, now: number): boolean => w !== undefined && w.count > 0 && now - w.start < span;
|
|
280
|
+
const wait = (w: Window, span: number, now: number): Record<string, string> => ({ "Retry-After": String(Math.max(1, Math.ceil((w.start + span - now) / 1000))) });
|
|
281
|
+
const count = (w: Window, span: number, now: number, n: number): void => {
|
|
282
|
+
if (!live(w, span, now)) [w.start, w.count] = [now, 0];
|
|
283
|
+
w.count += n;
|
|
284
|
+
};
|
|
285
|
+
const access = (id: string) => chest.members.some(m => m.id === id);
|
|
286
|
+
// drop removes kept notifications in place: a test may hold the list.
|
|
287
|
+
const drop = (gone: (n: FakeNotification) => boolean): void => {
|
|
288
|
+
for (let i = chest.notifications.length - 1; i >= 0; i--) if (gone(chest.notifications[i]!)) chest.notifications.splice(i, 1);
|
|
289
|
+
};
|
|
290
|
+
// recipients reads 1 to 500 member identifiers, each once.
|
|
291
|
+
function recipients(value: unknown): string[] | { error: string } {
|
|
292
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > maxRecipients) return { error: "invalid_body" };
|
|
293
|
+
if (!value.every(v => typeof v === "string" && memberIdPattern.test(v))) return { error: "invalid_id" };
|
|
294
|
+
return [...new Set(value as string[])];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function notificationsRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
298
|
+
if (!capabilities.has("notifications")) return send(response, 403, { error: "capability_not_granted" });
|
|
299
|
+
const badge = request.method === "PUT" && url.pathname.startsWith("/badges/");
|
|
300
|
+
if (!badge && !(request.method === "PUT" && url.pathname === "/badges") && !(request.method === "POST" && (url.pathname === "/notifications" || url.pathname === "/notifications/withdraw"))) return send(response, 404, { error: "not_found" });
|
|
301
|
+
const raw = await body(request, 64 << 10);
|
|
302
|
+
let command: Record<string, unknown> | null = null;
|
|
303
|
+
try {
|
|
304
|
+
const value = JSON.parse(raw?.toString() ?? "") as unknown;
|
|
305
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) command = value as Record<string, unknown>;
|
|
306
|
+
} catch {
|
|
307
|
+
command = null;
|
|
308
|
+
}
|
|
309
|
+
const keys = (...allowed: string[]) => command !== null && Object.keys(command).every(k => allowed.includes(k));
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
if (badge || url.pathname === "/badges") {
|
|
312
|
+
let writes: { member: string; count: number }[];
|
|
313
|
+
if (badge) {
|
|
314
|
+
const id = url.pathname.slice("/badges/".length);
|
|
315
|
+
if (!memberIdPattern.test(id)) return send(response, 400, { error: "invalid_id" });
|
|
316
|
+
if (!keys("count") || !("count" in command!)) return send(response, 400, { error: "invalid_body" });
|
|
317
|
+
writes = [{ member: id, count: command!["count"] as number }];
|
|
318
|
+
} else {
|
|
319
|
+
const list = command?.["badges"];
|
|
320
|
+
if (!keys("badges") || !Array.isArray(list) || list.length < 1 || list.length > maxRecipients || !list.every(b => b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).every(k => k === "member" || k === "count"))) return send(response, 400, { error: "invalid_body" });
|
|
321
|
+
writes = list as { member: string; count: number }[];
|
|
322
|
+
if (!writes.every(b => typeof b.member === "string" && memberIdPattern.test(b.member))) return send(response, 400, { error: "invalid_id" });
|
|
323
|
+
}
|
|
324
|
+
if (!writes.every(b => typeof b.count === "number" && Number.isInteger(b.count) && b.count >= 0 && b.count <= maxCount)) return send(response, 400, { error: "invalid_count" });
|
|
325
|
+
if (new Set(writes.map(b => b.member)).size !== writes.length) return send(response, 400, { error: "invalid_body" });
|
|
326
|
+
if (live(minute, 60_000, now) && minute.count + writes.length > badgesPerMinute) return send(response, 429, { error: "quota_exceeded" }, wait(minute, 60_000, now));
|
|
327
|
+
count(minute, 60_000, now, writes.length);
|
|
328
|
+
const answer = { set: [] as string[], skipped: [] as string[] };
|
|
329
|
+
for (const b of writes) {
|
|
330
|
+
if (!access(b.member)) {
|
|
331
|
+
answer.skipped.push(b.member);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (b.count === 0) chest.badges.delete(b.member);
|
|
335
|
+
else chest.badges.set(b.member, b.count);
|
|
336
|
+
answer.set.push(b.member);
|
|
337
|
+
}
|
|
338
|
+
return send(response, 200, answer);
|
|
339
|
+
}
|
|
340
|
+
if (url.pathname === "/notifications/withdraw") {
|
|
341
|
+
if (!keys("key", "members")) return send(response, 400, { error: "invalid_body" });
|
|
342
|
+
const key = command!["key"];
|
|
343
|
+
if (typeof key !== "string" || !keyPattern.test(key)) return send(response, 400, { error: "invalid_key" });
|
|
344
|
+
const named = command!["members"] === undefined ? null : recipients(command!["members"]);
|
|
345
|
+
if (named !== null && !Array.isArray(named)) return send(response, 400, named);
|
|
346
|
+
drop(n => n.key === key && (named === null || named.includes(n.member)));
|
|
347
|
+
return send(response, 204);
|
|
348
|
+
}
|
|
349
|
+
if (!keys("members", "title", "body", "path", "key")) return send(response, 400, { error: "invalid_body" });
|
|
350
|
+
const { title, body: text, path, key } = command!;
|
|
351
|
+
const ids = recipients(command!["members"]);
|
|
352
|
+
if (!Array.isArray(ids)) return send(response, 400, ids);
|
|
353
|
+
if (typeof title !== "string" || [...title].length < 1 || [...title].length > maxTitle || cleanTitle(title) === "") return send(response, 400, { error: "invalid_title" });
|
|
354
|
+
if (text !== undefined && (typeof text !== "string" || [...text].length > maxText)) return send(response, 400, { error: "invalid_text" });
|
|
355
|
+
if (path !== undefined && !isPath(path)) return send(response, 400, { error: "invalid_path" });
|
|
356
|
+
if (key !== undefined && (typeof key !== "string" || !keyPattern.test(key))) return send(response, 400, { error: "invalid_key" });
|
|
357
|
+
const kept = ids.filter(access);
|
|
358
|
+
if (live(hour, 3_600_000, now) && hour.count + kept.length > recipientsPerHour) return send(response, 429, { error: "quota_exceeded" }, wait(hour, 3_600_000, now));
|
|
359
|
+
const full = kept.map(id => days.get(id)).filter((w): w is Window => live(w, 86_400_000, now) && w!.count >= itemsPerDay);
|
|
360
|
+
if (full.length > 0) return send(response, 429, { error: "quota_exceeded" }, wait(full.reduce((a, b) => a.start > b.start ? a : b), 86_400_000, now));
|
|
361
|
+
count(hour, 3_600_000, now, kept.length);
|
|
362
|
+
const cleaned = typeof text === "string" ? cleanText(text) : "";
|
|
363
|
+
for (const id of kept) {
|
|
364
|
+
if (!days.has(id)) days.set(id, { start: 0, count: 0 });
|
|
365
|
+
count(days.get(id)!, 86_400_000, now, 1);
|
|
366
|
+
if (key !== undefined) drop(n => n.member === id && n.key === key);
|
|
367
|
+
chest.notifications.push({ member: id, title: cleanTitle(title), ...(cleaned ? { body: cleaned } : {}), path: (path as string | undefined) ?? "/chest", ...(key !== undefined ? { key: key as string } : {}) });
|
|
368
|
+
}
|
|
369
|
+
send(response, 200, { delivered: kept, skipped: ids.filter(id => !access(id)) });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// The acknowledgment of an erasure the tool was told of (emit).
|
|
373
|
+
async function erasuresRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
|
|
374
|
+
if (!receives.includes("member.*")) return send(response, 403, { error: "capability_not_granted" });
|
|
375
|
+
const done = /^\/erasures\/([^/]+)\/done$/u.exec(url.pathname);
|
|
376
|
+
if (request.method !== "POST" || !done || url.search) return send(response, 404, { error: "not_found" });
|
|
377
|
+
const id = done[1]!;
|
|
378
|
+
if (!/^era_[a-z2-7]{26}$/u.test(id)) return send(response, 400, { error: "invalid_id" });
|
|
379
|
+
if (!erasures.has(id)) return send(response, 404, { error: "erasure_not_found" });
|
|
380
|
+
if (!chest.acknowledged.includes(id)) chest.acknowledged.push(id);
|
|
381
|
+
send(response, 204);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const server = createServer((request, response) => {
|
|
385
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
386
|
+
const route = url.pathname.startsWith("/erasures/") ? erasuresRoute
|
|
387
|
+
: url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
|
|
388
|
+
: url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
|
|
389
|
+
: url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
|
|
390
|
+
if (!route) return send(response, 404, { error: "not_found" });
|
|
391
|
+
route(request, response, url).catch(() => { if (!response.headersSent) send(response, 503, { error: "unavailable" }); else response.destroy(); });
|
|
392
|
+
});
|
|
393
|
+
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
394
|
+
const saved = Object.fromEntries(["CHEST_API", "CHEST_TOKEN", "CHEST_TOOL"].map(name => [name, process.env[name]]));
|
|
395
|
+
chest.api = "http://127.0.0.1:" + (server.address() as AddressInfo).port;
|
|
396
|
+
Object.assign(process.env, { CHEST_API: chest.api, CHEST_TOKEN: token, CHEST_TOOL: tool });
|
|
397
|
+
forget();
|
|
398
|
+
chest.emit = async (event, to) => {
|
|
399
|
+
const id = event.id ?? "evt_" + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
|
|
400
|
+
const body = JSON.stringify({ id, type: event.type, occurredAt: event.occurredAt ?? new Date().toISOString(), data: event.data });
|
|
401
|
+
if (event.type === "member.erased") erasures.add(event.data.erasure);
|
|
402
|
+
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 });
|
|
403
|
+
const answer = typeof to === "string" ? await fetch(request, { redirect: "manual" }) : await to(request);
|
|
404
|
+
await answer.body?.cancel();
|
|
405
|
+
return answer.status;
|
|
406
|
+
};
|
|
407
|
+
chest.close = async () => {
|
|
408
|
+
server.closeAllConnections();
|
|
409
|
+
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
410
|
+
for (const [name, value] of Object.entries(saved)) {
|
|
411
|
+
if (value === undefined) delete process.env[name];
|
|
412
|
+
else process.env[name] = value;
|
|
413
|
+
}
|
|
414
|
+
forget();
|
|
415
|
+
};
|
|
416
|
+
return chest;
|
|
417
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,7 @@ export * from "./src/errors.js";
|
|
|
2
2
|
export * from "./src/member.js";
|
|
3
3
|
export * from "./src/database.js";
|
|
4
4
|
export * as files from "./src/files.js";
|
|
5
|
+
export * as members from "./src/members.js";
|
|
6
|
+
export * as notifications from "./src/notifications.js";
|
|
7
|
+
export * as events from "./src/events.js";
|
|
5
8
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAOA,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAOA,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AACxC,OAAO,KAAK,OAAO,MAAM,kBAAkB,CAAC;AAC5C,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,MAAM,MAAM,iBAAiB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
// The package root: every published module
|
|
2
|
-
// Each one is also its own subpath (@argentic/chest-sdk/member,
|
|
3
|
-
// /files, /errors), which
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// The package root: every published module a tool's code uses (tool contract
|
|
2
|
+
// v2). Each one is also its own subpath (@argentic/chest-sdk/member,
|
|
3
|
+
// /database, /files, /members, /notifications, /events, /errors), which
|
|
4
|
+
// pulls in nothing else. The files, members, notifications and events APIs
|
|
5
|
+
// are namespaces here, as their names (get, list, stat, move, notify,
|
|
6
|
+
// verify…) are too plain to stand alone. @argentic/chest-sdk/testing is for a tool's tests only, and
|
|
7
|
+
// is not here.
|
|
8
8
|
export * from "./src/errors.js";
|
|
9
9
|
export * from "./src/member.js";
|
|
10
10
|
export * from "./src/database.js";
|
|
11
11
|
export * as files from "./src/files.js";
|
|
12
|
+
export * as members from "./src/members.js";
|
|
13
|
+
export * as notifications from "./src/notifications.js";
|
|
14
|
+
export * as events from "./src/events.js";
|
|
12
15
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,qEAAqE;AACrE,wEAAwE;AACxE,2EAA2E;AAC3E,sEAAsE;AACtE,qGAAqG;AACrG,eAAe;AACf,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AACxC,OAAO,KAAK,OAAO,MAAM,kBAAkB,CAAC;AAC5C,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,MAAM,MAAM,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ChestError } from "./errors.js";
|
|
2
|
+
export declare function ask(capability: string, method: string, path: string, init?: {
|
|
3
|
+
body?: Uint8Array<ArrayBuffer> | string;
|
|
4
|
+
type?: string;
|
|
5
|
+
}): Promise<Response>;
|
|
6
|
+
export declare function read(response: Response, limit: number): Promise<Uint8Array>;
|
|
7
|
+
export declare function json(response: Response): Promise<unknown>;
|
|
8
|
+
export declare function refusal(response: Response, capability: string): Promise<ChestError>;
|
|
9
|
+
//# sourceMappingURL=api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../client/src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,UAAU,EAAqD,MAAM,aAAa,CAAC;AAqBlH,wBAAsB,GAAG,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CASpK;AAID,wBAAsB,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAqBjF;AAGD,wBAAsB,IAAI,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAM/D;AAGD,wBAAsB,OAAO,CAAC,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAazF"}
|
package/dist/src/api.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { CapabilityNotGranted, ChestError, QuotaExceeded, RateLimited, TooLarge, Unavailable } from "./errors.js";
|
|
2
|
+
// The Chest's API as a server tool reaches it, shared by the modules that
|
|
3
|
+
// call it (files, members): CHEST_API is http://127.0.0.1:<port>, the tool's
|
|
4
|
+
// launcher, which relays each request to the Chest — the container has no
|
|
5
|
+
// network. A call reaches what is the tool's only: its instance is its
|
|
6
|
+
// identity. Not a published module.
|
|
7
|
+
const maxAnswer = 4 << 20;
|
|
8
|
+
const deadline = 120000;
|
|
9
|
+
// base is the Chest's API as the launcher gives it; without, the version holds
|
|
10
|
+
// none of the capabilities that use it.
|
|
11
|
+
function base(capability) {
|
|
12
|
+
const value = process.env["CHEST_API"];
|
|
13
|
+
if (typeof value !== "string" || !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/u.test(value) || Number(value.slice(17)) > 65535)
|
|
14
|
+
throw new CapabilityNotGranted(capability);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
// ask sends one request of a capability to the Chest; a failure to reach it
|
|
18
|
+
// is Unavailable.
|
|
19
|
+
export async function ask(capability, method, path, init = {}) {
|
|
20
|
+
const headers = {};
|
|
21
|
+
if (init.type !== undefined)
|
|
22
|
+
headers["Content-Type"] = init.type;
|
|
23
|
+
const url = base(capability) + path;
|
|
24
|
+
try {
|
|
25
|
+
return await fetch(url, { method, headers, ...(init.body !== undefined ? { body: init.body } : {}), redirect: "error", signal: AbortSignal.timeout(deadline) });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Unavailable();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// read takes a body of limit bytes at most; beyond, or cut, the answer is not
|
|
32
|
+
// the Chest's.
|
|
33
|
+
export async function read(response, limit) {
|
|
34
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
35
|
+
if (declared > limit)
|
|
36
|
+
throw new Unavailable();
|
|
37
|
+
const chunks = [];
|
|
38
|
+
let size = 0;
|
|
39
|
+
try {
|
|
40
|
+
for await (const chunk of response.body ?? []) {
|
|
41
|
+
size += chunk.byteLength;
|
|
42
|
+
if (size > limit)
|
|
43
|
+
throw new Unavailable();
|
|
44
|
+
chunks.push(chunk);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Unavailable();
|
|
49
|
+
}
|
|
50
|
+
const all = new Uint8Array(size);
|
|
51
|
+
let at = 0;
|
|
52
|
+
for (const chunk of chunks) {
|
|
53
|
+
all.set(chunk, at);
|
|
54
|
+
at += chunk.byteLength;
|
|
55
|
+
}
|
|
56
|
+
return all;
|
|
57
|
+
}
|
|
58
|
+
// json reads an answer of the Chest as JSON; anything else is Unavailable.
|
|
59
|
+
export async function json(response) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await read(response, maxAnswer)));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Unavailable();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// refusal turns an answer that is not a success into what the tool tests.
|
|
68
|
+
export async function refusal(response, capability) {
|
|
69
|
+
let code = "refused";
|
|
70
|
+
try {
|
|
71
|
+
const given = (await json(response))?.error;
|
|
72
|
+
if (typeof given === "string" && /^[a-z_]{1,40}$/u.test(given))
|
|
73
|
+
code = given;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// The code stays "refused".
|
|
77
|
+
}
|
|
78
|
+
if (response.status === 403)
|
|
79
|
+
return new CapabilityNotGranted(capability);
|
|
80
|
+
if (response.status === 413)
|
|
81
|
+
return new TooLarge();
|
|
82
|
+
if (response.status === 429)
|
|
83
|
+
return code === "rate_limited" ? new RateLimited() : new QuotaExceeded();
|
|
84
|
+
if (response.status >= 500)
|
|
85
|
+
return new Unavailable();
|
|
86
|
+
return new ChestError(code, response.status, `the Chest refused: ${code}`);
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.js","sourceRoot":"","sources":["../../client/src/api.ts"],"names":[],"mappings":"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"}
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export declare class CapabilityNotGranted extends ChestError {
|
|
|
9
9
|
export declare class QuotaExceeded extends ChestError {
|
|
10
10
|
constructor();
|
|
11
11
|
}
|
|
12
|
+
export declare class RateLimited extends ChestError {
|
|
13
|
+
constructor();
|
|
14
|
+
}
|
|
12
15
|
export declare class TooLarge extends ChestError {
|
|
13
16
|
constructor();
|
|
14
17
|
}
|
package/dist/src/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../client/src/errors.ts"],"names":[],"mappings":"AAEA,qBAAa,UAAW,SAAQ,KAAK;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,YAAY,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAKxD;CACF;AAKD,qBAAa,oBAAqB,SAAQ,UAAU;IAClD,YAAY,UAAU,EAAE,MAAM,EAE7B;CACF;
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../client/src/errors.ts"],"names":[],"mappings":"AAEA,qBAAa,UAAW,SAAQ,KAAK;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,YAAY,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAKxD;CACF;AAKD,qBAAa,oBAAqB,SAAQ,UAAU;IAClD,YAAY,UAAU,EAAE,MAAM,EAE7B;CACF;AAMD,qBAAa,aAAc,SAAQ,UAAU;IAC3C,cAEC;CACF;AAID,qBAAa,WAAY,SAAQ,UAAU;IACzC,cAEC;CACF;AAID,qBAAa,QAAS,SAAQ,UAAU;IACtC,cAEC;CACF;AAID,qBAAa,WAAY,SAAQ,UAAU;IACzC,cAEC;CACF"}
|
package/dist/src/errors.js
CHANGED
|
@@ -18,14 +18,24 @@ export class CapabilityNotGranted extends ChestError {
|
|
|
18
18
|
super("capability_not_granted", 403, `the Chest did not grant the capability "${capability}" to this version of the tool`);
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
//
|
|
22
|
-
// (1 GiB
|
|
21
|
+
// The call would take the tool beyond what its Chest gives it: for files,
|
|
22
|
+
// their total (1 GiB unless its manifest asks more) or their count (10,000
|
|
23
|
+
// objects); for notifications, 1,000 recipients an hour, 100 items per member
|
|
24
|
+
// a day or 600 badge writes a minute. A refused call changes nothing.
|
|
23
25
|
export class QuotaExceeded extends ChestError {
|
|
24
26
|
constructor() {
|
|
25
27
|
super("quota_exceeded", 429, "the Chest refused: the tool's quota would be exceeded");
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
|
-
//
|
|
30
|
+
// The tool called the Chest's API more often than its bound (600 calls a
|
|
31
|
+
// minute for its members): it waits before calling again.
|
|
32
|
+
export class RateLimited extends ChestError {
|
|
33
|
+
constructor() {
|
|
34
|
+
super("rate_limited", 429, "the Chest refused: too many calls, wait a minute");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// One object is beyond the bound of one (for a file, the tool's largest
|
|
38
|
+
// object: 32 MiB unless its manifest asks more, 512 MiB at most).
|
|
29
39
|
export class TooLarge extends ChestError {
|
|
30
40
|
constructor() {
|
|
31
41
|
super("too_large", 413, "the Chest refused: the object is too large");
|
package/dist/src/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../client/src/errors.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,mEAAmE;AACnE,MAAM,OAAO,UAAW,SAAQ,KAAK;IAC1B,IAAI,CAAS;IACb,MAAM,CAAS;IACxB,YAAY,IAAY,EAAE,MAAc,EAAE,OAAe;QACvD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;CACF;AAED,4EAA4E;AAC5E,0EAA0E;AAC1E,WAAW;AACX,MAAM,OAAO,oBAAqB,SAAQ,UAAU;IAClD,YAAY,UAAkB;QAC5B,KAAK,CAAC,wBAAwB,EAAE,GAAG,EAAE,2CAA2C,UAAU,+BAA+B,CAAC,CAAC;IAC7H,CAAC;CACF;AAED,
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../client/src/errors.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,mEAAmE;AACnE,MAAM,OAAO,UAAW,SAAQ,KAAK;IAC1B,IAAI,CAAS;IACb,MAAM,CAAS;IACxB,YAAY,IAAY,EAAE,MAAc,EAAE,OAAe;QACvD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;CACF;AAED,4EAA4E;AAC5E,0EAA0E;AAC1E,WAAW;AACX,MAAM,OAAO,oBAAqB,SAAQ,UAAU;IAClD,YAAY,UAAkB;QAC5B,KAAK,CAAC,wBAAwB,EAAE,GAAG,EAAE,2CAA2C,UAAU,+BAA+B,CAAC,CAAC;IAC7H,CAAC;CACF;AAED,0EAA0E;AAC1E,2EAA2E;AAC3E,8EAA8E;AAC9E,sEAAsE;AACtE,MAAM,OAAO,aAAc,SAAQ,UAAU;IAC3C;QACE,KAAK,CAAC,gBAAgB,EAAE,GAAG,EAAE,uDAAuD,CAAC,CAAC;IACxF,CAAC;CACF;AAED,yEAAyE;AACzE,0DAA0D;AAC1D,MAAM,OAAO,WAAY,SAAQ,UAAU;IACzC;QACE,KAAK,CAAC,cAAc,EAAE,GAAG,EAAE,kDAAkD,CAAC,CAAC;IACjF,CAAC;CACF;AAED,wEAAwE;AACxE,kEAAkE;AAClE,MAAM,OAAO,QAAS,SAAQ,UAAU;IACtC;QACE,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,4CAA4C,CAAC,CAAC;IACxE,CAAC;CACF;AAED,4EAA4E;AAC5E,gDAAgD;AAChD,MAAM,OAAO,WAAY,SAAQ,UAAU;IACzC;QACE,KAAK,CAAC,aAAa,EAAE,GAAG,EAAE,0BAA0B,CAAC,CAAC;IACxD,CAAC;CACF"}
|