@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.
- package/README.md +508 -80
- package/client/index.ts +11 -7
- package/client/src/api.ts +19 -18
- package/client/src/database.ts +6 -1
- package/client/src/errors.ts +50 -0
- package/client/src/eventrules.ts +117 -0
- package/client/src/events.ts +181 -39
- package/client/src/files.ts +37 -7
- package/client/src/member.ts +15 -9
- package/client/src/members.ts +34 -16
- package/client/src/notifications.ts +84 -27
- package/client/src/realtime-client.ts +516 -0
- package/client/src/realtime.ts +118 -0
- package/client/src/sealed.ts +158 -0
- package/client/src/signed.ts +8 -4
- package/client/src/testing-realtime.ts +361 -0
- package/client/src/testing.ts +391 -94
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/src/api.d.ts +2 -1
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +18 -16
- package/dist/src/api.js.map +1 -1
- package/dist/src/database.d.ts.map +1 -1
- package/dist/src/database.js +5 -1
- package/dist/src/database.js.map +1 -1
- package/dist/src/errors.d.ts +18 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +44 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/eventrules.d.ts +23 -0
- package/dist/src/eventrules.d.ts.map +1 -0
- package/dist/src/eventrules.js +107 -0
- package/dist/src/eventrules.js.map +1 -0
- package/dist/src/events.d.ts +31 -6
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +130 -26
- 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 +34 -3
- package/dist/src/files.js.map +1 -1
- package/dist/src/member.d.ts +1 -1
- package/dist/src/member.d.ts.map +1 -1
- package/dist/src/member.js +9 -6
- package/dist/src/member.js.map +1 -1
- package/dist/src/members.d.ts +9 -2
- package/dist/src/members.d.ts.map +1 -1
- package/dist/src/members.js +29 -13
- package/dist/src/members.js.map +1 -1
- package/dist/src/notifications.d.ts +12 -1
- package/dist/src/notifications.d.ts.map +1 -1
- package/dist/src/notifications.js +60 -19
- package/dist/src/notifications.js.map +1 -1
- package/dist/src/realtime-client.d.ts +45 -0
- package/dist/src/realtime-client.d.ts.map +1 -0
- package/dist/src/realtime-client.js +453 -0
- package/dist/src/realtime-client.js.map +1 -0
- package/dist/src/realtime.d.ts +22 -0
- package/dist/src/realtime.d.ts.map +1 -0
- package/dist/src/realtime.js +99 -0
- package/dist/src/realtime.js.map +1 -0
- package/dist/src/sealed.d.ts +20 -0
- package/dist/src/sealed.d.ts.map +1 -0
- package/dist/src/sealed.js +121 -0
- package/dist/src/sealed.js.map +1 -0
- package/dist/src/signed.d.ts.map +1 -1
- package/dist/src/signed.js +6 -2
- package/dist/src/signed.js.map +1 -1
- package/dist/src/testing-realtime.d.ts +54 -0
- package/dist/src/testing-realtime.d.ts.map +1 -0
- package/dist/src/testing-realtime.js +376 -0
- package/dist/src/testing-realtime.js.map +1 -0
- package/dist/src/testing.d.ts +40 -3
- package/dist/src/testing.d.ts.map +1 -1
- package/dist/src/testing.js +368 -81
- package/dist/src/testing.js.map +1 -1
- 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
|
|
2
|
-
// 0.
|
|
3
|
-
// /
|
|
4
|
-
// pulls in nothing else. The
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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:") &&
|
|
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
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
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))
|
|
88
|
+
if (typeof given === "string" && /^[a-z_]{1,40}$/u.test(given)) return given;
|
|
95
89
|
} catch {
|
|
96
|
-
//
|
|
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
|
}
|
package/client/src/database.ts
CHANGED
|
@@ -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 ||
|
|
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;
|
package/client/src/errors.ts
CHANGED
|
@@ -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
|
+
}
|
package/client/src/events.ts
CHANGED
|
@@ -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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
-
//
|
|
47
|
-
|
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
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
|
|
90
|
-
// type this SDK does not know. It reads the body (
|
|
91
|
-
//
|
|
92
|
-
|
|
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
|
|
103
|
-
// leaves the event unseen and handle throws: answer 500, the Chest
|
|
104
|
-
// it again. seen is the store of the ids handled (memorySeen by
|
|
105
|
-
// lost at a restart: give a durable one).
|
|
106
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|