@argentic/chest-sdk 0.1.1 → 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 +381 -35
- package/client/index.ts +10 -5
- 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 +93 -88
- 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 -5
- 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 +95 -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,228 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
3
|
+
import { ask, refusal } from "./api.js";
|
|
4
|
+
import { ChestError, Unavailable } from "./errors.js";
|
|
5
|
+
import { memberIdPattern } from "./member.js";
|
|
6
|
+
import { forget } from "./members.js";
|
|
7
|
+
|
|
8
|
+
// What the Chest tells a server tool of its members' lifecycle, for a tool
|
|
9
|
+
// whose chest.json declares "capabilities": ["members"] and "receives":
|
|
10
|
+
// ["member.*"]. The Chest posts each event to the tool's POST /chest-events,
|
|
11
|
+
// through its launcher only (never from the Internet), signed for this tool:
|
|
12
|
+
//
|
|
13
|
+
// // app/chest-events/route.ts (Next.js): outside /chest, never behind a session
|
|
14
|
+
// import * as events from "@argentic/chest-sdk/events";
|
|
15
|
+
// export async function POST(request: Request) {
|
|
16
|
+
// return new Response(null, { status: await events.handle(request, {
|
|
17
|
+
// "member.erased": async e => { await anonymise(e.data.id); await events.acknowledgeErasure(e.data.erasure); },
|
|
18
|
+
// "access.revoked": e => unassign(e.data.id),
|
|
19
|
+
// }, { seen }) });
|
|
20
|
+
// }
|
|
21
|
+
//
|
|
22
|
+
// Delivery is at least once, in no guaranteed order: the same event may come
|
|
23
|
+
// again, always with the same id — handle() drops what the store of seen ids
|
|
24
|
+
// already holds. An event the tool does not answer with a success is
|
|
25
|
+
// delivered again, after a growing delay, for 72 hours; after that the tool
|
|
26
|
+
// is out of sync and reconciles by listing its members (members.list) at its
|
|
27
|
+
// next start. Every event also empties what members.lookup keeps.
|
|
28
|
+
|
|
29
|
+
// What changed of a member the tool sees; "email" only with members.email.
|
|
30
|
+
export type MemberChange = "name" | "photo" | "role" | "groups" | "email";
|
|
31
|
+
// Something the tool sees of a member who has it changed.
|
|
32
|
+
export type MemberUpdated = { id: string; type: "member.updated"; occurredAt: string; data: { id: string; changed: MemberChange[] } };
|
|
33
|
+
// The member lost access to the tool but stays in the Chest.
|
|
34
|
+
export type AccessRevoked = { id: string; type: "access.revoked"; occurredAt: string; data: { id: string } };
|
|
35
|
+
// The member left the Chest: members.lookup reads them "former".
|
|
36
|
+
export type MemberRemoved = { id: string; type: "member.removed"; occurredAt: string; data: { id: string } };
|
|
37
|
+
// The owner asked for this person's data to be erased: delete or anonymise
|
|
38
|
+
// what the tool keeps of them before deadline, then acknowledgeErasure(erasure).
|
|
39
|
+
export type MemberErased = { id: string; type: "member.erased"; occurredAt: string; data: { id: string; erasure: string; deadline: string } };
|
|
40
|
+
// An event, told apart by its type.
|
|
41
|
+
export type ChestEvent = MemberUpdated | AccessRevoked | MemberRemoved | MemberErased;
|
|
42
|
+
export type ChestEventType = ChestEvent["type"];
|
|
43
|
+
|
|
44
|
+
// What handle() calls for each type; a type left out is accepted and ignored.
|
|
45
|
+
export type Handlers = { [K in ChestEventType]?: (event: Extract<ChestEvent, { type: K }>) => void | Promise<void> };
|
|
46
|
+
|
|
47
|
+
// Where handle() remembers the ids of the events already handled: a store
|
|
48
|
+
// the tool chooses. Keep it durable — a table of the tool's database — so a
|
|
49
|
+
// delivery made again after a restart of the tool is recognised:
|
|
50
|
+
//
|
|
51
|
+
// create table chest_events (id text primary key, at timestamptz not null default now());
|
|
52
|
+
// const seen = {
|
|
53
|
+
// has: async (id: string) => (await sql`select 1 from chest_events where id = ${id}`).length > 0,
|
|
54
|
+
// add: async (id: string) => { await sql`insert into chest_events (id) values (${id}) on conflict do nothing`; },
|
|
55
|
+
// };
|
|
56
|
+
export type Seen = { has(id: string): boolean | Promise<boolean>; add(id: string): void | Promise<void> };
|
|
57
|
+
|
|
58
|
+
// memorySeen keeps the last limit ids in this process: lost at a restart,
|
|
59
|
+
// enough for a tool whose handlers are idempotent anyway.
|
|
60
|
+
export function memorySeen(limit = 10000): Seen {
|
|
61
|
+
const ids = new Set<string>();
|
|
62
|
+
return {
|
|
63
|
+
has: id => ids.has(id),
|
|
64
|
+
add: id => {
|
|
65
|
+
ids.delete(id);
|
|
66
|
+
ids.add(id);
|
|
67
|
+
while (ids.size > limit) ids.delete(ids.values().next().value as string);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The key of the events is HMAC-SHA256 of this label under the text of
|
|
73
|
+
// CHEST_TOKEN, exactly as the Chest derives it (chest/toolfront): neither the
|
|
74
|
+
// token itself nor the key of the Chest-Member assertion.
|
|
75
|
+
const label = "Chest-Event v1";
|
|
76
|
+
const claims = ["aud", "iat", "exp", "jti", "digest"] as const;
|
|
77
|
+
// Clocks of the Chest and of the container may differ by this much, in seconds.
|
|
78
|
+
const skew = 5;
|
|
79
|
+
const maxSignature = 2048;
|
|
80
|
+
const maxBody = 64 << 10;
|
|
81
|
+
const compact = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/u;
|
|
82
|
+
const eventIdPattern = /^evt_[a-z2-7]{26}$/u;
|
|
83
|
+
// The grammar of an erasure's identifier, as the Chest mints it.
|
|
84
|
+
export const erasureIdPattern = /^era_[a-z2-7]{26}$/u;
|
|
85
|
+
const changes: readonly string[] = ["name", "photo", "role", "groups", "email"];
|
|
86
|
+
|
|
87
|
+
function object(value: unknown): Record<string, unknown> | null {
|
|
88
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
89
|
+
}
|
|
90
|
+
function json(raw: string): unknown {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(raw) as unknown;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const instant = (v: unknown): v is string => typeof v === "string" && v.length <= 40 && !Number.isNaN(Date.parse(v));
|
|
98
|
+
|
|
99
|
+
function headerOf(request: IncomingMessage | Request): string | null {
|
|
100
|
+
const headers = request.headers as Headers | IncomingMessage["headers"];
|
|
101
|
+
const value = typeof (headers as Headers).get === "function" ? (headers as Headers).get("chest-event") : (headers as IncomingMessage["headers"])["chest-event"];
|
|
102
|
+
return typeof value === "string" && value.length <= maxSignature ? value : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// bodyOf reads the body, 64 KiB at most; null beyond, or when it was read
|
|
106
|
+
// already.
|
|
107
|
+
async function bodyOf(request: IncomingMessage | Request): Promise<Buffer | null> {
|
|
108
|
+
try {
|
|
109
|
+
if (request instanceof Request) {
|
|
110
|
+
if (request.bodyUsed || Number(request.headers.get("content-length") ?? "0") > maxBody) return null;
|
|
111
|
+
const raw = Buffer.from(await request.arrayBuffer());
|
|
112
|
+
return raw.length <= maxBody ? raw : null;
|
|
113
|
+
}
|
|
114
|
+
const chunks: Buffer[] = [];
|
|
115
|
+
let size = 0;
|
|
116
|
+
for await (const chunk of request) {
|
|
117
|
+
size += (chunk as Buffer).length;
|
|
118
|
+
if (size > maxBody) return null;
|
|
119
|
+
chunks.push(chunk as Buffer);
|
|
120
|
+
}
|
|
121
|
+
return Buffer.concat(chunks);
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// envelope reads a signed delivery: the envelope when the signature, the tool,
|
|
128
|
+
// the time and the digest of the body hold; known says its type is one this
|
|
129
|
+
// SDK reads.
|
|
130
|
+
async function envelope(request: IncomingMessage | Request): Promise<{ event: ChestEvent; known: true } | { event: { id: string }; known: false } | null> {
|
|
131
|
+
const token = process.env["CHEST_TOKEN"];
|
|
132
|
+
const tool = process.env["CHEST_TOOL"];
|
|
133
|
+
if (!token || !/^[A-Za-z0-9_-]{43,512}$/u.test(token) || !tool || request.method !== "POST") return null;
|
|
134
|
+
const signature = headerOf(request);
|
|
135
|
+
const parts = signature === null ? null : compact.exec(signature);
|
|
136
|
+
if (!parts) return null;
|
|
137
|
+
const [, encodedHeader = "", encodedPayload = "", encodedSignature = ""] = parts;
|
|
138
|
+
const header = object(json(Buffer.from(encodedHeader, "base64url").toString("utf8")));
|
|
139
|
+
if (!header || Object.keys(header).length !== 2 || header["alg"] !== "HS256" || header["typ"] !== "JWT") return null;
|
|
140
|
+
const key = createHmac("sha256", Buffer.from(token, "utf8")).update(label).digest();
|
|
141
|
+
const expected = createHmac("sha256", key).update(encodedHeader + "." + encodedPayload).digest();
|
|
142
|
+
const given = Buffer.from(encodedSignature, "base64url");
|
|
143
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;
|
|
144
|
+
const payload = object(json(Buffer.from(encodedPayload, "base64url").toString("utf8")));
|
|
145
|
+
if (!payload || Object.keys(payload).length !== claims.length || !claims.every(name => Object.hasOwn(payload, name))) return null;
|
|
146
|
+
const { aud, iat, exp, jti, digest } = payload;
|
|
147
|
+
if (aud !== tool || typeof jti !== "string" || !eventIdPattern.test(jti) || typeof digest !== "string") return null;
|
|
148
|
+
if (typeof iat !== "number" || !Number.isSafeInteger(iat) || typeof exp !== "number" || !Number.isSafeInteger(exp) || exp <= iat) return null;
|
|
149
|
+
const now = Math.floor(Date.now() / 1000);
|
|
150
|
+
if (iat > now + skew || exp <= now - skew) return null;
|
|
151
|
+
const body = await bodyOf(request);
|
|
152
|
+
if (body === null) return null;
|
|
153
|
+
const sum = createHash("sha256").update(body).digest();
|
|
154
|
+
const told = Buffer.from(digest, "base64url");
|
|
155
|
+
if (told.length !== sum.length || !timingSafeEqual(told, sum)) return null;
|
|
156
|
+
const e = object(json(body.toString("utf8")));
|
|
157
|
+
if (!e || Object.keys(e).length !== 4 || e["id"] !== jti || typeof e["type"] !== "string" || !instant(e["occurredAt"])) return null;
|
|
158
|
+
const data = object(e["data"]);
|
|
159
|
+
if (!data || typeof data["id"] !== "string" || !memberIdPattern.test(data["id"])) return null;
|
|
160
|
+
const keys = Object.keys(data).sort().join(",");
|
|
161
|
+
const base = { id: jti, occurredAt: e["occurredAt"] as string };
|
|
162
|
+
switch (e["type"]) {
|
|
163
|
+
case "member.updated": {
|
|
164
|
+
const changed = data["changed"];
|
|
165
|
+
if (keys !== "changed,id" || !Array.isArray(changed) || changed.length < 1 || changed.length > changes.length || !changed.every(c => typeof c === "string" && changes.includes(c)) || new Set(changed).size !== changed.length) return null;
|
|
166
|
+
return { known: true, event: { ...base, type: "member.updated", data: { id: data["id"], changed: [...changed] as MemberChange[] } } };
|
|
167
|
+
}
|
|
168
|
+
case "access.revoked":
|
|
169
|
+
case "member.removed":
|
|
170
|
+
return keys === "id" ? { known: true, event: { ...base, type: e["type"], data: { id: data["id"] } } } : null;
|
|
171
|
+
case "member.erased":
|
|
172
|
+
if (keys !== "deadline,erasure,id" || typeof data["erasure"] !== "string" || !erasureIdPattern.test(data["erasure"]) || !instant(data["deadline"])) return null;
|
|
173
|
+
return { known: true, event: { ...base, type: "member.erased", data: { id: data["id"], erasure: data["erasure"], deadline: data["deadline"] as string } } };
|
|
174
|
+
}
|
|
175
|
+
// A type of a later Chest: signed, so the tool accepts it, and ignores it.
|
|
176
|
+
return { known: false, event: { id: jti } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// verify returns the event a delivery carries, or null when it is not one
|
|
180
|
+
// the Chest made for this tool — no or another signature, for another tool,
|
|
181
|
+
// expired, a body that is not the one signed, not a POST — or when it is of a
|
|
182
|
+
// type this SDK does not know. It reads the body (64 KiB at most): call it
|
|
183
|
+
// before anything else reads it. It never throws for what a request carries.
|
|
184
|
+
export async function verify(request: IncomingMessage | Request): Promise<ChestEvent | null> {
|
|
185
|
+
const read = await envelope(request);
|
|
186
|
+
return read?.known ? read.event : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const remembered = memorySeen();
|
|
190
|
+
|
|
191
|
+
// handle verifies one delivery and hands the event to its handler, once:
|
|
192
|
+
// the status to answer the Chest. 401 for a delivery that is not the
|
|
193
|
+
// Chest's; 204 for an event handled, one already seen (seen.has), a type
|
|
194
|
+
// without a handler or one this SDK does not know. A handler that throws
|
|
195
|
+
// leaves the event unseen and handle throws: answer 500, the Chest delivers
|
|
196
|
+
// it again. seen is the store of the ids handled (memorySeen by default,
|
|
197
|
+
// lost at a restart: give a durable one).
|
|
198
|
+
export async function handle(request: IncomingMessage | Request, handlers: Handlers, options: { seen?: Seen } = {}): Promise<number> {
|
|
199
|
+
const read = await envelope(request);
|
|
200
|
+
if (!read) return 401;
|
|
201
|
+
if (!read.known) return 204;
|
|
202
|
+
const seen = options.seen ?? remembered;
|
|
203
|
+
const event = read.event;
|
|
204
|
+
if (await seen.has(event.id)) return 204;
|
|
205
|
+
// A member changed or left: what lookup kept of them is stale.
|
|
206
|
+
forget();
|
|
207
|
+
const handler = handlers[event.type] as ((e: ChestEvent) => void | Promise<void>) | undefined;
|
|
208
|
+
if (handler) await handler(event);
|
|
209
|
+
await seen.add(event.id);
|
|
210
|
+
return 204;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// acknowledgeErasure tells the Chest the tool deleted or anonymised what it
|
|
214
|
+
// kept of the person of that erasure (member.erased): the owner sees it done.
|
|
215
|
+
// Acknowledging again is harmless. Errors: ChestError erasure_not_found
|
|
216
|
+
// (404, an erasure this tool was not told of), invalid_id (400),
|
|
217
|
+
// CapabilityNotGranted (403: the version does not receive member events),
|
|
218
|
+
// Unavailable.
|
|
219
|
+
export async function acknowledgeErasure(erasure: string): Promise<void> {
|
|
220
|
+
if (typeof erasure !== "string" || !erasureIdPattern.test(erasure)) throw new ChestError("invalid_id", 400, "invalid erasure identifier");
|
|
221
|
+
const response = await ask("events", "POST", `/erasures/${erasure}/done`);
|
|
222
|
+
if (response.status === 204) return;
|
|
223
|
+
if (response.status < 400) {
|
|
224
|
+
await response.body?.cancel();
|
|
225
|
+
throw new Unavailable();
|
|
226
|
+
}
|
|
227
|
+
throw await refusal(response, "events");
|
|
228
|
+
}
|
package/client/src/files.ts
CHANGED
|
@@ -1,115 +1,61 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { json, read, ask as chest, refusal as refused } from "./api.js";
|
|
2
|
+
import { ChestError, TooLarge, Unavailable } from "./errors.js";
|
|
2
3
|
|
|
3
4
|
// The private files of a server tool whose chest.json declares
|
|
4
5
|
// "capabilities": ["files"]: kept by its Chest (1 GiB, 10,000 objects, 32 MiB
|
|
5
|
-
// each
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// only: its instance is its identity.
|
|
6
|
+
// each, unless the manifest asks otherwise: "files": {"quota", "maxObject"}),
|
|
7
|
+
// never on the tool's own disk, through the Chest's API (CHEST_API, api.ts).
|
|
8
|
+
// A call reaches the tool's files only: its instance is its identity.
|
|
9
9
|
//
|
|
10
10
|
// import * as files from "@argentic/chest-sdk/files";
|
|
11
11
|
// await files.put("photos/cat.png", bytes, "image/png");
|
|
12
12
|
// const { url } = await files.url("photos/cat.png"); // 15 min, team host
|
|
13
|
+
// const up = await files.uploadUrl("photos/", { types: ["image/*"] }); // a member's browser sends it
|
|
13
14
|
//
|
|
14
15
|
// Names: up to 8 segments of 1–100 letters, digits, '.', '_' or '-',
|
|
15
16
|
// separated by '/', none starting with '.' or '-'. Errors: CapabilityNotGranted
|
|
16
17
|
// (403), TooLarge (413), QuotaExceeded (429), Unavailable (503, or the Chest
|
|
17
|
-
// not reached), ChestError otherwise (invalid_name, invalid_type
|
|
18
|
+
// not reached), ChestError otherwise (invalid_name, invalid_type,
|
|
19
|
+
// no_thumbnail 400, not_found 404…).
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
// width and height: of a JPEG, PNG, GIF or WebP image the Chest measured.
|
|
22
|
+
export type FileObject = { name: string; type: string; size: number; updated: string; width?: number; height?: number };
|
|
20
23
|
export type FileData = { data: Uint8Array; type: string; size: number };
|
|
21
24
|
export type FilePage = { files: FileObject[]; next: string | null };
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const deadline = 120000;
|
|
26
|
+
// The largest object a manifest may ask; the Chest holds each tool to its own.
|
|
27
|
+
const maxObject = 512 << 20;
|
|
26
28
|
// The grammar of a name, the same as the Chest's (chest/toolfiles, name).
|
|
27
29
|
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;
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
// A media type an upload accepts: without parameters, "family/*" for a whole
|
|
31
|
+
// family (RFC 6838 names).
|
|
32
|
+
const typePattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
|
|
33
|
+
// Where the team host serves a link, and where it takes an upload.
|
|
34
|
+
const linkPattern = /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?\/_chest\/files\/([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/u;
|
|
35
|
+
const uploadPattern = /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?\/_chest\/files\/upload\/([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/u;
|
|
36
|
+
// The longest an upload may wait, in seconds.
|
|
37
|
+
const uploadLife = 900;
|
|
36
38
|
|
|
37
39
|
function checkName(name: unknown): string {
|
|
38
40
|
if (typeof name !== "string" || !namePattern.test(name)) throw new ChestError("invalid_name", 400, "invalid file name");
|
|
39
41
|
return name;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const headers: Record<string, string> = {};
|
|
45
|
-
if (init.type !== undefined) headers["Content-Type"] = init.type;
|
|
46
|
-
try {
|
|
47
|
-
return await fetch(base() + path, { method, headers, ...(init.body !== undefined ? { body: init.body } : {}), redirect: "error", signal: AbortSignal.timeout(deadline) });
|
|
48
|
-
} catch (error) {
|
|
49
|
-
if (error instanceof ChestError) throw error;
|
|
50
|
-
throw new Unavailable();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// read takes a body of limit bytes at most; beyond, or cut, the answer is not
|
|
55
|
-
// the Chest's.
|
|
56
|
-
async function read(response: Response, limit: number): Promise<Uint8Array> {
|
|
57
|
-
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
58
|
-
if (declared > limit) throw new Unavailable();
|
|
59
|
-
const chunks: Uint8Array[] = [];
|
|
60
|
-
let size = 0;
|
|
61
|
-
try {
|
|
62
|
-
for await (const chunk of response.body ?? []) {
|
|
63
|
-
size += chunk.byteLength;
|
|
64
|
-
if (size > limit) throw new Unavailable();
|
|
65
|
-
chunks.push(chunk);
|
|
66
|
-
}
|
|
67
|
-
} catch {
|
|
68
|
-
throw new Unavailable();
|
|
69
|
-
}
|
|
70
|
-
const all = new Uint8Array(size);
|
|
71
|
-
let at = 0;
|
|
72
|
-
for (const chunk of chunks) {
|
|
73
|
-
all.set(chunk, at);
|
|
74
|
-
at += chunk.byteLength;
|
|
75
|
-
}
|
|
76
|
-
return all;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function json(response: Response): Promise<unknown> {
|
|
80
|
-
try {
|
|
81
|
-
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await read(response, maxAnswer)));
|
|
82
|
-
} catch (error) {
|
|
83
|
-
if (error instanceof ChestError) throw error;
|
|
84
|
-
throw new Unavailable();
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// refusal turns an answer that is not a success into what the tool tests.
|
|
89
|
-
async function refusal(response: Response): Promise<ChestError> {
|
|
90
|
-
if (response.status === 403) return new CapabilityNotGranted("files");
|
|
91
|
-
if (response.status === 413) return new TooLarge();
|
|
92
|
-
if (response.status === 429) return new QuotaExceeded();
|
|
93
|
-
if (response.status >= 500) return new Unavailable();
|
|
94
|
-
let code = "refused";
|
|
95
|
-
try {
|
|
96
|
-
const body = await json(response);
|
|
97
|
-
const given = (body as { error?: unknown } | null)?.error;
|
|
98
|
-
if (typeof given === "string" && /^[a-z_]{1,40}$/u.test(given)) code = given;
|
|
99
|
-
} catch {
|
|
100
|
-
// The code stays "refused".
|
|
101
|
-
}
|
|
102
|
-
return new ChestError(code, response.status, `the Chest refused: ${code}`);
|
|
103
|
-
}
|
|
44
|
+
const ask = (method: string, path: string, init?: { body?: Uint8Array<ArrayBuffer> | string; type?: string }) => chest("files", method, path, init);
|
|
45
|
+
const refusal = (response: Response) => refused(response, "files");
|
|
104
46
|
|
|
105
47
|
function isObject(value: unknown): value is FileObject {
|
|
106
48
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
107
49
|
const o = value as Record<string, unknown>;
|
|
108
|
-
return typeof o["name"] === "string" && namePattern.test(o["name"]) && typeof o["type"] === "string" && o["type"].length <= 200 && typeof o["size"] === "number" && Number.isSafeInteger(o["size"]) && o["size"] >= 0 && typeof o["updated"] === "string";
|
|
50
|
+
return typeof o["name"] === "string" && namePattern.test(o["name"]) && typeof o["type"] === "string" && o["type"].length <= 200 && typeof o["size"] === "number" && Number.isSafeInteger(o["size"]) && o["size"] >= 0 && typeof o["updated"] === "string" && isSide(o["width"]) && isSide(o["height"]) && (o["width"] === undefined) === (o["height"] === undefined);
|
|
51
|
+
}
|
|
52
|
+
// isSide accepts a side of an image the Chest measured, or none.
|
|
53
|
+
function isSide(value: unknown): boolean {
|
|
54
|
+
return value === undefined || (typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65536);
|
|
109
55
|
}
|
|
110
56
|
function object(value: unknown): FileObject {
|
|
111
57
|
if (!isObject(value)) throw new Unavailable();
|
|
112
|
-
return { name: value.name, type: value.type, size: value.size, updated: value.updated };
|
|
58
|
+
return { name: value.name, type: value.type, size: value.size, updated: value.updated, ...(value.width !== undefined ? { width: value.width, height: value.height! } : {}) };
|
|
113
59
|
}
|
|
114
60
|
|
|
115
61
|
// put keeps data as the file name, of type type (application/octet-stream
|
|
@@ -117,6 +63,7 @@ function object(value: unknown): FileObject {
|
|
|
117
63
|
export async function put(name: string, data: Uint8Array | string, type?: string): Promise<FileObject> {
|
|
118
64
|
checkName(name);
|
|
119
65
|
if (!(data instanceof Uint8Array) && typeof data !== "string") throw new TypeError("data must be bytes or text");
|
|
66
|
+
if (data instanceof Uint8Array && data.byteLength > maxObject) throw new TooLarge();
|
|
120
67
|
const body = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
|
121
68
|
if (body.byteLength > maxObject) throw new TooLarge();
|
|
122
69
|
const response = await ask("PUT", "/files/" + name, { body, type: type ?? (typeof data === "string" ? "text/plain; charset=utf-8" : "application/octet-stream") });
|
|
@@ -168,15 +115,73 @@ async function remove(name: string): Promise<boolean> {
|
|
|
168
115
|
}
|
|
169
116
|
export { remove as delete };
|
|
170
117
|
|
|
118
|
+
// stat says what the tool keeps as the file name, without reading it; null
|
|
119
|
+
// when it has none of that name.
|
|
120
|
+
export async function stat(name: string): Promise<FileObject | null> {
|
|
121
|
+
checkName(name);
|
|
122
|
+
const response = await ask("GET", "/files/" + name + "?stat");
|
|
123
|
+
if (response.status === 404) {
|
|
124
|
+
await response.body?.cancel();
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (response.status !== 200) throw await refusal(response);
|
|
128
|
+
const found = object(await json(response));
|
|
129
|
+
if (found.name !== name) throw new Unavailable();
|
|
130
|
+
return found;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// move renames the file from as to, at once, replacing the one named to;
|
|
134
|
+
// ChestError not_found when the tool has none named from.
|
|
135
|
+
export async function move(from: string, to: string): Promise<FileObject> {
|
|
136
|
+
checkName(from);
|
|
137
|
+
checkName(to);
|
|
138
|
+
const response = await ask("POST", "/files/move", { body: JSON.stringify({ from, to }), type: "application/json" });
|
|
139
|
+
if (response.status !== 200) throw await refusal(response);
|
|
140
|
+
const moved = object(await json(response));
|
|
141
|
+
if (moved.name !== to) throw new Unavailable();
|
|
142
|
+
return moved;
|
|
143
|
+
}
|
|
144
|
+
|
|
171
145
|
// url signs a link to the file as it is now, on the tool's team host: anyone
|
|
172
146
|
// who has it opens the file, without signing in, for expiresIn seconds
|
|
173
|
-
// (15 minutes), or until the file changes or goes.
|
|
174
|
-
//
|
|
175
|
-
|
|
147
|
+
// (15 minutes), or until the file changes or goes. thumbnail links to the
|
|
148
|
+
// image reduced to 256 or 1024 pixels (JPEG, PNG, GIF, WebP; ChestError
|
|
149
|
+
// no_thumbnail otherwise); download has it saved rather than shown. Give it
|
|
150
|
+
// to a member's browser, never to a public page.
|
|
151
|
+
export async function url(name: string, options: { thumbnail?: 256 | 1024; download?: boolean } = {}): Promise<{ url: string; expiresIn: number }> {
|
|
176
152
|
checkName(name);
|
|
177
|
-
const
|
|
153
|
+
const { thumbnail, download } = options;
|
|
154
|
+
if (thumbnail !== undefined && thumbnail !== 256 && thumbnail !== 1024) throw new ChestError("invalid_body", 400, "a thumbnail is 256 or 1024 pixels");
|
|
155
|
+
if (download !== undefined && typeof download !== "boolean") throw new TypeError("download must be true or false");
|
|
156
|
+
const command = { name, ...(thumbnail !== undefined ? { thumbnail } : {}), ...(download !== undefined ? { download } : {}) };
|
|
157
|
+
const response = await ask("POST", "/files/url", { body: JSON.stringify(command), type: "application/json" });
|
|
178
158
|
if (response.status !== 200) throw await refusal(response);
|
|
179
159
|
const body = (await json(response)) as { url?: unknown; expires_in?: unknown } | null;
|
|
180
|
-
|
|
181
|
-
|
|
160
|
+
const token = body && typeof body.url === "string" ? linkPattern.exec(body.url)?.[2] : undefined;
|
|
161
|
+
if (!body || token === undefined || token.length > 1536 || typeof body.expires_in !== "number" || !Number.isInteger(body.expires_in) || body.expires_in <= 0) throw new Unavailable();
|
|
162
|
+
return { url: body.url as string, expiresIn: body.expires_in };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// uploadUrl authorises one upload from a member's browser, which sends the
|
|
166
|
+
// file itself to url, the tool's team host, with its session:
|
|
167
|
+
// fetch(up.url, { method: "PUT", body: file, headers: { "Content-Type": file.type } })
|
|
168
|
+
// name is the object's, or a folder ending in '/' where the Chest names it;
|
|
169
|
+
// maxSize bytes at most (the tool's largest object when not said), of types
|
|
170
|
+
// (up to 8, "image/*" for a family; any when not said), within expiresIn
|
|
171
|
+
// seconds (1–900, 900 when not said), once. Call it from a /chest route,
|
|
172
|
+
// after member(); then stat the name before recording it.
|
|
173
|
+
export async function uploadUrl(name: string, options: { maxSize?: number; types?: string[]; expiresIn?: number } = {}): Promise<{ url: string; method: "PUT"; expiresIn: number }> {
|
|
174
|
+
if (typeof name !== "string" || !(name.endsWith("/") ? namePattern.test(name.slice(0, -1)) && name.split("/").length <= 8 : namePattern.test(name))) throw new ChestError("invalid_name", 400, "invalid file or folder name");
|
|
175
|
+
const { maxSize, types, expiresIn } = options;
|
|
176
|
+
if (maxSize !== undefined && (typeof maxSize !== "number" || !Number.isSafeInteger(maxSize) || maxSize < 1)) throw new ChestError("invalid_body", 400, "maxSize is a number of bytes");
|
|
177
|
+
if (maxSize !== undefined && maxSize > maxObject) throw new TooLarge();
|
|
178
|
+
if (types !== undefined && (!Array.isArray(types) || types.length > 8 || types.some((t, i) => typeof t !== "string" || t.length > 100 || !typePattern.test(t) || types.indexOf(t) !== i))) throw new ChestError("invalid_type", 400, "invalid media types");
|
|
179
|
+
if (expiresIn !== undefined && (typeof expiresIn !== "number" || !Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > uploadLife)) throw new ChestError("invalid_body", 400, "expiresIn is 1 to 900 seconds");
|
|
180
|
+
const command = { name, ...(maxSize !== undefined ? { max_size: maxSize } : {}), ...(types !== undefined ? { types } : {}), ...(expiresIn !== undefined ? { expires_in: expiresIn } : {}) };
|
|
181
|
+
const response = await ask("POST", "/files/upload-url", { body: JSON.stringify(command), type: "application/json" });
|
|
182
|
+
if (response.status !== 200) throw await refusal(response);
|
|
183
|
+
const body = (await json(response)) as { url?: unknown; method?: unknown; expires_in?: unknown } | null;
|
|
184
|
+
const token = body && typeof body.url === "string" ? uploadPattern.exec(body.url)?.[2] : undefined;
|
|
185
|
+
if (!body || token === undefined || token.length > 2048 || body.method !== "PUT" || typeof body.expires_in !== "number" || !Number.isInteger(body.expires_in) || body.expires_in < 1 || body.expires_in > uploadLife) throw new Unavailable();
|
|
186
|
+
return { url: body.url as string, method: "PUT", expiresIn: body.expires_in };
|
|
182
187
|
}
|
package/client/src/member.ts
CHANGED
|
@@ -1,30 +1,51 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { IncomingMessage } from "node:http";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// A member of the Chest, as the tool sees them: on a request of its team host
|
|
5
|
+
// (member), and in its members (members.ts).
|
|
6
|
+
//
|
|
7
|
+
// - id is the member's identifier in this Chest, "mbr_" and 26 characters:
|
|
8
|
+
// the same in every tool of the Chest, never reused, never derived from an
|
|
9
|
+
// address or an account. Store it; resolve names when rendering.
|
|
10
|
+
// - name is "First Last", or the local part of the address when the member
|
|
11
|
+
// set no name.
|
|
12
|
+
// - photo is the address of their picture on the tool's team host
|
|
13
|
+
// (/_chest/members/{id}/photo?v=<rev>), role one of the roles chest.json
|
|
14
|
+
// declares: null when there is none.
|
|
15
|
+
// - isBuilder says they build this tool; groups are the groups that give them
|
|
16
|
+
// this tool ("grp_…").
|
|
17
|
+
// - email is there only when the tool holds "members.email".
|
|
7
18
|
export type Member = {
|
|
8
19
|
id: string;
|
|
9
20
|
firstName: string;
|
|
10
21
|
lastName: string;
|
|
11
22
|
name: string;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
role?: string;
|
|
23
|
+
photo: string | null;
|
|
24
|
+
role: string | null;
|
|
15
25
|
isAdmin: boolean;
|
|
16
26
|
isBuilder: boolean;
|
|
27
|
+
groups: string[];
|
|
28
|
+
email?: string;
|
|
17
29
|
};
|
|
18
30
|
|
|
31
|
+
// The grammars of the identifiers the Chest mints: a tool may check with them
|
|
32
|
+
// the identifiers it stores.
|
|
33
|
+
export const memberIdPattern = /^mbr_[a-z2-7]{26}$/u;
|
|
34
|
+
export const groupIdPattern = /^grp_[a-z2-7]{26}$/u;
|
|
35
|
+
|
|
19
36
|
// The key of the assertions is HMAC-SHA256 of this label under the text of
|
|
20
|
-
// CHEST_TOKEN, exactly as the Chest derives it (chest/toolfront).
|
|
21
|
-
|
|
37
|
+
// CHEST_TOKEN, exactly as the Chest derives it (chest/toolfront). Its version
|
|
38
|
+
// is the shape of the claims: an assertion of another shape is refused. This
|
|
39
|
+
// module stands alone (node:* only), so that it can be copied by itself.
|
|
40
|
+
const label = "Chest-Member v2";
|
|
41
|
+
// The claims every assertion carries; email only for a tool that holds
|
|
42
|
+
// members.email.
|
|
43
|
+
const claims = ["iss", "aud", "iat", "exp", "sub", "given_name", "family_name", "name", "picture", "role", "admin", "builder", "groups"] as const;
|
|
22
44
|
// Clocks of the Chest and of the container may differ by this much, in seconds.
|
|
23
45
|
const skew = 5;
|
|
24
46
|
// An assertion is a few hundred bytes; anything longer is not one.
|
|
25
47
|
const maxLength = 8192;
|
|
26
48
|
const compact = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/u;
|
|
27
|
-
const claims = ["iss", "aud", "iat", "exp", "sub", "given_name", "family_name", "name", "email", "picture", "role", "admin", "builder"] as const;
|
|
28
49
|
|
|
29
50
|
function assertionOf(request: IncomingMessage | Request): string | null {
|
|
30
51
|
const headers = request.headers as Headers | IncomingMessage["headers"];
|
|
@@ -45,10 +66,11 @@ function json(part: string): Record<string, unknown> | null {
|
|
|
45
66
|
|
|
46
67
|
// member returns who the Chest says is making this request, or null when the
|
|
47
68
|
// request carries no valid assertion — absent, malformed, signed with another
|
|
48
|
-
// key, for another tool, expired or not yet valid —, or
|
|
49
|
-
// CHEST_TOOL is missing. It never throws for what a
|
|
50
|
-
// Chest's front reaches the tool; the signature is a
|
|
51
|
-
// tool still decides what a member may do with its
|
|
69
|
+
// key or for another shape, for another tool, expired or not yet valid —, or
|
|
70
|
+
// when CHEST_TOKEN or CHEST_TOOL is missing. It never throws for what a
|
|
71
|
+
// request carries. Only the Chest's front reaches the tool; the signature is a
|
|
72
|
+
// second defence, and the tool still decides what a member may do with its
|
|
73
|
+
// own rules.
|
|
52
74
|
export function member(request: IncomingMessage | Request): Member | null {
|
|
53
75
|
const token = process.env["CHEST_TOKEN"];
|
|
54
76
|
const tool = process.env["CHEST_TOOL"];
|
|
@@ -65,11 +87,12 @@ export function member(request: IncomingMessage | Request): Member | null {
|
|
|
65
87
|
if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) return null;
|
|
66
88
|
const payload = json(encodedPayload);
|
|
67
89
|
if (!payload || !claims.every(name => Object.hasOwn(payload, name))) return null;
|
|
68
|
-
const { iss, aud, iat, exp, sub, given_name, family_name, name, email, picture, role, admin, builder } = payload;
|
|
69
|
-
if (typeof iss !== "string" || iss === "" || aud !== tool || typeof sub !== "string" || sub
|
|
90
|
+
const { iss, aud, iat, exp, sub, given_name, family_name, name, email, picture, role, admin, builder, groups } = payload;
|
|
91
|
+
if (typeof iss !== "string" || iss === "" || aud !== tool || typeof sub !== "string" || !memberIdPattern.test(sub)) return null;
|
|
70
92
|
if (typeof iat !== "number" || !Number.isSafeInteger(iat) || typeof exp !== "number" || !Number.isSafeInteger(exp) || exp <= iat) return null;
|
|
71
93
|
const now = Math.floor(Date.now() / 1000);
|
|
72
94
|
if (iat > now + skew || exp <= now - skew) return null;
|
|
73
|
-
if (typeof given_name !== "string" || typeof family_name !== "string" || typeof name !== "string" || typeof
|
|
74
|
-
|
|
95
|
+
if (typeof given_name !== "string" || typeof family_name !== "string" || typeof name !== "string" || typeof picture !== "string" || typeof role !== "string" || typeof admin !== "boolean" || typeof builder !== "boolean") return null;
|
|
96
|
+
if (!Array.isArray(groups) || groups.length > 16 || !groups.every(g => typeof g === "string" && groupIdPattern.test(g)) || (email !== undefined && typeof email !== "string")) return null;
|
|
97
|
+
return { id: sub, firstName: given_name, lastName: family_name, name, photo: picture === "" ? null : picture, role: role === "" ? null : role, isAdmin: admin, isBuilder: builder, groups: [...groups] as string[], ...(email === undefined ? {} : { email }) };
|
|
75
98
|
}
|