@argentic/chest-sdk 0.4.0 → 0.5.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 (80) hide show
  1. package/README.md +508 -80
  2. package/client/index.ts +11 -7
  3. package/client/src/api.ts +19 -18
  4. package/client/src/database.ts +6 -1
  5. package/client/src/errors.ts +50 -0
  6. package/client/src/eventrules.ts +117 -0
  7. package/client/src/events.ts +181 -39
  8. package/client/src/files.ts +37 -7
  9. package/client/src/member.ts +15 -9
  10. package/client/src/members.ts +34 -16
  11. package/client/src/notifications.ts +84 -27
  12. package/client/src/realtime-client.ts +516 -0
  13. package/client/src/realtime.ts +118 -0
  14. package/client/src/sealed.ts +158 -0
  15. package/client/src/signed.ts +8 -4
  16. package/client/src/testing-realtime.ts +361 -0
  17. package/client/src/testing.ts +391 -94
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +11 -7
  21. package/dist/index.js.map +1 -1
  22. package/dist/src/api.d.ts +2 -1
  23. package/dist/src/api.d.ts.map +1 -1
  24. package/dist/src/api.js +18 -16
  25. package/dist/src/api.js.map +1 -1
  26. package/dist/src/database.d.ts.map +1 -1
  27. package/dist/src/database.js +5 -1
  28. package/dist/src/database.js.map +1 -1
  29. package/dist/src/errors.d.ts +18 -0
  30. package/dist/src/errors.d.ts.map +1 -1
  31. package/dist/src/errors.js +44 -0
  32. package/dist/src/errors.js.map +1 -1
  33. package/dist/src/eventrules.d.ts +23 -0
  34. package/dist/src/eventrules.d.ts.map +1 -0
  35. package/dist/src/eventrules.js +107 -0
  36. package/dist/src/eventrules.js.map +1 -0
  37. package/dist/src/events.d.ts +31 -6
  38. package/dist/src/events.d.ts.map +1 -1
  39. package/dist/src/events.js +130 -26
  40. package/dist/src/events.js.map +1 -1
  41. package/dist/src/files.d.ts +1 -0
  42. package/dist/src/files.d.ts.map +1 -1
  43. package/dist/src/files.js +34 -3
  44. package/dist/src/files.js.map +1 -1
  45. package/dist/src/member.d.ts +1 -1
  46. package/dist/src/member.d.ts.map +1 -1
  47. package/dist/src/member.js +9 -6
  48. package/dist/src/member.js.map +1 -1
  49. package/dist/src/members.d.ts +9 -2
  50. package/dist/src/members.d.ts.map +1 -1
  51. package/dist/src/members.js +29 -13
  52. package/dist/src/members.js.map +1 -1
  53. package/dist/src/notifications.d.ts +12 -1
  54. package/dist/src/notifications.d.ts.map +1 -1
  55. package/dist/src/notifications.js +60 -19
  56. package/dist/src/notifications.js.map +1 -1
  57. package/dist/src/realtime-client.d.ts +45 -0
  58. package/dist/src/realtime-client.d.ts.map +1 -0
  59. package/dist/src/realtime-client.js +453 -0
  60. package/dist/src/realtime-client.js.map +1 -0
  61. package/dist/src/realtime.d.ts +22 -0
  62. package/dist/src/realtime.d.ts.map +1 -0
  63. package/dist/src/realtime.js +99 -0
  64. package/dist/src/realtime.js.map +1 -0
  65. package/dist/src/sealed.d.ts +20 -0
  66. package/dist/src/sealed.d.ts.map +1 -0
  67. package/dist/src/sealed.js +121 -0
  68. package/dist/src/sealed.js.map +1 -0
  69. package/dist/src/signed.d.ts.map +1 -1
  70. package/dist/src/signed.js +6 -2
  71. package/dist/src/signed.js.map +1 -1
  72. package/dist/src/testing-realtime.d.ts +54 -0
  73. package/dist/src/testing-realtime.d.ts.map +1 -0
  74. package/dist/src/testing-realtime.js +376 -0
  75. package/dist/src/testing-realtime.js.map +1 -0
  76. package/dist/src/testing.d.ts +40 -3
  77. package/dist/src/testing.d.ts.map +1 -1
  78. package/dist/src/testing.js +368 -81
  79. package/dist/src/testing.js.map +1 -1
  80. package/package.json +23 -4
package/client/index.ts CHANGED
@@ -1,17 +1,21 @@
1
- // The package root: every published module a tool's code uses (tool contract
2
- // 0.4). Each one is also its own subpath (@argentic/chest-sdk/member, /chest,
3
- // /database, /files, /members, /notifications, /events, /schedules, /ai, /errors), which
4
- // pulls in nothing else. The files, members, notifications, events, schedules and ai
5
- // APIs are namespaces here, as their names (get, list, stat, move, notify,
6
- // verify, chat…) are too plain to stand alone. @argentic/chest-sdk/testing is for a tool's tests only, and
7
- // is not here.
1
+ // The package root: every published module a tool's server code uses (tool
2
+ // contract 0.5). Each one is also its own subpath (@argentic/chest-sdk/member,
3
+ // /chest, /database, /sealed, /files, /members, /notifications, /events,
4
+ // /schedules, /ai, /realtime, /errors), which pulls in nothing else. The
5
+ // sealed, files, members, notifications, events, schedules, ai and realtime
6
+ // APIs are namespaces here, as their names (seal, open, get, list, stat, move,
7
+ // notify, verify, chat, publish…) are too plain to stand alone.
8
+ // @argentic/chest-sdk/realtime/client runs in the browser, and
9
+ // @argentic/chest-sdk/testing in a tool's tests only: neither is here.
8
10
  export * from "./src/errors.js";
9
11
  export * from "./src/member.js";
10
12
  export * from "./src/chest.js";
11
13
  export * from "./src/database.js";
14
+ export * as sealed from "./src/sealed.js";
12
15
  export * as files from "./src/files.js";
13
16
  export * as members from "./src/members.js";
14
17
  export * as notifications from "./src/notifications.js";
15
18
  export * as events from "./src/events.js";
16
19
  export * as schedules from "./src/schedules.js";
17
20
  export * as ai from "./src/ai.js";
21
+ export * as realtime from "./src/realtime.js";
package/client/src/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CapabilityNotGranted, ChestError, QuotaExceeded, RateLimited, TooLarge, Unavailable } from "./errors.js";
1
+ import { CapabilityNotGranted, ChestError, QuotaExceeded, RateLimited, StorageFull, TooLarge, Unavailable } from "./errors.js";
2
2
 
3
3
  // The Chest's API as a server tool reaches it, shared by the modules that
4
4
  // call it (files, members): CHEST_API is http://127.0.0.1:<port>, the tool's
@@ -9,21 +9,15 @@ import { CapabilityNotGranted, ChestError, QuotaExceeded, RateLimited, TooLarge,
9
9
  const maxAnswer = 4 << 20;
10
10
  const deadline = 120000;
11
11
 
12
- // fakeOrigins are the origins of the fake Chests a test started in this
13
- // process (testing.ts, fakeChest), each http://127.0.0.1:<port>: links and
14
- // uploads a fake gives on them are taken as the Chest's https ones are. Only
15
- // the testing module adds to it — the one module no production code
16
- // imports —, and only for as long as its fake runs; an origin is never read
17
- // from the environment, so nothing set around a tool widens what it accepts.
18
- export const fakeOrigins = new Set<string>();
19
-
20
12
  // chestLink reads a link to the team host the Chest answered, at path (its
21
13
  // links, its uploads): the token it carries, or undefined for an address
22
- // that is not one — https, or the origin of a fake Chest of this process.
14
+ // that is not one — https, or the origin of the Chest's API itself
15
+ // (CHEST_API), where only a fake Chest of a tool's tests serves its links:
16
+ // the address the tool already trusts for every call, never another.
23
17
  export function chestLink(url: unknown, path: string): string | undefined {
24
18
  if (typeof url !== "string") return undefined;
25
19
  const found = /^(https:\/\/[A-Za-z0-9.-]{1,253}(?::[0-9]{1,5})?|http:\/\/127\.0\.0\.1:[0-9]{1,5})(\/_chest\/[a-z/]+\/)([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/u.exec(url);
26
- if (!found || found[2] !== path || (found[1]!.startsWith("http:") && !fakeOrigins.has(found[1]!))) return undefined;
20
+ if (!found || found[2] !== path || (found[1]!.startsWith("http:") && found[1] !== process.env["CHEST_API"])) return undefined;
27
21
  return found[3];
28
22
  }
29
23
 
@@ -39,8 +33,8 @@ function base(capability: string): string {
39
33
  // is Unavailable. The request and the reading of its answer end after
40
34
  // deadline milliseconds (120 seconds unless said), or when the caller's
41
35
  // signal aborts: its reason is then thrown.
42
- export async function ask(capability: string, method: string, path: string, init: { body?: Uint8Array<ArrayBuffer> | string; type?: string; deadline?: number; signal?: AbortSignal } = {}): Promise<Response> {
43
- const headers: Record<string, string> = {};
36
+ export async function ask(capability: string, method: string, path: string, init: { body?: Uint8Array<ArrayBuffer> | string; type?: string; headers?: Record<string, string>; deadline?: number; signal?: AbortSignal } = {}): Promise<Response> {
37
+ const headers: Record<string, string> = { ...init.headers };
44
38
  if (init.type !== undefined) headers["Content-Type"] = init.type;
45
39
  const url = base(capability) + path;
46
40
  const timeout = AbortSignal.timeout(init.deadline ?? deadline);
@@ -86,18 +80,25 @@ export async function json(response: Response): Promise<unknown> {
86
80
  }
87
81
  }
88
82
 
89
- // refusal turns an answer that is not a success into what the tool tests.
90
- export async function refusal(response: Response, capability: string): Promise<ChestError> {
91
- let code = "refused";
83
+ // errorCode is the code of a refusal of the Chest ({"error": code}), or
84
+ // "refused" for an answer that says none.
85
+ export async function errorCode(response: Response): Promise<string> {
92
86
  try {
93
87
  const given = ((await json(response)) as { error?: unknown } | null)?.error;
94
- if (typeof given === "string" && /^[a-z_]{1,40}$/u.test(given)) code = given;
88
+ if (typeof given === "string" && /^[a-z_]{1,40}$/u.test(given)) return given;
95
89
  } catch {
96
- // The code stays "refused".
90
+ // No code of the Chest's.
97
91
  }
92
+ return "refused";
93
+ }
94
+
95
+ // refusal turns an answer that is not a success into what the tool tests.
96
+ export async function refusal(response: Response, capability: string): Promise<ChestError> {
97
+ const code = await errorCode(response);
98
98
  if (response.status === 403) return new CapabilityNotGranted(capability);
99
99
  if (response.status === 413) return new TooLarge();
100
100
  if (response.status === 429) return code === "rate_limited" ? new RateLimited() : new QuotaExceeded();
101
+ if (response.status === 507 && code === "storage_full") return new StorageFull();
101
102
  if (response.status >= 500) return new Unavailable();
102
103
  return new ChestError(code, response.status, `the Chest refused: ${code}`);
103
104
  }
@@ -8,9 +8,14 @@ import { CapabilityNotGranted } from "./errors.js";
8
8
  // postgres (porsager) or pg; the SDK carries none. PGHOST, PGPORT, PGUSER,
9
9
  // PGPASSWORD and PGDATABASE say the same for a client that reads them.
10
10
  //
11
+ // Its user is the tool's role (t_<tool>), or, in the preview of a draft
12
+ // Perseus Code builds, the draft's (pb_<project>: its own empty database).
13
+ //
11
14
  // Throws CapabilityNotGranted when the Chest gave no database: the version
12
15
  // does not declare it, or a DATABASE_URL of the tool's own is not the
13
16
  // Chest's. The value is a secret: never log it, never send it to a browser.
17
+ const role = /^(t_[a-z][a-z0-9_]{0,47}|pb_[a-z2-7]{26})$/u;
18
+
14
19
  export function databaseUrl(): string {
15
20
  const value = process.env["DATABASE_URL"];
16
21
  if (typeof value !== "string" || value.length > 1024) throw new CapabilityNotGranted("database");
@@ -21,7 +26,7 @@ export function databaseUrl(): string {
21
26
  throw new CapabilityNotGranted("database");
22
27
  }
23
28
  const port = Number(url.port);
24
- if (url.protocol !== "postgres:" || url.hostname !== "127.0.0.1" || !Number.isInteger(port) || port < 1 || port > 65535 || !/^t_[a-z][a-z0-9_]{0,47}$/u.test(url.username) || url.pathname !== "/" + url.username || url.password === "" || url.search !== "?sslmode=disable" || url.hash !== "") {
29
+ if (url.protocol !== "postgres:" || url.hostname !== "127.0.0.1" || !Number.isInteger(port) || port < 1 || port > 65535 || !role.test(url.username) || url.pathname !== "/" + url.username || url.password === "" || url.search !== "?sslmode=disable" || url.hash !== "") {
25
30
  throw new CapabilityNotGranted("database");
26
31
  }
27
32
  return value;
@@ -46,6 +46,15 @@ export class TooLarge extends ChestError {
46
46
  }
47
47
  }
48
48
 
49
+ // The server of the Chest has no more disk for files, whatever the tool's
50
+ // quota: nothing was kept. Its owner frees space or takes a larger server;
51
+ // the tool says the file could not be kept, and may try again later.
52
+ export class StorageFull extends ChestError {
53
+ constructor() {
54
+ super("storage_full", 507, "the Chest refused: its server's disk is full");
55
+ }
56
+ }
57
+
49
58
  // The Chest did not answer, or not as it does: nothing is known of what was
50
59
  // asked — a write may or may not have happened.
51
60
  export class Unavailable extends ChestError {
@@ -97,3 +106,44 @@ export class AiRefused extends ChestError {
97
106
  super("content_refused", 422, "the AI provider refused the content of the request");
98
107
  }
99
108
  }
109
+
110
+ // Opening a sealed value needs a member on the request: none came with it,
111
+ // or their ticket expired (a request lasts 60 seconds). A public page, a
112
+ // schedule or an event opens nothing.
113
+ export class MemberRequired extends ChestError {
114
+ constructor() {
115
+ super("member_required", 401, "the Chest refused: sealed values open only on the request of a member");
116
+ }
117
+ }
118
+
119
+ // The member may not open this: they lost the tool since the request
120
+ // began, or the value was sealed for roles they do not hold.
121
+ export class NotAllowed extends ChestError {
122
+ constructor() {
123
+ super("not_allowed", 403, "the Chest refused: this member may not open this value");
124
+ }
125
+ }
126
+
127
+ // The sealed value does not open: it was altered, it is another tool's, or
128
+ // it was sealed in another context.
129
+ export class SealedInvalid extends ChestError {
130
+ constructor() {
131
+ super("sealed_invalid", 400, "the sealed value does not open: altered, another tool's or of another context");
132
+ }
133
+ }
134
+
135
+ // The Chest was restored and its sealed data waits for the owner's recovery
136
+ // code: nothing is sealed or opened until they enter it (Settings).
137
+ export class SealedLocked extends ChestError {
138
+ constructor() {
139
+ super("sealed_locked", 503, "the Chest's sealed data is locked until its owner enters the recovery code");
140
+ }
141
+ }
142
+
143
+ // The key of this tool's sealed values is lost for good (the Chest key and
144
+ // its recovery code both): its sealed values never open again.
145
+ export class SealedLost extends ChestError {
146
+ constructor() {
147
+ super("sealed_lost", 503, "the key of this tool's sealed values is lost");
148
+ }
149
+ }
@@ -0,0 +1,117 @@
1
+ import { groupIdPattern, memberIdPattern } from "./member.js";
2
+
3
+ // The rules of events between tools, as the Chest applies them
4
+ // (chest/toolevents): what a type, an identifier, an instant, an audience
5
+ // and a declared field are. events reads a delivery and checks an emit with
6
+ // them before sending it; testing's fake Chest refuses what the Chest
7
+ // refuses with them. Not a published module.
8
+
9
+ // A type: two to four dotted segments of lowercase letters, digits and
10
+ // dashes, each starting with a letter, 64 characters at most; member.* and
11
+ // access.* are the Chest's own (the members' lifecycle).
12
+ const typePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*){1,3}$/u;
13
+ export const isToolEventType = (value: unknown): value is string =>
14
+ typeof value === "string" && value.length <= 64 && typePattern.test(value) && !value.startsWith("member.") && !value.startsWith("access.");
15
+
16
+ // An identifier of the publisher's own: the "id" kind of a field, an
17
+ // event's subject and its idempotency key.
18
+ export const itemIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
19
+ // The name of a tool, as an event's source.
20
+ export const toolNamePattern = /^[a-z][a-z0-9-]{0,47}$/u;
21
+ // A role of a tool's own (chest.json "roles"), as an audience or a
22
+ // broadcast names it.
23
+ export const rolePattern = /^[a-z][a-z0-9-]{0,47}$/u;
24
+ // A field of an event's data: a camelCase name.
25
+ export const fieldNamePattern = /^[a-z][A-Za-z0-9]{0,39}$/u;
26
+
27
+ // The largest data of one event, in bytes of its JSON (UTF-8).
28
+ export const maxData = 16 << 10;
29
+ // How far back an event's occurredAt may go — the Chest's 72 hours of
30
+ // retries — and how far ahead the clocks of the Chest and of the container
31
+ // may differ, in milliseconds.
32
+ export const eventWindow = 72 * 3600_000;
33
+ export const clockSkew = 5000;
34
+
35
+ const daysIn = (year: number, month: number): number => month === 2 ? (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28) : [4, 6, 9, 11].includes(month) ? 30 : 31;
36
+ const instantPattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/u;
37
+ const datePattern = /^(\d{4})-(\d{2})-(\d{2})$/u;
38
+
39
+ // instantOf is the time of an RFC 3339 instant (a date, a time to the
40
+ // second or finer, Z or an offset) in milliseconds, or null for anything
41
+ // else — a day or an hour that does not exist included.
42
+ export function instantOf(value: unknown): number | null {
43
+ const parts = typeof value === "string" ? instantPattern.exec(value) : null;
44
+ if (!parts) return null;
45
+ const [year, month, day, hour, minute, second, offsetHour, offsetMinute] = parts.slice(1).map(v => Number(v ?? "0")) as [number, number, number, number, number, number, number, number];
46
+ if (month < 1 || month > 12 || day < 1 || day > daysIn(year, month) || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) return null;
47
+ return Date.parse(value as string);
48
+ }
49
+
50
+ // isDate says a value is a day that exists, YYYY-MM-DD.
51
+ export function isDate(value: unknown): boolean {
52
+ const parts = typeof value === "string" ? datePattern.exec(value) : null;
53
+ if (!parts) return false;
54
+ const [year, month, day] = parts.slice(1).map(Number) as [number, number, number];
55
+ return month >= 1 && month <= 12 && day >= 1 && day <= daysIn(year, month);
56
+ }
57
+
58
+ // Text: no control character (C0, C1) but line feeds and tabs, no format
59
+ // character (Cf: U+200B, U+202E…) and no unpaired surrogate; no length of
60
+ // its own (the data's 16 KiB bound it).
61
+ const isText = (value: unknown): boolean => typeof value === "string" && !/[^\P{Cc}\n\t]|[\p{Cf}\p{Cs}]/u.test(value);
62
+
63
+ // The kinds of a declared field, and what a value of each is. A member is
64
+ // one the publisher has or had (known).
65
+ export type FieldKind = "id" | "text" | "number" | "boolean" | "time" | "date" | "member" | "members";
66
+ const kinds: Record<FieldKind, (value: unknown, known: (id: string) => boolean) => boolean> = {
67
+ id: value => typeof value === "string" && itemIdPattern.test(value),
68
+ text: isText,
69
+ number: value => typeof value === "number" && Number.isFinite(value),
70
+ boolean: value => typeof value === "boolean",
71
+ time: value => instantOf(value) !== null,
72
+ date: isDate,
73
+ member: (value, known) => typeof value === "string" && memberIdPattern.test(value) && known(value),
74
+ // A list, empty or not, of distinct members.
75
+ members: (value, known) => Array.isArray(value) && value.every(v => typeof v === "string" && memberIdPattern.test(v) && known(v)) && new Set(value).size === value.length,
76
+ };
77
+
78
+ // fieldOf reads a field's declaration in "emits" ("number", "text?"): its
79
+ // kind and whether it may be left out; null for one the Chest refuses.
80
+ export function fieldOf(declared: unknown): { kind: FieldKind; optional: boolean } | null {
81
+ if (typeof declared !== "string") return null;
82
+ const optional = declared.endsWith("?"), kind = optional ? declared.slice(0, -1) : declared;
83
+ return Object.hasOwn(kinds, kind) ? { kind: kind as FieldKind, optional } : null;
84
+ }
85
+
86
+ // isDeclaredData says data is what its type declares: an object with every
87
+ // required field, no field not declared, each value of its kind — null is
88
+ // a field left out. declared
89
+ // maps each field to its kind ("text?"), as "emits" names them; known says
90
+ // a member is one the publisher has or had. Its size (maxData) is checked
91
+ // apart.
92
+ export function isDeclaredData(data: unknown, declared: Record<string, string>, known: (id: string) => boolean): boolean {
93
+ if (data === null || typeof data !== "object" || Array.isArray(data)) return false;
94
+ const given = data as Record<string, unknown>;
95
+ for (const name of Object.keys(given)) if (!Object.hasOwn(declared, name)) return false;
96
+ return Object.entries(declared).every(([name, spec]) => {
97
+ const field = fieldOf(spec);
98
+ if (!field) return false;
99
+ if (!Object.hasOwn(given, name) || given[name] === null) return field.optional;
100
+ return kinds[field.kind](given[name], known);
101
+ });
102
+ }
103
+
104
+ // An event's audience as the publisher names it: members, groups and roles
105
+ // of the publisher, any of whom may see the item.
106
+ export type Audience = { members?: string[]; groups?: string[]; roles?: string[] };
107
+ const audienceLists: Record<keyof Audience, RegExp> = { members: memberIdPattern, groups: groupIdPattern, roles: rolePattern };
108
+
109
+ // isAudience says a value is an audience the Chest takes: an object of
110
+ // members, groups and roles only, each a list of distinct identifiers of
111
+ // its grammar, one list at least not empty.
112
+ export function isAudience(value: unknown): value is Audience {
113
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
114
+ const lists = Object.entries(value as Record<string, unknown>);
115
+ return lists.some(([, list]) => Array.isArray(list) && list.length > 0) && lists.every(([name, list]) =>
116
+ Object.hasOwn(audienceLists, name) && Array.isArray(list) && list.every(v => typeof v === "string" && audienceLists[name as keyof Audience].test(v)) && new Set(list).size === list.length);
117
+ }
@@ -1,14 +1,23 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import type { IncomingMessage } from "node:http";
2
- import { ask, refusal } from "./api.js";
3
- import { ChestError, Unavailable } from "./errors.js";
3
+ import { ask, json as readJson, refusal } from "./api.js";
4
+ import { ChestError, TooLarge, Unavailable } from "./errors.js";
5
+ import { clockSkew, eventWindow, instantOf, isAudience, isToolEventType, itemIdPattern, maxData, toolNamePattern, type Audience } from "./eventrules.js";
4
6
  import { memberIdPattern } from "./member.js";
5
7
  import { forget } from "./members.js";
6
8
  import { delivery, eventChannel, instant, json, memorySeen, object, type Seen } from "./signed.js";
7
9
 
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:
10
+ // What the Chest tells a server tool, and what the tool tells other tools
11
+ // of its Chest, on one route. Two kinds of events come to the tool's POST
12
+ // /chest-events, through its launcher only (never from the Internet),
13
+ // signed for this tool:
14
+ //
15
+ // - its members' lifecycle (member.updated, access.revoked, member.removed,
16
+ // member.erased), for a tool whose chest.json declares "capabilities":
17
+ // ["members"] and "receives": ["member.*"];
18
+ // - events between tools: those another tool of the Chest emits
19
+ // ("quote.accepted"), for a tool whose chest.json "receives" names their
20
+ // type, once the owner approved the link between the two tools.
12
21
  //
13
22
  // // app/chest-events/route.ts (Next.js): outside /chest, never behind a session
14
23
  // import * as events from "@argentic/chest-sdk/events";
@@ -16,15 +25,23 @@ import { delivery, eventChannel, instant, json, memorySeen, object, type Seen }
16
25
  // return new Response(null, { status: await events.handle(request, {
17
26
  // "member.erased": async e => { await anonymise(e.data.id); await events.acknowledgeErasure(e.data.erasure); },
18
27
  // "access.revoked": e => unassign(e.data.id),
28
+ // "quote.accepted": e => createProject(e.data, e.audience),
19
29
  // }, { seen }) });
20
30
  // }
21
31
  //
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.
32
+ // A tool tells the others with emit, for the types its chest.json declares
33
+ // in "emits", each with the fields its data carries:
34
+ //
35
+ // await events.emit("quote.accepted", { quote: q.id, total: q.total }, { subject: q.id, key: `accepted:${q.id}` });
36
+ //
37
+ // Delivery is at least once: the same event may come again, always with the
38
+ // same id — handle() drops what the store of seen ids already holds. Tool
39
+ // events of one subject come in the order emitted; nothing else is ordered.
40
+ // An event the tool does not answer with a success is delivered again,
41
+ // after a growing delay, for 72 hours; after that a member event leaves the
42
+ // tool out of sync (it reconciles by listing its members, members.list, at
43
+ // its next start) and a tool event is kept as a failed delivery an admin
44
+ // may send again. Every member event also empties what members.lookup keeps.
28
45
 
29
46
  // What changed of a member the tool sees; "email" only with members.email;
30
47
  // "language" and "timeZone": the language the Chest speaks to them and the
@@ -39,12 +56,32 @@ export type MemberRemoved = { id: string; type: "member.removed"; occurredAt: st
39
56
  // The owner asked for this person's data to be erased: delete or anonymise
40
57
  // what the tool keeps of them before deadline, then acknowledgeErasure(erasure).
41
58
  export type MemberErased = { id: string; type: "member.erased"; occurredAt: string; data: { id: string; erasure: string; deadline: string } };
42
- // An event, told apart by its type.
59
+ // An event of the members' lifecycle, told apart by its type.
43
60
  export type ChestEvent = MemberUpdated | AccessRevoked | MemberRemoved | MemberErased;
44
61
  export type ChestEventType = ChestEvent["type"];
45
62
 
46
- // What handle() calls for each type; a type left out is accepted and ignored.
47
- export type Handlers = { [K in ChestEventType]?: (event: Extract<ChestEvent, { type: K }>) => void | Promise<void> };
63
+ // An event another tool of the Chest told (emit): its id ("evt_…", the same
64
+ // on every delivery of it), its type, the tool that told it (source, stamped
65
+ // by the Chest), when it happened, the thing it is about (subject, when the
66
+ // publisher named one), who may see it, and its data — the fields its type
67
+ // declares in the publisher's chest.json, member ids for people. audience is
68
+ // "all" when every member who has this tool may see the item, otherwise
69
+ // the members of this tool who may: show it to them only (the Chest never
70
+ // delivers an item none of them may see; honouring the list is the tool's
71
+ // rule). Read data defensively: a publisher's later version may add fields.
72
+ export type ToolEvent = { id: string; type: string; source: string; occurredAt: string; subject?: string; audience: "all" | string[]; data: Record<string, unknown> };
73
+ // What a delivery carries: an event of the members' lifecycle, or of a tool.
74
+ export type ReceivedEvent = ChestEvent | ToolEvent;
75
+
76
+ // The type of an event between tools as a handler is keyed: dotted words.
77
+ export type ToolEventType = `${string}.${string}`;
78
+ // What handle() calls for an event of type K: the event of the members'
79
+ // lifecycle of that type, or a tool event.
80
+ export type Handler<K extends string> = (event: K extends ChestEventType ? Extract<ChestEvent, { type: K }> : ToolEvent) => void | Promise<void>;
81
+ // What handle() calls for each type — a member type ("access.revoked"),
82
+ // given its own event, or a type of another tool ("quote.accepted"), given a
83
+ // ToolEvent; a type left out is accepted and ignored.
84
+ export type Handlers<K extends ToolEventType = ChestEventType | ToolEventType> = { [P in K]?: Handler<P> };
48
85
 
49
86
  // Where handle() remembers the ids of the events already handled (Seen), and
50
87
  // memorySeen, which keeps them in this process: shared with schedules.
@@ -54,19 +91,15 @@ export { memorySeen, type Seen } from "./signed.js";
54
91
  export const erasureIdPattern = /^era_[a-z2-7]{26}$/u;
55
92
  const changes: readonly string[] = ["name", "photo", "role", "groups", "email", "language", "timeZone"];
56
93
 
57
- // envelope reads a signed delivery: the envelope when the signature, the tool,
58
- // the time and the digest of the body hold; known says its type is one this
59
- // SDK reads.
60
- async function envelope(request: IncomingMessage | Request): Promise<{ event: ChestEvent; known: true } | { event: { id: string }; known: false } | null> {
61
- const signed = await delivery(request, eventChannel);
62
- if (!signed) return null;
63
- const { id: jti, body } = signed;
64
- const e = object(json(body.toString("utf8")));
65
- if (!e || Object.keys(e).length !== 4 || e["id"] !== jti || typeof e["type"] !== "string" || !instant(e["occurredAt"])) return null;
94
+ // memberEvent reads the envelope of a member event (its four keys): the
95
+ // event when it is one this SDK reads, known false for a type of a later
96
+ // Chest, null for one its type cannot say.
97
+ function memberEvent(e: Record<string, unknown>, id: string): { event: ChestEvent; known: true } | { known: false } | null {
98
+ if (Object.keys(e).length !== 4 || typeof e["type"] !== "string" || !instant(e["occurredAt"])) return null;
66
99
  const data = object(e["data"]);
67
100
  if (!data || typeof data["id"] !== "string" || !memberIdPattern.test(data["id"])) return null;
68
101
  const keys = Object.keys(data).sort().join(",");
69
- const base = { id: jti, occurredAt: e["occurredAt"] as string };
102
+ const base = { id, occurredAt: e["occurredAt"] as string };
70
103
  switch (e["type"]) {
71
104
  case "member.updated": {
72
105
  const changed = data["changed"];
@@ -81,43 +114,152 @@ async function envelope(request: IncomingMessage | Request): Promise<{ event: Ch
81
114
  return { known: true, event: { ...base, type: "member.erased", data: { id: data["id"], erasure: data["erasure"], deadline: data["deadline"] as string } } };
82
115
  }
83
116
  // A type of a later Chest: signed, so the tool accepts it, and ignores it.
84
- return { known: false, event: { id: jti } };
117
+ return { known: false };
118
+ }
119
+
120
+ const toolKeys = new Set(["id", "type", "source", "occurredAt", "subject", "audience", "data"]);
121
+ // toolEvent reads the envelope of a tool event: its keys exactly, subject
122
+ // only when the publisher named one; null for anything else.
123
+ function toolEvent(e: Record<string, unknown>, id: string): ToolEvent | null {
124
+ const { type, source, occurredAt, subject, audience } = e, data = object(e["data"]);
125
+ if (Object.keys(e).length !== toolKeys.size - (subject === undefined ? 1 : 0) || !Object.keys(e).every(k => toolKeys.has(k))) return null;
126
+ if (!isToolEventType(type) || typeof source !== "string" || !toolNamePattern.test(source) || instantOf(occurredAt) === null || !data) return null;
127
+ if (subject !== undefined && (typeof subject !== "string" || !itemIdPattern.test(subject))) return null;
128
+ const all = audience === "all";
129
+ if (!all && (!Array.isArray(audience) || audience.length === 0 || !audience.every(m => typeof m === "string" && memberIdPattern.test(m)) || new Set(audience).size !== audience.length)) return null;
130
+ return { id, type, source, occurredAt: occurredAt as string, ...(subject === undefined ? {} : { subject }), audience: all ? "all" : [...audience as string[]], data };
131
+ }
132
+
133
+ // envelope reads a signed delivery: the event when the signature, the tool,
134
+ // the time and the digest of the body hold and the envelope is one of a
135
+ // member event or of a tool event (it carries a source); known false for a
136
+ // member event of a type this SDK does not read.
137
+ async function envelope(request: IncomingMessage | Request): Promise<{ event: ReceivedEvent; known: true } | { known: false } | null> {
138
+ const signed = await delivery(request, eventChannel);
139
+ if (!signed) return null;
140
+ const e = object(json(signed.body.toString("utf8")));
141
+ if (!e || e["id"] !== signed.id) return null;
142
+ if (!Object.hasOwn(e, "source")) return memberEvent(e, signed.id);
143
+ const event = toolEvent(e, signed.id);
144
+ return event ? { known: true, event } : null;
85
145
  }
86
146
 
87
147
  // verify returns the event a delivery carries, or null when it is not one
88
148
  // the Chest made for this tool — no or another signature, for another tool,
89
- // expired, a body that is not the one signed, not a POST — or when it is of a
90
- // type this SDK does not know. It reads the body (64 KiB at most): call it
91
- // before anything else reads it. It never throws for what a request carries.
92
- export async function verify(request: IncomingMessage | Request): Promise<ChestEvent | null> {
149
+ // expired, a body that is not the one signed, not a POST — or when it is a
150
+ // member event of a type this SDK does not know. It reads the body (32 MiB
151
+ // at most, once its signature holds: a tool event names who may see it,
152
+ // up to a whole team): call it before anything else reads it. It never
153
+ // throws for what a request carries.
154
+ export async function verify(request: IncomingMessage | Request): Promise<ReceivedEvent | null> {
93
155
  const read = await envelope(request);
94
156
  return read?.known ? read.event : null;
95
157
  }
96
158
 
97
159
  const remembered = memorySeen();
160
+ // The event whose handler runs: an emit made while it runs continues its
161
+ // chain (cause), so that the Chest cuts a loop between tools.
162
+ const handling = new AsyncLocalStorage<string>();
98
163
 
99
164
  // handle verifies one delivery and hands the event to its handler, once:
100
165
  // the status to answer the Chest. 401 for a delivery that is not the
101
166
  // Chest's; 204 for an event handled, one already seen (seen.has), a type
102
- // without a handler or one this SDK does not know. A handler that throws
103
- // leaves the event unseen and handle throws: answer 500, the Chest delivers
104
- // it again. seen is the store of the ids handled (memorySeen by default,
105
- // lost at a restart: give a durable one).
106
- export async function handle(request: IncomingMessage | Request, handlers: Handlers, options: { seen?: Seen } = {}): Promise<number> {
167
+ // without a handler or a member type this SDK does not know. A handler that
168
+ // throws leaves the event unseen and handle throws: answer 500, the Chest
169
+ // delivers it again. seen is the store of the ids handled (memorySeen by
170
+ // default, lost at a restart: give a durable one). An emit made by the
171
+ // handler of a tool event carries that event as its cause, by itself.
172
+ export async function handle<K extends ToolEventType>(request: IncomingMessage | Request, handlers: Handlers<K>, options: { seen?: Seen } = {}): Promise<number> {
107
173
  const read = await envelope(request);
108
174
  if (!read) return 401;
109
175
  if (!read.known) return 204;
110
176
  const seen = options.seen ?? remembered;
111
177
  const event = read.event;
112
178
  if (await seen.has(event.id)) return 204;
113
- // A member changed or left: what lookup kept of them is stale.
114
- forget();
115
- const handler = handlers[event.type] as ((e: ChestEvent) => void | Promise<void>) | undefined;
116
- if (handler) await handler(event);
179
+ const handler = Object.hasOwn(handlers, event.type) ? (handlers as Record<string, ((e: ReceivedEvent) => void | Promise<void>) | undefined>)[event.type] : undefined;
180
+ if ("source" in event) {
181
+ if (handler) await handling.run(event.id, () => handler(event));
182
+ } else {
183
+ // A member changed or left: what lookup kept of them is stale.
184
+ forget();
185
+ if (handler) await handler(event);
186
+ }
117
187
  await seen.add(event.id);
118
188
  return 204;
119
189
  }
120
190
 
191
+ // Who, in the tool, may see the item an event is about: some of its
192
+ // members, the members of some groups, the members holding some of its
193
+ // roles (chest.json "roles"); left out, everyone who has the tool.
194
+ export type EmitAudience = Audience;
195
+ // What an emit may say besides its type and data: the thing it is about
196
+ // (subject, an identifier of the tool's own: a receiver gets one subject's
197
+ // events in the order emitted), an idempotency key (the same key within 72
198
+ // hours answers the same event, told once), when it happened (now by
199
+ // default; within the last 72 hours), and its audience.
200
+ export type EmitOptions = { subject?: string; key?: string; occurredAt?: Date | string; audience?: EmitAudience };
201
+ // What the Chest answers an emit: the event's id, and how many tools it was
202
+ // written for (0 is no error: no installed tool listens, or none of their
203
+ // members may see it).
204
+ export type Emitted = { id: string; receivers: number };
205
+
206
+ // audienceOf is the audience as the Chest takes it: each list once per
207
+ // identifier, empty lists left out; invalid_audience when it names nobody
208
+ // or an identifier of no grammar.
209
+ function audienceOf(given: EmitAudience): Audience {
210
+ const audience = object(given) && Object.fromEntries(Object.entries(given).filter(([, list]) => !Array.isArray(list) || list.length > 0).map(([name, list]) => [name, Array.isArray(list) ? [...new Set(list)] : list]));
211
+ if (!isAudience(audience)) throw new ChestError("invalid_audience", 400, "an audience names members (mbr_…), groups (grp_…) or roles of the tool, one at least");
212
+ return audience;
213
+ }
214
+
215
+ // emit tells the tools of the Chest that receive its type that something
216
+ // happened in this tool: type is one its chest.json declares in "emits",
217
+ // data its declared fields (16 KiB of JSON at most). The Chest writes it
218
+ // for each linked receiver before answering, and delivers it at least once.
219
+ // Emitted from a handler of a tool event, it continues that event's chain
220
+ // by itself (the Chest tells nobody an event that would loop). Errors:
221
+ // ChestError invalid_type (400: not declared in "emits", or not a type),
222
+ // invalid_data (400: a field not declared, missing or of another kind, a
223
+ // member the tool never had), invalid_audience (400), invalid_event (400:
224
+ // subject, key or occurredAt), key_reused (409: the key went with other
225
+ // content); TooLarge (413), CapabilityNotGranted (403: the version emits
226
+ // nothing), Unavailable (the event may or may not be told: emit again with
227
+ // the same key).
228
+ export async function emit(type: string, data: Record<string, unknown>, options: EmitOptions = {}): Promise<Emitted> {
229
+ if (!isToolEventType(type)) throw new ChestError("invalid_type", 400, "a type is two to four dotted lowercase words (quote.accepted), not member.* nor access.*");
230
+ if (!object(data)) throw new ChestError("invalid_data", 400, "data is an object of the fields the type declares");
231
+ let size: number;
232
+ try {
233
+ size = Buffer.byteLength(JSON.stringify(data));
234
+ } catch {
235
+ throw new ChestError("invalid_data", 400, "data is not JSON");
236
+ }
237
+ if (size > maxData) throw new TooLarge();
238
+ const { subject, key, occurredAt, audience } = options;
239
+ for (const id of [subject, key]) {
240
+ if (id !== undefined && (typeof id !== "string" || !itemIdPattern.test(id))) throw new ChestError("invalid_event", 400, "a subject or a key is 1 to 128 letters, digits and . _ : -, starting with a letter or a digit");
241
+ }
242
+ const at = occurredAt instanceof Date ? (Number.isNaN(occurredAt.getTime()) ? undefined : occurredAt.toISOString()) : occurredAt;
243
+ if (occurredAt !== undefined) {
244
+ const time = instantOf(at), now = Date.now();
245
+ if (time === null || time > now + clockSkew || time < now - eventWindow) throw new ChestError("invalid_event", 400, "occurredAt is an RFC 3339 instant of the last 72 hours");
246
+ }
247
+ const cause = handling.getStore();
248
+ const body = JSON.stringify({ type, data, ...(subject === undefined ? {} : { subject }), ...(key === undefined ? {} : { key }), ...(at === undefined ? {} : { occurredAt: at }), ...(audience === undefined ? {} : { audience: audienceOf(audience) }), ...(cause === undefined ? {} : { cause }) });
249
+ const response = await ask("events", "POST", "/events", { body, type: "application/json" });
250
+ if (response.status === 200 || response.status === 202) {
251
+ const answer = object(await readJson(response));
252
+ const id = answer?.["id"], receivers = answer?.["receivers"];
253
+ if (typeof id !== "string" || !eventChannel.id.test(id) || typeof receivers !== "number" || !Number.isSafeInteger(receivers) || receivers < 0) throw new Unavailable();
254
+ return { id, receivers };
255
+ }
256
+ if (response.status < 400) {
257
+ await response.body?.cancel();
258
+ throw new Unavailable();
259
+ }
260
+ throw await refusal(response, "events");
261
+ }
262
+
121
263
  // acknowledgeErasure tells the Chest the tool deleted or anonymised what it
122
264
  // kept of the person of that erasure (member.erased): the owner sees it done.
123
265
  // Acknowledging again is harmless. Errors: ChestError erasure_not_found