@argentic/chest-sdk 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +208 -43
- package/client/index.ts +4 -3
- package/client/src/api.ts +18 -0
- package/client/src/chest.ts +30 -6
- package/client/src/events.ts +12 -104
- package/client/src/files.ts +9 -9
- package/client/src/members.ts +18 -13
- package/client/src/schedules.ts +87 -0
- package/client/src/signed.ts +166 -0
- package/client/src/testing.ts +132 -54
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/src/api.d.ts +2 -0
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +18 -0
- package/dist/src/api.js.map +1 -1
- package/dist/src/chest.d.ts +5 -0
- package/dist/src/chest.d.ts.map +1 -1
- package/dist/src/chest.js +13 -1
- package/dist/src/chest.js.map +1 -1
- package/dist/src/events.d.ts +3 -6
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +8 -102
- package/dist/src/events.js.map +1 -1
- package/dist/src/files.d.ts +1 -0
- package/dist/src/files.d.ts.map +1 -1
- package/dist/src/files.js +6 -7
- package/dist/src/files.js.map +1 -1
- package/dist/src/members.d.ts +1 -1
- package/dist/src/members.d.ts.map +1 -1
- package/dist/src/members.js +6 -5
- package/dist/src/members.js.map +1 -1
- package/dist/src/schedules.d.ts +15 -0
- package/dist/src/schedules.d.ts.map +1 -0
- package/dist/src/schedules.js +45 -0
- package/dist/src/schedules.js.map +1 -0
- package/dist/src/signed.d.ts +29 -0
- package/dist/src/signed.d.ts.map +1 -0
- package/dist/src/signed.js +139 -0
- package/dist/src/signed.js.map +1 -0
- package/dist/src/testing.d.ts +15 -5
- package/dist/src/testing.d.ts.map +1 -1
- package/dist/src/testing.js +124 -52
- package/dist/src/testing.js.map +1 -1
- package/package.json +14 -3
package/client/src/events.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
1
|
import type { IncomingMessage } from "node:http";
|
|
3
2
|
import { ask, refusal } from "./api.js";
|
|
4
3
|
import { ChestError, Unavailable } from "./errors.js";
|
|
5
4
|
import { memberIdPattern } from "./member.js";
|
|
6
5
|
import { forget } from "./members.js";
|
|
6
|
+
import { delivery, eventChannel, instant, json, memorySeen, object, type Seen } from "./signed.js";
|
|
7
7
|
|
|
8
8
|
// What the Chest tells a server tool of its members' lifecycle, for a tool
|
|
9
9
|
// whose chest.json declares "capabilities": ["members"] and "receives":
|
|
@@ -26,8 +26,10 @@ import { forget } from "./members.js";
|
|
|
26
26
|
// is out of sync and reconciles by listing its members (members.list) at its
|
|
27
27
|
// next start. Every event also empties what members.lookup keeps.
|
|
28
28
|
|
|
29
|
-
// What changed of a member the tool sees; "email" only with members.email
|
|
30
|
-
|
|
29
|
+
// What changed of a member the tool sees; "email" only with members.email;
|
|
30
|
+
// "language" and "timeZone": the language the Chest speaks to them and the
|
|
31
|
+
// zone they work in — what the tool writes to them, and at what hour.
|
|
32
|
+
export type MemberChange = "name" | "photo" | "role" | "groups" | "email" | "language" | "timeZone";
|
|
31
33
|
// Something the tool sees of a member who has it changed.
|
|
32
34
|
export type MemberUpdated = { id: string; type: "member.updated"; occurredAt: string; data: { id: string; changed: MemberChange[] } };
|
|
33
35
|
// The member lost access to the tool but stays in the Chest.
|
|
@@ -44,115 +46,21 @@ export type ChestEventType = ChestEvent["type"];
|
|
|
44
46
|
// What handle() calls for each type; a type left out is accepted and ignored.
|
|
45
47
|
export type Handlers = { [K in ChestEventType]?: (event: Extract<ChestEvent, { type: K }>) => void | Promise<void> };
|
|
46
48
|
|
|
47
|
-
// Where handle() remembers the ids of the events already handled
|
|
48
|
-
//
|
|
49
|
-
|
|
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
|
-
}
|
|
49
|
+
// Where handle() remembers the ids of the events already handled (Seen), and
|
|
50
|
+
// memorySeen, which keeps them in this process: shared with schedules.
|
|
51
|
+
export { memorySeen, type Seen } from "./signed.js";
|
|
71
52
|
|
|
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
53
|
// The grammar of an erasure's identifier, as the Chest mints it.
|
|
84
54
|
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
|
-
}
|
|
55
|
+
const changes: readonly string[] = ["name", "photo", "role", "groups", "email", "language", "timeZone"];
|
|
126
56
|
|
|
127
57
|
// envelope reads a signed delivery: the envelope when the signature, the tool,
|
|
128
58
|
// the time and the digest of the body hold; known says its type is one this
|
|
129
59
|
// SDK reads.
|
|
130
60
|
async function envelope(request: IncomingMessage | Request): Promise<{ event: ChestEvent; known: true } | { event: { id: string }; known: false } | null> {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
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;
|
|
61
|
+
const signed = await delivery(request, eventChannel);
|
|
62
|
+
if (!signed) return null;
|
|
63
|
+
const { id: jti, body } = signed;
|
|
156
64
|
const e = object(json(body.toString("utf8")));
|
|
157
65
|
if (!e || Object.keys(e).length !== 4 || e["id"] !== jti || typeof e["type"] !== "string" || !instant(e["occurredAt"])) return null;
|
|
158
66
|
const data = object(e["data"]);
|
package/client/src/files.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { json, read, ask as chest, refusal as refused } from "./api.js";
|
|
1
|
+
import { chestLink, json, read, ask as chest, refusal as refused } from "./api.js";
|
|
2
2
|
import { ChestError, TooLarge, Unavailable } from "./errors.js";
|
|
3
3
|
|
|
4
4
|
// The private files of a server tool whose chest.json declares
|
|
@@ -18,8 +18,9 @@ import { ChestError, TooLarge, Unavailable } from "./errors.js";
|
|
|
18
18
|
// not reached), ChestError otherwise (invalid_name, invalid_type,
|
|
19
19
|
// no_thumbnail 400, not_found 404…).
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
|
|
21
|
+
// sha256: the digest of the content, in hex, as the Chest took it; width and
|
|
22
|
+
// height: of a JPEG, PNG, GIF or WebP image the Chest measured.
|
|
23
|
+
export type FileObject = { name: string; type: string; size: number; sha256: string; updated: string; width?: number; height?: number };
|
|
23
24
|
export type FileData = { data: Uint8Array; type: string; size: number };
|
|
24
25
|
export type FilePage = { files: FileObject[]; next: string | null };
|
|
25
26
|
|
|
@@ -31,8 +32,7 @@ const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}(\/[A-Za-z0-9][A-Za-z0-9._-
|
|
|
31
32
|
// family (RFC 6838 names).
|
|
32
33
|
const typePattern = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]{0,62})$/u;
|
|
33
34
|
// Where the team host serves a link, and where it takes an upload.
|
|
34
|
-
const
|
|
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;
|
|
35
|
+
const linkPath = "/_chest/files/", uploadPath = "/_chest/files/upload/";
|
|
36
36
|
// The longest an upload may wait, in seconds.
|
|
37
37
|
const uploadLife = 900;
|
|
38
38
|
|
|
@@ -47,7 +47,7 @@ const refusal = (response: Response) => refused(response, "files");
|
|
|
47
47
|
function isObject(value: unknown): value is FileObject {
|
|
48
48
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
49
49
|
const o = value as Record<string, unknown>;
|
|
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);
|
|
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["sha256"] === "string" && /^[a-f0-9]{64}$/u.test(o["sha256"]) && typeof o["updated"] === "string" && isSide(o["width"]) && isSide(o["height"]) && (o["width"] === undefined) === (o["height"] === undefined);
|
|
51
51
|
}
|
|
52
52
|
// isSide accepts a side of an image the Chest measured, or none.
|
|
53
53
|
function isSide(value: unknown): boolean {
|
|
@@ -55,7 +55,7 @@ function isSide(value: unknown): boolean {
|
|
|
55
55
|
}
|
|
56
56
|
function object(value: unknown): FileObject {
|
|
57
57
|
if (!isObject(value)) throw new Unavailable();
|
|
58
|
-
return { name: value.name, type: value.type, size: value.size, updated: value.updated, ...(value.width !== undefined ? { width: value.width, height: value.height! } : {}) };
|
|
58
|
+
return { name: value.name, type: value.type, size: value.size, sha256: value.sha256, updated: value.updated, ...(value.width !== undefined ? { width: value.width, height: value.height! } : {}) };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
// put keeps data as the file name, of type type (application/octet-stream
|
|
@@ -157,7 +157,7 @@ export async function url(name: string, options: { thumbnail?: 256 | 1024; downl
|
|
|
157
157
|
const response = await ask("POST", "/files/url", { body: JSON.stringify(command), type: "application/json" });
|
|
158
158
|
if (response.status !== 200) throw await refusal(response);
|
|
159
159
|
const body = (await json(response)) as { url?: unknown; expires_in?: unknown } | null;
|
|
160
|
-
const token = body
|
|
160
|
+
const token = body ? chestLink(body.url, linkPath) : undefined;
|
|
161
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
162
|
return { url: body.url as string, expiresIn: body.expires_in };
|
|
163
163
|
}
|
|
@@ -181,7 +181,7 @@ export async function uploadUrl(name: string, options: { maxSize?: number; types
|
|
|
181
181
|
const response = await ask("POST", "/files/upload-url", { body: JSON.stringify(command), type: "application/json" });
|
|
182
182
|
if (response.status !== 200) throw await refusal(response);
|
|
183
183
|
const body = (await json(response)) as { url?: unknown; method?: unknown; expires_in?: unknown } | null;
|
|
184
|
-
const token = body
|
|
184
|
+
const token = body ? chestLink(body.url, uploadPath) : undefined;
|
|
185
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
186
|
return { url: body.url as string, method: "PUT", expiresIn: body.expires_in };
|
|
187
187
|
}
|
package/client/src/members.ts
CHANGED
|
@@ -5,8 +5,9 @@ import { groupIdPattern, languagePattern, memberIdPattern, timeZonePattern, type
|
|
|
5
5
|
// Who has the tool, for a server tool whose chest.json declares
|
|
6
6
|
// "capabilities": ["members"] (and "members.email" for their addresses):
|
|
7
7
|
// exactly the members who have access to it at the time of the call — by a
|
|
8
|
-
// grant, a group, open to all, or because they run it.
|
|
9
|
-
//
|
|
8
|
+
// grant, a group, open to all, or because they run it. list and get see
|
|
9
|
+
// only them; lookup also names those the tool had who no longer have it
|
|
10
|
+
// (FormerMember), so that what they did keeps its author.
|
|
10
11
|
//
|
|
11
12
|
// import * as members from "@argentic/chest-sdk/members";
|
|
12
13
|
// const { members: page, next } = await members.list({ q: "cam" });
|
|
@@ -21,12 +22,15 @@ import { groupIdPattern, languagePattern, memberIdPattern, timeZonePattern, type
|
|
|
21
22
|
|
|
22
23
|
// A page of the list, and the cursor of the next one (null after the last).
|
|
23
24
|
export type MemberPage = { members: Member[]; next: string | null };
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
25
|
+
// Someone the tool had who no longer has it: "no_access" with their name, a
|
|
26
|
+
// member of the Chest who lost access to the tool — render “Léa Dubois (no
|
|
27
|
+
// access)” —; "former" with the name they had, a member who left the Chest —
|
|
28
|
+
// “Léa Dubois (former member)” —; or "erased" without any once the owner had
|
|
29
|
+
// their data erased — “Former member”.
|
|
30
|
+
export type FormerMember = { id: string; name: string | null; status: "no_access" | "former" | "erased" };
|
|
31
|
+
// What a lookup found: members who have the tool, those it had who no
|
|
32
|
+
// longer have it, and identifiers the tool does not know — never had, or
|
|
33
|
+
// forgotten.
|
|
30
34
|
export type Lookup = { members: Member[]; former: FormerMember[]; unknown: string[] };
|
|
31
35
|
// A group that gives the tool, with the identifiers of its members.
|
|
32
36
|
export type Group = { id: string; name: string; members: string[] };
|
|
@@ -76,8 +80,9 @@ export async function list(options: { after?: string; limit?: number; q?: string
|
|
|
76
80
|
return { members: page.members.map(shown), next: page.next };
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
// get is the member of that identifier, or null when
|
|
80
|
-
//
|
|
83
|
+
// get is the member of that identifier, or null when they do not have the
|
|
84
|
+
// tool: never a member, a former one, or one without access (lookup names
|
|
85
|
+
// the last two).
|
|
81
86
|
export async function get(id: string): Promise<Member | null> {
|
|
82
87
|
const response = await ask("members", "GET", "/members/" + checkId(id));
|
|
83
88
|
if (response.status === 404) {
|
|
@@ -106,8 +111,8 @@ function keep(id: string, answer: Omit<Known, "at">): void {
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
// lookup resolves identifiers, each once, in the order given: the members
|
|
109
|
-
// who have the tool,
|
|
110
|
-
// does not know. Any number of them: the SDK asks 200 at a time, and keeps
|
|
114
|
+
// who have the tool, those it had who no longer have it, and the identifiers
|
|
115
|
+
// it does not know. Any number of them: the SDK asks 200 at a time, and keeps
|
|
111
116
|
// each answer a minute.
|
|
112
117
|
export async function lookup(ids: Iterable<string>): Promise<Lookup> {
|
|
113
118
|
const wanted = [...new Set([...ids].map(checkId))];
|
|
@@ -129,7 +134,7 @@ export async function lookup(ids: Iterable<string>): Promise<Lookup> {
|
|
|
129
134
|
}
|
|
130
135
|
for (const value of answer.former) {
|
|
131
136
|
const f = value as { id?: unknown; name?: unknown; status?: unknown } | null;
|
|
132
|
-
if (!f || typeof f.id !== "string" || !memberIdPattern.test(f.id) || !(f.status === "former" ? f.name === undefined || text(f.name, 520) : f.status === "erased" && f.name === undefined)) throw new Unavailable();
|
|
137
|
+
if (!f || typeof f.id !== "string" || !memberIdPattern.test(f.id) || !(f.status === "former" ? f.name === undefined || text(f.name, 520) : f.status === "no_access" ? text(f.name, 520) : f.status === "erased" && f.name === undefined)) throw new Unavailable();
|
|
133
138
|
keep(f.id, { former: { id: f.id, name: (f.name as string | undefined) ?? null, status: f.status as FormerMember["status"] } });
|
|
134
139
|
told.add(f.id);
|
|
135
140
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import { delivery, instant, json, memorySeen, object, scheduleChannel, type Seen } from "./signed.js";
|
|
3
|
+
|
|
4
|
+
// Work a server tool does by itself, at set times, without anyone opening
|
|
5
|
+
// it: a morning digest, reminders, a purge, a badge kept true overnight.
|
|
6
|
+
// Nothing runs in the tool's container between requests — the tool may be
|
|
7
|
+
// asleep —: the Chest calls it. Its chest.json declares each schedule, a
|
|
8
|
+
// name and a cron line read on the wall clock of the Chest's time zone
|
|
9
|
+
// (chest.timeZone), which the owner approves in words ("Runs by itself:
|
|
10
|
+
// morning, weekdays at 7:30 AM"):
|
|
11
|
+
//
|
|
12
|
+
// "schedules": [{ "name": "morning", "cron": "30 7 * * 1-5" }]
|
|
13
|
+
//
|
|
14
|
+
// At each time, the Chest posts the run to the tool's POST /chest-schedules,
|
|
15
|
+
// through its launcher only (never from the Internet), signed for this tool,
|
|
16
|
+
// the tool woken first when it sleeps:
|
|
17
|
+
//
|
|
18
|
+
// // app/chest-schedules/route.ts (Next.js): outside /chest, never behind a session
|
|
19
|
+
// import * as schedules from "@argentic/chest-sdk/schedules";
|
|
20
|
+
// export async function POST(request: Request) {
|
|
21
|
+
// return new Response(null, { status: await schedules.handle(request, {
|
|
22
|
+
// morning: async run => { await remindDueToday(); },
|
|
23
|
+
// }, { seen }) });
|
|
24
|
+
// }
|
|
25
|
+
//
|
|
26
|
+
// The tool answers once its work is done, within 5 minutes. A run is
|
|
27
|
+
// delivered at least once, with the same id every time: one not answered
|
|
28
|
+
// with a success is delivered again after 1, 5 and 15 minutes (run.attempt
|
|
29
|
+
// counts, 4 at most), unless the next time of its schedule comes first.
|
|
30
|
+
// Runs of one schedule never overlap: a time that comes while the previous
|
|
31
|
+
// run still runs is skipped. A server that was stopped runs a missed time
|
|
32
|
+
// once when it starts again. Whoever runs the tool sees each run on its
|
|
33
|
+
// page, and may run a schedule now.
|
|
34
|
+
//
|
|
35
|
+
// Bounds (the Chest's): 8 schedules, each 15 minutes apart at least; 5
|
|
36
|
+
// minutes a run.
|
|
37
|
+
|
|
38
|
+
// A run: its id ("run_…", the same on every delivery of it), its schedule,
|
|
39
|
+
// the time it stands for (RFC 3339, UTC: a run asked now stands for the time
|
|
40
|
+
// it was asked), and its attempt (1 to 4).
|
|
41
|
+
export type Run = { id: string; name: string; scheduledAt: string; attempt: number };
|
|
42
|
+
|
|
43
|
+
// What handle() calls for each schedule, by its name.
|
|
44
|
+
export type Handlers = Record<string, (run: Run) => void | Promise<void>>;
|
|
45
|
+
|
|
46
|
+
export type { Seen };
|
|
47
|
+
|
|
48
|
+
// The grammar of a schedule's name, as the Chest keeps it.
|
|
49
|
+
const scheduleNamePattern = /^[a-z][a-z0-9-]{0,31}$/u;
|
|
50
|
+
|
|
51
|
+
// verify returns the run a delivery carries, or null when it is not one the
|
|
52
|
+
// Chest made for this tool: not a POST, no or another signature, another
|
|
53
|
+
// tool, expired, a body other than the one signed or not of a run's shape.
|
|
54
|
+
// It reads the body (1 KiB at most): call it before anything else reads it.
|
|
55
|
+
// It never throws for what a request carries.
|
|
56
|
+
export async function verify(request: IncomingMessage | Request): Promise<Run | null> {
|
|
57
|
+
const signed = await delivery(request, scheduleChannel);
|
|
58
|
+
if (!signed) return null;
|
|
59
|
+
const r = object(json(signed.body.toString("utf8")));
|
|
60
|
+
if (!r || Object.keys(r).sort().join(",") !== "attempt,id,name,scheduledAt" || r["id"] !== signed.id) return null;
|
|
61
|
+
const { name, scheduledAt, attempt } = r;
|
|
62
|
+
if (typeof name !== "string" || !scheduleNamePattern.test(name) || !instant(scheduledAt) || typeof attempt !== "number" || !Number.isInteger(attempt) || attempt < 1 || attempt > 4) return null;
|
|
63
|
+
return { id: signed.id, name, scheduledAt, attempt };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const remembered = memorySeen();
|
|
67
|
+
|
|
68
|
+
// handle verifies one delivery and runs the handler of its schedule, once:
|
|
69
|
+
// the status to answer the Chest. 401 for a delivery that is not the
|
|
70
|
+
// Chest's; 404 for a schedule without a handler (the Chest does not try
|
|
71
|
+
// again: the version in service does not do it); 204 once the handler
|
|
72
|
+
// returned, or for a run already handled (seen.has). A handler that throws
|
|
73
|
+
// leaves the run unseen and handle throws: answer 500, the Chest delivers
|
|
74
|
+
// it again. seen is the store of the runs handled (memorySeen by default,
|
|
75
|
+
// lost at a restart: give a durable one — events' store serves both, the
|
|
76
|
+
// ids never meet).
|
|
77
|
+
export async function handle(request: IncomingMessage | Request, handlers: Handlers, options: { seen?: Seen } = {}): Promise<number> {
|
|
78
|
+
const run = await verify(request);
|
|
79
|
+
if (!run) return 401;
|
|
80
|
+
const handler = Object.hasOwn(handlers, run.name) ? handlers[run.name] : undefined;
|
|
81
|
+
if (!handler) return 404;
|
|
82
|
+
const seen = options.seen ?? remembered;
|
|
83
|
+
if (await seen.has(run.id)) return 204;
|
|
84
|
+
await handler(run);
|
|
85
|
+
await seen.add(run.id);
|
|
86
|
+
return 204;
|
|
87
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
3
|
+
|
|
4
|
+
// What the Chest signs for a server tool — the events of its members
|
|
5
|
+
// (events), the runs of its schedules (schedules), posted through its
|
|
6
|
+
// launcher only (never from the Internet), each on a route of its own —: one
|
|
7
|
+
// mechanism, shared by the modules that read it and by testing, which signs
|
|
8
|
+
// as the Chest. Not a published module. member.ts reads the Chest-Member
|
|
9
|
+
// assertion with the same steps in its own words: it imports nothing but
|
|
10
|
+
// node:*, so that a tool may vendor it alone (Forms does, and webpack does
|
|
11
|
+
// not resolve a relative ./x.js import to ./x.ts).
|
|
12
|
+
//
|
|
13
|
+
// The signature is a compact JWS, HS256, typ JWT, in the channel's header,
|
|
14
|
+
// under HMAC-SHA256 of the channel's label keyed by the text of CHEST_TOKEN,
|
|
15
|
+
// exactly as the Chest derives it (chest/toolfront) — neither the token
|
|
16
|
+
// itself nor the key of the Chest-Member assertion, and never another
|
|
17
|
+
// channel's: a delivery of one is never read as the other's. Its claims are
|
|
18
|
+
// the tool (aud), iat, exp, the identifier of what is posted (jti) and the
|
|
19
|
+
// SHA-256 of the body (digest, base64url).
|
|
20
|
+
|
|
21
|
+
// A channel: the header of its signature, the label its key derives from,
|
|
22
|
+
// the grammar of its identifiers and the largest body it carries.
|
|
23
|
+
export type Channel = { header: string; label: string; id: RegExp; maxBody: number };
|
|
24
|
+
|
|
25
|
+
// The events of the members (events): POST /chest-events.
|
|
26
|
+
export const eventChannel: Channel = { header: "Chest-Event", label: "Chest-Event v1", id: /^evt_[a-z2-7]{26}$/u, maxBody: 64 << 10 };
|
|
27
|
+
// The runs of the schedules (schedules): POST /chest-schedules.
|
|
28
|
+
export const scheduleChannel: Channel = { header: "Chest-Schedule", label: "Chest-Schedule v1", id: /^run_[a-z2-7]{26}$/u, maxBody: 1024 };
|
|
29
|
+
|
|
30
|
+
const claims = ["aud", "iat", "exp", "jti", "digest"] as const;
|
|
31
|
+
// Clocks of the Chest and of the container may differ by this much, in seconds.
|
|
32
|
+
const skew = 5;
|
|
33
|
+
const maxSignature = 2048;
|
|
34
|
+
const compact = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/u;
|
|
35
|
+
|
|
36
|
+
export function object(value: unknown): Record<string, unknown> | null {
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
38
|
+
}
|
|
39
|
+
export function json(raw: string): unknown {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(raw) as unknown;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// An instant as the Chest writes it (RFC 3339).
|
|
47
|
+
export const instant = (v: unknown): v is string => typeof v === "string" && v.length <= 40 && !Number.isNaN(Date.parse(v));
|
|
48
|
+
|
|
49
|
+
// headerValue is the value of a header, maxLength at most; null for none,
|
|
50
|
+
// longer, or repeated (a Web Request joins repeated values with ", ", which
|
|
51
|
+
// no signature contains; a Node request keeps them as an array).
|
|
52
|
+
export function headerValue(request: IncomingMessage | Request, name: string, maxLength: number): string | null {
|
|
53
|
+
const headers = request.headers as Headers | IncomingMessage["headers"];
|
|
54
|
+
const value = typeof (headers as Headers).get === "function" ? (headers as Headers).get(name) : (headers as IncomingMessage["headers"])[name];
|
|
55
|
+
return typeof value === "string" && value.length <= maxLength ? value : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// signedClaims reads what the Chest signed for this tool under a label: the
|
|
59
|
+
// claims of a compact JWS, HS256, typ JWT, under HMAC-SHA256 of the label
|
|
60
|
+
// keyed by the text of CHEST_TOKEN, when the signature holds, its audience
|
|
61
|
+
// is CHEST_TOOL and now is within iat and exp (5 s of skew each way); null
|
|
62
|
+
// otherwise, or outside a Chest. It never throws for what it is given.
|
|
63
|
+
export function signedClaims(value: string | null, label: string): Record<string, unknown> | null {
|
|
64
|
+
const token = process.env["CHEST_TOKEN"];
|
|
65
|
+
const tool = process.env["CHEST_TOOL"];
|
|
66
|
+
if (!token || !/^[A-Za-z0-9_-]{43,512}$/u.test(token) || !tool) return null;
|
|
67
|
+
const parts = value === null ? null : compact.exec(value);
|
|
68
|
+
if (!parts) return null;
|
|
69
|
+
const [, encodedHeader = "", encodedPayload = "", encodedSignature = ""] = parts;
|
|
70
|
+
const header = object(json(Buffer.from(encodedHeader, "base64url").toString("utf8")));
|
|
71
|
+
if (!header || Object.keys(header).length !== 2 || header["alg"] !== "HS256" || header["typ"] !== "JWT") return null;
|
|
72
|
+
const key = createHmac("sha256", Buffer.from(token, "utf8")).update(label).digest();
|
|
73
|
+
const expected = createHmac("sha256", key).update(encodedHeader + "." + encodedPayload).digest();
|
|
74
|
+
const given = Buffer.from(encodedSignature, "base64url");
|
|
75
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;
|
|
76
|
+
const payload = object(json(Buffer.from(encodedPayload, "base64url").toString("utf8")));
|
|
77
|
+
if (!payload || payload["aud"] !== tool) return null;
|
|
78
|
+
const { iat, exp } = payload;
|
|
79
|
+
if (typeof iat !== "number" || !Number.isSafeInteger(iat) || typeof exp !== "number" || !Number.isSafeInteger(exp) || exp <= iat) return null;
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
return iat > now + skew || exp <= now - skew ? null : payload;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// signClaims is what the Chest would sign under a label for these claims
|
|
85
|
+
// (their aud, iat and exp included), with that token: for the fake Chest of
|
|
86
|
+
// a tool's tests (testing).
|
|
87
|
+
export function signClaims(label: string, claims: Record<string, unknown>, token: string): string {
|
|
88
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
89
|
+
const signed = encode({ alg: "HS256", typ: "JWT" }) + "." + encode(claims);
|
|
90
|
+
const key = createHmac("sha256", Buffer.from(token, "utf8")).update(label).digest();
|
|
91
|
+
return signed + "." + createHmac("sha256", key).update(signed).digest("base64url");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// bodyOf reads the body, limit bytes at most; null beyond, or when it was
|
|
95
|
+
// read already.
|
|
96
|
+
async function bodyOf(request: IncomingMessage | Request, limit: number): Promise<Buffer | null> {
|
|
97
|
+
try {
|
|
98
|
+
if (request instanceof Request) {
|
|
99
|
+
if (request.bodyUsed || Number(request.headers.get("content-length") ?? "0") > limit) return null;
|
|
100
|
+
const raw = Buffer.from(await request.arrayBuffer());
|
|
101
|
+
return raw.length <= limit ? raw : null;
|
|
102
|
+
}
|
|
103
|
+
const chunks: Buffer[] = [];
|
|
104
|
+
let size = 0;
|
|
105
|
+
for await (const chunk of request) {
|
|
106
|
+
size += (chunk as Buffer).length;
|
|
107
|
+
if (size > limit) return null;
|
|
108
|
+
chunks.push(chunk as Buffer);
|
|
109
|
+
}
|
|
110
|
+
return Buffer.concat(chunks);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// delivery reads a request of a channel: the identifier it was signed for
|
|
117
|
+
// and its body, when the signature, the tool, the time and the digest of
|
|
118
|
+
// the body hold; null otherwise — not a POST, no or another signature,
|
|
119
|
+
// another tool, expired, a body other than the one signed. It reads the
|
|
120
|
+
// body. It never throws for what a request carries.
|
|
121
|
+
export async function delivery(request: IncomingMessage | Request, channel: Channel): Promise<{ id: string; body: Buffer } | null> {
|
|
122
|
+
if (request.method !== "POST") return null;
|
|
123
|
+
const payload = signedClaims(headerValue(request, channel.header.toLowerCase(), maxSignature), channel.label);
|
|
124
|
+
if (!payload || Object.keys(payload).length !== claims.length || !claims.every(name => Object.hasOwn(payload, name))) return null;
|
|
125
|
+
const { jti, digest } = payload;
|
|
126
|
+
if (typeof jti !== "string" || !channel.id.test(jti) || typeof digest !== "string") return null;
|
|
127
|
+
const body = await bodyOf(request, channel.maxBody);
|
|
128
|
+
if (body === null) return null;
|
|
129
|
+
const sum = createHash("sha256").update(body).digest();
|
|
130
|
+
const told = Buffer.from(digest, "base64url");
|
|
131
|
+
if (told.length !== sum.length || !timingSafeEqual(told, sum)) return null;
|
|
132
|
+
return { id: jti, body };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// sign is the value of a channel's header the Chest would send with that
|
|
136
|
+
// body: for the fake Chest of a tool's tests (testing).
|
|
137
|
+
export function sign(channel: Channel, id: string, body: string, options: { token: string; tool: string }): string {
|
|
138
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
139
|
+
return signClaims(channel.label, { aud: options.tool, iat, exp: iat + 60, jti: id, digest: createHash("sha256").update(body).digest("base64url") }, options.token);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Where a handler remembers the identifiers of the deliveries already
|
|
143
|
+
// handled: a store the tool chooses. Keep it durable — a table of the
|
|
144
|
+
// tool's database — so a delivery made again after a restart of the tool is
|
|
145
|
+
// recognised:
|
|
146
|
+
//
|
|
147
|
+
// create table chest_seen (id text primary key, at timestamptz not null default now());
|
|
148
|
+
// const seen = {
|
|
149
|
+
// has: async (id: string) => (await sql`select 1 from chest_seen where id = ${id}`).length > 0,
|
|
150
|
+
// add: async (id: string) => { await sql`insert into chest_seen (id) values (${id}) on conflict do nothing`; },
|
|
151
|
+
// };
|
|
152
|
+
export type Seen = { has(id: string): boolean | Promise<boolean>; add(id: string): void | Promise<void> };
|
|
153
|
+
|
|
154
|
+
// memorySeen keeps the last limit ids in this process: lost at a restart,
|
|
155
|
+
// enough for a tool whose handlers are idempotent anyway.
|
|
156
|
+
export function memorySeen(limit = 10000): Seen {
|
|
157
|
+
const ids = new Set<string>();
|
|
158
|
+
return {
|
|
159
|
+
has: id => ids.has(id),
|
|
160
|
+
add: id => {
|
|
161
|
+
ids.delete(id);
|
|
162
|
+
ids.add(id);
|
|
163
|
+
while (ids.size > limit) ids.delete(ids.values().next().value as string);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|