@argentic/chest-sdk 0.2.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.
Files changed (62) hide show
  1. package/README.md +406 -51
  2. package/client/index.ts +8 -5
  3. package/client/src/ai.ts +374 -0
  4. package/client/src/api.ts +25 -3
  5. package/client/src/chest.ts +104 -0
  6. package/client/src/errors.ts +44 -0
  7. package/client/src/events.ts +12 -104
  8. package/client/src/files.ts +9 -9
  9. package/client/src/member.ts +26 -5
  10. package/client/src/members.ts +21 -16
  11. package/client/src/schedules.ts +87 -0
  12. package/client/src/signed.ts +166 -0
  13. package/client/src/testing.ts +290 -65
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/src/ai.d.ts +131 -0
  19. package/dist/src/ai.d.ts.map +1 -0
  20. package/dist/src/ai.js +290 -0
  21. package/dist/src/ai.js.map +1 -0
  22. package/dist/src/api.d.ts +4 -0
  23. package/dist/src/api.d.ts.map +1 -1
  24. package/dist/src/api.js +25 -2
  25. package/dist/src/api.js.map +1 -1
  26. package/dist/src/chest.d.ts +15 -0
  27. package/dist/src/chest.d.ts.map +1 -0
  28. package/dist/src/chest.js +61 -0
  29. package/dist/src/chest.js.map +1 -0
  30. package/dist/src/errors.d.ts +16 -0
  31. package/dist/src/errors.d.ts.map +1 -1
  32. package/dist/src/errors.js +36 -0
  33. package/dist/src/errors.js.map +1 -1
  34. package/dist/src/events.d.ts +3 -6
  35. package/dist/src/events.d.ts.map +1 -1
  36. package/dist/src/events.js +8 -102
  37. package/dist/src/events.js.map +1 -1
  38. package/dist/src/files.d.ts +1 -0
  39. package/dist/src/files.d.ts.map +1 -1
  40. package/dist/src/files.js +6 -7
  41. package/dist/src/files.js.map +1 -1
  42. package/dist/src/member.d.ts +4 -0
  43. package/dist/src/member.d.ts.map +1 -1
  44. package/dist/src/member.js +17 -5
  45. package/dist/src/member.js.map +1 -1
  46. package/dist/src/members.d.ts +1 -1
  47. package/dist/src/members.d.ts.map +1 -1
  48. package/dist/src/members.js +9 -8
  49. package/dist/src/members.js.map +1 -1
  50. package/dist/src/schedules.d.ts +15 -0
  51. package/dist/src/schedules.d.ts.map +1 -0
  52. package/dist/src/schedules.js +45 -0
  53. package/dist/src/schedules.js.map +1 -0
  54. package/dist/src/signed.d.ts +29 -0
  55. package/dist/src/signed.d.ts.map +1 -0
  56. package/dist/src/signed.js +139 -0
  57. package/dist/src/signed.js.map +1 -0
  58. package/dist/src/testing.d.ts +53 -12
  59. package/dist/src/testing.d.ts.map +1 -1
  60. package/dist/src/testing.js +265 -60
  61. package/dist/src/testing.js.map +1 -1
  62. package/package.json +27 -4
@@ -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
- export type MemberChange = "name" | "photo" | "role" | "groups" | "email";
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: 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
- }
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 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;
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"]);
@@ -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
- // 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 };
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 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;
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 && typeof body.url === "string" ? linkPattern.exec(body.url)?.[2] : undefined;
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 && typeof body.url === "string" ? uploadPattern.exec(body.url)?.[2] : undefined;
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
  }
@@ -14,6 +14,14 @@ import type { IncomingMessage } from "node:http";
14
14
  // declares: null when there is none.
15
15
  // - isBuilder says they build this tool; groups are the groups that give them
16
16
  // this tool ("grp_…").
17
+ // - language is the language the Chest speaks to this member (their own,
18
+ // else the Chest's default): a BCP 47 primary tag the product speaks
19
+ // ("en", "fr"…). The tool's private part (/chest) speaks it to them; a
20
+ // notification or an email to them is written in it.
21
+ // - timeZone is the IANA zone the member works in ("America/New_York"):
22
+ // the one they chose in their profile, else their browser's, else the
23
+ // Chest's. Show them times in it; remind them at their hour in it. The
24
+ // company's day and business rules are the Chest's (chest.timeZone).
17
25
  // - email is there only when the tool holds "members.email".
18
26
  export type Member = {
19
27
  id: string;
@@ -25,22 +33,34 @@ export type Member = {
25
33
  isAdmin: boolean;
26
34
  isBuilder: boolean;
27
35
  groups: string[];
36
+ language: string;
37
+ timeZone: string;
28
38
  email?: string;
29
39
  };
40
+ // What is the same for every member — the organization, the company's time
41
+ // zone — is the Chest's: the chest module.
30
42
 
31
43
  // The grammars of the identifiers the Chest mints: a tool may check with them
32
44
  // the identifiers it stores.
33
45
  export const memberIdPattern = /^mbr_[a-z2-7]{26}$/u;
34
46
  export const groupIdPattern = /^grp_[a-z2-7]{26}$/u;
47
+ // The grammar of a language the Chest gives: a primary tag, whichever the
48
+ // product speaks (a language added to the Chest needs no change here); of a
49
+ // zone: UTC, or an area and a location ("Europe/Paris",
50
+ // "America/Argentina/Buenos_Aires").
51
+ export const languagePattern = /^[a-z]{2,3}$/u;
52
+ export const timeZonePattern = /^(?:UTC|[A-Z][A-Za-z_]{1,31}(?:\/[A-Za-z0-9_+-]{1,31}){1,2})$/u;
35
53
 
36
54
  // The key of the assertions is HMAC-SHA256 of this label under the text of
37
55
  // 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.
56
+ // changes when a claim changes meaning or goes, so that an assertion of
57
+ // another shape is refused rather than misread; a claim added keeps it, as a
58
+ // reader of the former claims still reads them. This module stands alone
59
+ // (node:* only), so that it can be copied by itself.
40
60
  const label = "Chest-Member v2";
41
61
  // The claims every assertion carries; email only for a tool that holds
42
62
  // members.email.
43
- const claims = ["iss", "aud", "iat", "exp", "sub", "given_name", "family_name", "name", "picture", "role", "admin", "builder", "groups"] as const;
63
+ const claims = ["iss", "aud", "iat", "exp", "sub", "given_name", "family_name", "name", "picture", "role", "admin", "builder", "groups", "language", "time_zone"] as const;
44
64
  // Clocks of the Chest and of the container may differ by this much, in seconds.
45
65
  const skew = 5;
46
66
  // An assertion is a few hundred bytes; anything longer is not one.
@@ -87,12 +107,13 @@ export function member(request: IncomingMessage | Request): Member | null {
87
107
  if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) return null;
88
108
  const payload = json(encodedPayload);
89
109
  if (!payload || !claims.every(name => Object.hasOwn(payload, name))) return null;
90
- const { iss, aud, iat, exp, sub, given_name, family_name, name, email, picture, role, admin, builder, groups } = payload;
110
+ const { iss, aud, iat, exp, sub, given_name, family_name, name, email, picture, role, admin, builder, groups, language, time_zone } = payload;
91
111
  if (typeof iss !== "string" || iss === "" || aud !== tool || typeof sub !== "string" || !memberIdPattern.test(sub)) return null;
92
112
  if (typeof iat !== "number" || !Number.isSafeInteger(iat) || typeof exp !== "number" || !Number.isSafeInteger(exp) || exp <= iat) return null;
93
113
  const now = Math.floor(Date.now() / 1000);
94
114
  if (iat > now + skew || exp <= now - skew) return null;
95
115
  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
116
  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 }) };
117
+ if (typeof language !== "string" || !languagePattern.test(language) || typeof time_zone !== "string" || !timeZonePattern.test(time_zone)) return null;
118
+ 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[], language, timeZone: time_zone, ...(email === undefined ? {} : { email }) };
98
119
  }
@@ -1,12 +1,13 @@
1
1
  import { ask, json, refusal } from "./api.js";
2
2
  import { ChestError, Unavailable } from "./errors.js";
3
- import { groupIdPattern, memberIdPattern, type Member } from "./member.js";
3
+ import { groupIdPattern, languagePattern, memberIdPattern, timeZonePattern, type Member } from "./member.js";
4
4
 
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. A member without
9
- // access is indistinguishable from an identifier that does not exist.
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, memberIdPattern, type Member } from "./member.js";
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
- // A member who left the Chest after having the tool: "former" with the name
25
- // they had, or "erased" without any once the owner had their data erased —
26
- // render “Former member”.
27
- export type FormerMember = { id: string; name: string | null; status: "former" | "erased" };
28
- // What a lookup found: members who have the tool, former members, and
29
- // identifiers the tool does not know.
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[] };
@@ -47,8 +51,8 @@ const text = (value: unknown, max: number): value is string => typeof value ===
47
51
  // Chest's answer.
48
52
  function shown(value: unknown): Member {
49
53
  const m = value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
50
- if (!m || typeof m["id"] !== "string" || !memberIdPattern.test(m["id"]) || !text(m["first_name"], 256) || !text(m["last_name"], 256) || !text(m["name"], 520) || !(m["photo"] === null || text(m["photo"], 200)) || !(m["role"] === null || text(m["role"], 48)) || typeof m["admin"] !== "boolean" || typeof m["builder"] !== "boolean" || !Array.isArray(m["groups"]) || m["groups"].length > 16 || !m["groups"].every(g => typeof g === "string" && groupIdPattern.test(g)) || !(m["email"] === undefined || text(m["email"], 254))) throw new Unavailable();
51
- return { id: m["id"], firstName: m["first_name"], lastName: m["last_name"], name: m["name"], photo: m["photo"], role: m["role"], isAdmin: m["admin"], isBuilder: m["builder"], groups: [...m["groups"]] as string[], ...(m["email"] === undefined ? {} : { email: m["email"] }) };
54
+ if (!m || typeof m["id"] !== "string" || !memberIdPattern.test(m["id"]) || !text(m["first_name"], 256) || !text(m["last_name"], 256) || !text(m["name"], 520) || !(m["photo"] === null || text(m["photo"], 200)) || !(m["role"] === null || text(m["role"], 48)) || typeof m["admin"] !== "boolean" || typeof m["builder"] !== "boolean" || !Array.isArray(m["groups"]) || m["groups"].length > 16 || !m["groups"].every(g => typeof g === "string" && groupIdPattern.test(g)) || typeof m["language"] !== "string" || !languagePattern.test(m["language"]) || typeof m["time_zone"] !== "string" || !timeZonePattern.test(m["time_zone"]) || !(m["email"] === undefined || text(m["email"], 254))) throw new Unavailable();
55
+ return { id: m["id"], firstName: m["first_name"], lastName: m["last_name"], name: m["name"], photo: m["photo"], role: m["role"], isAdmin: m["admin"], isBuilder: m["builder"], groups: [...m["groups"]] as string[], language: m["language"], timeZone: m["time_zone"], ...(m["email"] === undefined ? {} : { email: m["email"] }) };
52
56
  }
53
57
 
54
58
  // list says the members who have the tool, by name then identifier, limit
@@ -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 the tool does not know
80
- // them: never a member, a former one, or one without access.
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, the former members who had it, and the identifiers it
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
+ }