@argentic/chest-sdk 0.4.1 → 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 (76) hide show
  1. package/README.md +500 -73
  2. package/client/index.ts +11 -7
  3. package/client/src/api.ts +15 -8
  4. package/client/src/errors.ts +50 -0
  5. package/client/src/eventrules.ts +117 -0
  6. package/client/src/events.ts +181 -39
  7. package/client/src/files.ts +37 -7
  8. package/client/src/member.ts +15 -9
  9. package/client/src/members.ts +34 -16
  10. package/client/src/notifications.ts +84 -27
  11. package/client/src/realtime-client.ts +516 -0
  12. package/client/src/realtime.ts +118 -0
  13. package/client/src/sealed.ts +158 -0
  14. package/client/src/signed.ts +8 -4
  15. package/client/src/testing-realtime.ts +361 -0
  16. package/client/src/testing.ts +388 -89
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +11 -7
  20. package/dist/index.js.map +1 -1
  21. package/dist/src/api.d.ts +2 -0
  22. package/dist/src/api.d.ts.map +1 -1
  23. package/dist/src/api.js +14 -7
  24. package/dist/src/api.js.map +1 -1
  25. package/dist/src/errors.d.ts +18 -0
  26. package/dist/src/errors.d.ts.map +1 -1
  27. package/dist/src/errors.js +44 -0
  28. package/dist/src/errors.js.map +1 -1
  29. package/dist/src/eventrules.d.ts +23 -0
  30. package/dist/src/eventrules.d.ts.map +1 -0
  31. package/dist/src/eventrules.js +107 -0
  32. package/dist/src/eventrules.js.map +1 -0
  33. package/dist/src/events.d.ts +31 -6
  34. package/dist/src/events.d.ts.map +1 -1
  35. package/dist/src/events.js +130 -26
  36. package/dist/src/events.js.map +1 -1
  37. package/dist/src/files.d.ts +1 -0
  38. package/dist/src/files.d.ts.map +1 -1
  39. package/dist/src/files.js +34 -3
  40. package/dist/src/files.js.map +1 -1
  41. package/dist/src/member.d.ts +1 -1
  42. package/dist/src/member.d.ts.map +1 -1
  43. package/dist/src/member.js +9 -6
  44. package/dist/src/member.js.map +1 -1
  45. package/dist/src/members.d.ts +9 -2
  46. package/dist/src/members.d.ts.map +1 -1
  47. package/dist/src/members.js +29 -13
  48. package/dist/src/members.js.map +1 -1
  49. package/dist/src/notifications.d.ts +12 -1
  50. package/dist/src/notifications.d.ts.map +1 -1
  51. package/dist/src/notifications.js +60 -19
  52. package/dist/src/notifications.js.map +1 -1
  53. package/dist/src/realtime-client.d.ts +45 -0
  54. package/dist/src/realtime-client.d.ts.map +1 -0
  55. package/dist/src/realtime-client.js +453 -0
  56. package/dist/src/realtime-client.js.map +1 -0
  57. package/dist/src/realtime.d.ts +22 -0
  58. package/dist/src/realtime.d.ts.map +1 -0
  59. package/dist/src/realtime.js +99 -0
  60. package/dist/src/realtime.js.map +1 -0
  61. package/dist/src/sealed.d.ts +20 -0
  62. package/dist/src/sealed.d.ts.map +1 -0
  63. package/dist/src/sealed.js +121 -0
  64. package/dist/src/sealed.js.map +1 -0
  65. package/dist/src/signed.d.ts.map +1 -1
  66. package/dist/src/signed.js +6 -2
  67. package/dist/src/signed.js.map +1 -1
  68. package/dist/src/testing-realtime.d.ts +54 -0
  69. package/dist/src/testing-realtime.d.ts.map +1 -0
  70. package/dist/src/testing-realtime.js +376 -0
  71. package/dist/src/testing-realtime.js.map +1 -0
  72. package/dist/src/testing.d.ts +40 -3
  73. package/dist/src/testing.d.ts.map +1 -1
  74. package/dist/src/testing.js +365 -76
  75. package/dist/src/testing.js.map +1 -1
  76. package/package.json +23 -4
@@ -0,0 +1,158 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import { ask as chest, errorCode, json, refusal } from "./api.js";
3
+ import { CapabilityNotGranted, ChestError, MemberRequired, NotAllowed, SealedInvalid, SealedLocked, SealedLost, Unavailable } from "./errors.js";
4
+
5
+ // Sealed values, for a server tool whose chest.json declares
6
+ // "capabilities": ["sealed"]: an IBAN, a salary, a medical note, a
7
+ // confidential message. The Chest seals a value with a key of the tool that
8
+ // never leaves the Chest; the tool stores the sealed text in its own
9
+ // database, in any text column. Only the tool opens it again, through its
10
+ // Chest, on the request of a member who has the tool — and holds one of the
11
+ // roles the value was sealed for, when it was sealed for some. The Data tab,
12
+ // the agents' SQL, a builder, the owner, the backups read the sealed text
13
+ // alone, shown as "Sealed". Every open is journaled (who, how many, when;
14
+ // never a value), and every new version of the tool waits for the owner or
15
+ // an admin: its code can read sealed data.
16
+ //
17
+ // import { seal, open, openMany, isSealed } from "@argentic/chest-sdk/sealed";
18
+ // const iban = await seal("FR76 3000 6000 0112 3456 7890 189", { context: `employee:${id}`, roles: ["hr"] });
19
+ // await sql`update employees set iban = ${iban} where id = ${id}`;
20
+ // // On a request of a member (its ticket travels with it):
21
+ // const plain = await open(request, row.iban, { context: `employee:${row.id}` });
22
+ // const page = await openMany(request, rows.map(r => ({ sealed: r.iban, context: `employee:${r.id}` })));
23
+ // // → string | null each: null for a value this member may not open
24
+ //
25
+ // context binds a value to where it belongs (the row it is in): a sealed
26
+ // value copied into another row does not open there. roles are among those
27
+ // chest.json declares; the Chest checks the member's role (set by the owner
28
+ // or an admin, never by the tool). Seal what nobody searches: a sealed value
29
+ // is never searchable, sortable or filterable on the server; keep in clear
30
+ // what lists and filters need. Sealing needs no member (a public form, a
31
+ // schedule seal too); opening needs one, on their request.
32
+ //
33
+ // Errors: MemberRequired (no member on the request, or their ticket
34
+ // expired: a request lasts 60 seconds), NotAllowed (the member lost the
35
+ // tool, or open() of a value sealed for roles they do not hold),
36
+ // SealedInvalid (open() of a value altered, another tool's or of another
37
+ // context), SealedLocked (the Chest was restored and waits for its owner's
38
+ // recovery code), SealedLost (the key of this tool's values is lost for
39
+ // good), CapabilityNotGranted, Unavailable, ChestError (invalid_role,
40
+ // invalid_body 400).
41
+
42
+ // What a value is sealed with: the roles that may open it (those chest.json
43
+ // declares; any member who has the tool without) and its context.
44
+ export type SealOptions = { roles?: string[]; context?: string };
45
+ // A value to seal, and a sealed value to open in its context.
46
+ export type SealItem = { value: string } & SealOptions;
47
+ export type OpenItem = { sealed: string; context?: string };
48
+
49
+ // The bounds of a Chest: one value, a context, the roles a tool declares.
50
+ // No count bounds a call: a page of values goes in one, 4 MiB at most.
51
+ const maxValue = 512 << 10, maxContext = 256, maxRoles = 16;
52
+ const rolePattern = /^[a-z][a-z0-9-]{0,47}$/u;
53
+ // A sealed value: its format, the roles it was sealed for, its sealed bytes.
54
+ const sealedPattern = /^chest:sealed:1:(?:[a-z][a-z0-9-]{0,47}(?:,[a-z][a-z0-9-]{0,47}){0,15})?:[A-Za-z0-9_-]+$/u;
55
+ const maxSealed = 1 << 20;
56
+ const bytes = (s: string): number => Buffer.byteLength(s, "utf8");
57
+
58
+ // isSealed says text is a sealed value of a Chest (its shape only: whether
59
+ // it opens is the Chest's to say).
60
+ export function isSealed(text: unknown): text is string {
61
+ return typeof text === "string" && text.length <= maxSealed && sealedPattern.test(text);
62
+ }
63
+
64
+ // The ticket of the member of a request, as the Chest's front sends it
65
+ // (Chest-Opener); null without one.
66
+ function ticketOf(request: IncomingMessage | Request): string | null {
67
+ const headers = request.headers as Headers | IncomingMessage["headers"];
68
+ const value = typeof (headers as Headers).get === "function" ? (headers as Headers).get("chest-opener") : (headers as IncomingMessage["headers"])["chest-opener"];
69
+ return typeof value === "string" && value.length > 0 && value.length <= 256 ? value : null;
70
+ }
71
+
72
+ function checkContext(context: unknown): string {
73
+ if (context === undefined) return "";
74
+ if (typeof context !== "string" || bytes(context) > maxContext) throw new ChestError("invalid_body", 400, `a context is text of ${maxContext} bytes at most`);
75
+ return context;
76
+ }
77
+
78
+ function checkItem(item: SealItem): { value: string; roles?: string[]; context?: string } {
79
+ if (typeof item?.value !== "string" || bytes(item.value) > maxValue) throw new ChestError("invalid_body", 400, `a sealed value is text of ${maxValue >> 10} KiB at most`);
80
+ const context = checkContext(item.context);
81
+ if (item.roles !== undefined && (!Array.isArray(item.roles) || item.roles.length === 0 || item.roles.length > maxRoles || !item.roles.every(r => typeof r === "string" && rolePattern.test(r)) || new Set(item.roles).size !== item.roles.length)) throw new ChestError("invalid_role", 400, "roles are 1 to 16 distinct roles the tool declares");
82
+ return { value: item.value, ...(item.roles ? { roles: [...item.roles] } : {}), ...(context ? { context } : {}) };
83
+ }
84
+
85
+ // failed turns an answer that is not a success into what the tool tests.
86
+ async function failed(response: Response): Promise<ChestError> {
87
+ if (response.status === 401) return new MemberRequired();
88
+ if (response.status !== 403 && response.status !== 503) return refusal(response, "sealed");
89
+ switch (await errorCode(response)) {
90
+ case "access_removed": return new NotAllowed();
91
+ case "capability_not_granted": return new CapabilityNotGranted("sealed");
92
+ case "sealed_locked": return new SealedLocked();
93
+ case "sealed_lost": return new SealedLost();
94
+ default: return new Unavailable();
95
+ }
96
+ }
97
+
98
+ async function call(path: string, ticket: string | null, body: unknown): Promise<unknown> {
99
+ const response = await chest("sealed", "POST", path, { body: JSON.stringify(body), type: "application/json", ...(ticket === null ? {} : { headers: { "Chest-Opener": ticket } }) });
100
+ if (response.status === 200) return json(response);
101
+ if (response.status < 400) {
102
+ await response.body?.cancel();
103
+ throw new Unavailable();
104
+ }
105
+ throw await failed(response);
106
+ }
107
+
108
+ // sealMany seals values, each with its options, in one call: their sealed
109
+ // texts, in the order given.
110
+ export async function sealMany(items: SealItem[]): Promise<string[]> {
111
+ const checked = items.map(checkItem);
112
+ const answer = await call("/sealed/seal", null, { items: checked }) as { sealed?: unknown };
113
+ if (!Array.isArray(answer?.sealed) || answer.sealed.length !== checked.length || !answer.sealed.every(isSealed)) throw new Unavailable();
114
+ return answer.sealed;
115
+ }
116
+
117
+ // seal seals one value: its sealed text, to store.
118
+ export async function seal(value: string, options: SealOptions = {}): Promise<string> {
119
+ return (await sealMany([{ value, ...options }]))[0]!;
120
+ }
121
+
122
+ // openMany opens values for the member of request, in one call: each
123
+ // value's text, or null for one this member may not open (sealed for roles
124
+ // they do not hold), or that is not a value of this tool in that context.
125
+ export async function openMany(request: IncomingMessage | Request, items: OpenItem[]): Promise<(string | null)[]> {
126
+ const ticket = ticketOf(request);
127
+ if (ticket === null) throw new MemberRequired();
128
+ const checked = items.map(item => {
129
+ if (typeof item?.sealed !== "string" || item.sealed.length > maxSealed) throw new ChestError("invalid_body", 400, "a sealed value is the text seal returned");
130
+ const context = checkContext(item.context);
131
+ return { sealed: item.sealed, ...(context ? { context } : {}) };
132
+ });
133
+ const answer = await call("/sealed/open", ticket, { items: checked }) as { values?: unknown };
134
+ const values = answer?.values;
135
+ if (!Array.isArray(values) || values.length !== checked.length) throw new Unavailable();
136
+ return values.map(v => {
137
+ const item = v as { value?: unknown; refused?: unknown } | null;
138
+ if (typeof item?.value === "string") return item.value;
139
+ if (item?.refused === "role" || item?.refused === "invalid") return null;
140
+ throw new Unavailable();
141
+ });
142
+ }
143
+
144
+ // open opens one value for the member of request: its text. A value sealed
145
+ // for roles the member does not hold is NotAllowed; one altered, of another
146
+ // tool or another context, SealedInvalid.
147
+ export async function open(request: IncomingMessage | Request, sealed: string, options: { context?: string } = {}): Promise<string> {
148
+ const ticket = ticketOf(request);
149
+ if (ticket === null) throw new MemberRequired();
150
+ const context = checkContext(options.context);
151
+ if (typeof sealed !== "string" || sealed.length > maxSealed) throw new ChestError("invalid_body", 400, "a sealed value is the text seal returned");
152
+ const answer = await call("/sealed/open", ticket, { items: [{ sealed, ...(context ? { context } : {}) }] }) as { values?: unknown };
153
+ const item = (Array.isArray(answer?.values) && answer.values.length === 1 ? answer.values[0] : null) as { value?: unknown; refused?: unknown } | null;
154
+ if (typeof item?.value === "string") return item.value;
155
+ if (item?.refused === "role") throw new NotAllowed();
156
+ if (item?.refused === "invalid") throw new SealedInvalid();
157
+ throw new Unavailable();
158
+ }
@@ -1,8 +1,8 @@
1
1
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingMessage } from "node:http";
3
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
4
+ // What the Chest signs for a server tool — the events of its members and
5
+ // of other tools (events), the runs of its schedules (schedules), posted through its
6
6
  // launcher only (never from the Internet), each on a route of its own —: one
7
7
  // mechanism, shared by the modules that read it and by testing, which signs
8
8
  // as the Chest. Not a published module. member.ts reads the Chest-Member
@@ -22,8 +22,12 @@ import type { IncomingMessage } from "node:http";
22
22
  // the grammar of its identifiers and the largest body it carries.
23
23
  export type Channel = { header: string; label: string; id: RegExp; maxBody: number };
24
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 };
25
+ // The events of the members and of other tools (events): POST /chest-events.
26
+ // A tool event names the members who may see it, up to a whole team: its
27
+ // body is bounded by the team's capacity — a full team's policy, 32 MiB —
28
+ // and its 64 KiB of envelope and data. Only the Chest makes a tool read that
29
+ // much: delivery reads no body before its signature holds.
30
+ export const eventChannel: Channel = { header: "Chest-Event", label: "Chest-Event v1", id: /^evt_[a-z2-7]{26}$/u, maxBody: (32 << 20) + (64 << 10) };
27
31
  // The runs of the schedules (schedules): POST /chest-schedules.
28
32
  export const scheduleChannel: Channel = { header: "Chest-Schedule", label: "Chest-Schedule v1", id: /^run_[a-z2-7]{26}$/u, maxBody: 1024 };
29
33
 
@@ -0,0 +1,361 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { STATUS_CODES, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import type { Duplex } from "node:stream";
4
+ import { memberIdPattern } from "./member.js";
5
+ import { channelPattern, eventPattern, type Present } from "./realtime.js";
6
+ import { peerEventPattern } from "./realtime-client.js";
7
+
8
+ // The realtime of a fake Chest (testing.ts): the tool's API (/realtime/*)
9
+ // with the Chest's bounds, and the Chest's side of the pages — a hub on the
10
+ // fake Chest's own origin, which @argentic/chest-sdk/realtime/client
11
+ // connects to with connect({ url: chest.realtime.url(member) }) — under the
12
+ // rules of the tool's chest.json. Rows of feeds are committed by the test
13
+ // (commit), membership rows removed by it (removed): there is no database,
14
+ // but the Chest's change log of the tool (each committed row's position,
15
+ // kept 7 days) and its memory of each channel (2 minutes), on a clock the
16
+ // test moves (advance). Not a published module.
17
+
18
+ // A channel as chest.json declares it (its "realtime" key).
19
+ export type FakeChannelRule = { name: string; join?: string[] | { table: string; key: string; member: string }; send?: boolean; presence?: boolean };
20
+ // A feed as chest.json declares it.
21
+ export type FakeFeed = { table: string; channel: string; columns: string[] };
22
+ // The realtime of a fake Chest: the tool's channels and feeds, as its
23
+ // chest.json declares them, and who is in a membership table (none by
24
+ // default).
25
+ export type FakeRealtimeOptions = { channels?: FakeChannelRule[]; feeds?: FakeFeed[]; membership?: (table: string, key: string, member: string) => boolean };
26
+ // What the tool published, and sent to members, in order.
27
+ export type FakePublished = { channel: string; event: string; payload: unknown; seq: number };
28
+ export type FakeSent = { members: string[]; event: string; payload: unknown };
29
+ export type FakeRealtime = {
30
+ published: FakePublished[];
31
+ sent: FakeSent[];
32
+ // renewals counts the pages' questions to the Chest without upgrading:
33
+ // each renews the member's session (every 5 minutes while connected, and
34
+ // before connecting again).
35
+ renewals: number;
36
+ // url is where a page of that member connects (the fake Chest has no
37
+ // session: the member is in the address).
38
+ url(memberId: string): string;
39
+ // commit is a row committed to a table, whole as the database holds it,
40
+ // as the Chest's triggers tell it: for each feed of the table, in the
41
+ // order declared, the next position of the tool's change log, published
42
+ // to the feed's channel — its template filled from the row's column it
43
+ // names, which need not be carried — with the feed's columns only. The
44
+ // positions given; none for a feed whose column is null or absent.
45
+ commit(table: string, op: "insert" | "update" | "delete", row: Record<string, unknown>): number[];
46
+ // removed is a row of a membership table that went: its member leaves
47
+ // the channels it gave them, at once.
48
+ removed(table: string, key: string, member: string): void;
49
+ // drop cuts the member's pages as a network would, or closes them with a
50
+ // code and reason (1001 going away, 1013 try later, 1008 session_ended):
51
+ // they reconnect.
52
+ drop(memberId: string, code?: number, reason?: string): void;
53
+ // revoke closes the member's pages as the Chest does when their access is
54
+ // taken back: they stop.
55
+ revoke(memberId: string): void;
56
+ // signOut ends the member's session: the next renewal of their pages
57
+ // answers 401 (they stop), and they connect no more.
58
+ signOut(memberId: string): void;
59
+ // full makes the Chest without room for that many seconds: connections
60
+ // are refused, and the question before connecting answers 503 with
61
+ // Retry-After — or, for "joins", its memory holds no more: a join answers
62
+ // full (the page tries again later).
63
+ full(seconds: number, what?: "connections" | "joins"): void;
64
+ // advance moves the Chest's clock forward: its memory (2 minutes), its
65
+ // change log (7 days) and a full Chest's wait age as much.
66
+ advance(ms: number): void;
67
+ };
68
+
69
+ const protocol = "chest-realtime.v1", maxPayload = 64 << 10, maxSend = 4 << 10, maxState = 1 << 10;
70
+ // How long the Chest keeps a channel's frames in memory, and the tool's
71
+ // committed rows in its change log.
72
+ const memoryFor = 2 * 60 * 1000, logFor = 7 * 24 * 60 * 60 * 1000;
73
+
74
+ // A page: its channels, those it is present in, the one it has on screen
75
+ // ("" none).
76
+ type Page = { member: string; joined: Set<string>; tracked: Set<string>; focus: string; write(value: unknown): void; close(code: number, reason: string): void; cut(): void };
77
+ // A channel: its last number, the frames its memory keeps (forgotten: the
78
+ // last number it no longer keeps), who is present.
79
+ type ChannelState = { seq: number; kept: { seq: number; at: number; frame: unknown }[]; forgotten: number; present: Map<string, { state: unknown; tracked: number }> };
80
+ // A row of the tool's change log, as a replay gives it.
81
+ type Logged = { pos: number; at: number; channel: string; frame: { op: "msg"; ch: string; event: string; payload: unknown; pos: number } };
82
+
83
+ // prefixOf splits a pattern into its fixed part and its variable last
84
+ // segment ("" for an exact name).
85
+ function prefixOf(name: string): [string, string] {
86
+ const at = name.lastIndexOf(":");
87
+ const last = name.slice(at + 1);
88
+ return last === "*" || /^\{[a-z_][a-z0-9_]*\}$/u.test(last) ? [name.slice(0, at + 1), last] : [name, ""];
89
+ }
90
+ function matches(rule: FakeChannelRule, name: string, member: string): boolean {
91
+ const [prefix, variable] = prefixOf(rule.name);
92
+ if (!variable) return name === prefix;
93
+ const last = name.startsWith(prefix) ? name.slice(prefix.length) : "";
94
+ return /^[a-z0-9_-]{1,64}$/u.test(last) && (variable !== "{member}" || last === member);
95
+ }
96
+
97
+ export function fakeRealtime(options: FakeRealtimeOptions, origin: () => string, roleOf: (member: string) => string | null | undefined) {
98
+ const channels = new Map<string, ChannelState>();
99
+ const pages = new Set<Page>();
100
+ const epoch = randomBytes(9).toString("base64url");
101
+ // The change log: its rows, the last position given, the last one no
102
+ // longer kept. The clock: the test's advance on top of the real one.
103
+ const log: Logged[] = [], signedOut = new Set<string>();
104
+ let head = 0, logForgotten = 0, skew = 0, fullUntil = 0, joinsFullUntil = 0;
105
+ const now = () => Date.now() + skew;
106
+ const realtime: FakeRealtime = {
107
+ published: [], sent: [], renewals: 0,
108
+ url: member => origin().replace(/^http/u, "ws") + "/_chest/realtime?member=" + encodeURIComponent(member),
109
+ commit(table, op, row) {
110
+ const positions: number[] = [];
111
+ for (const feed of options.feeds ?? []) {
112
+ if (feed.table !== table) continue;
113
+ const [prefix, variable] = prefixOf(feed.channel);
114
+ const suffix = variable ? row[variable.slice(1, -1)] : "";
115
+ if (suffix === null || suffix === undefined) continue;
116
+ const channel = prefix + String(suffix), event = table + "." + op, payload = Object.fromEntries(feed.columns.map(c => [c, row[c] ?? null]));
117
+ const pos = ++head;
118
+ log.push({ pos, at: now(), channel, frame: { op: "msg", ch: channel, event, payload, pos } });
119
+ publish(channel, event, payload, pos);
120
+ positions.push(pos);
121
+ }
122
+ return positions;
123
+ },
124
+ removed(table, key, member) {
125
+ for (const rule of options.channels ?? []) {
126
+ if (typeof rule.join !== "object" || Array.isArray(rule.join) || rule.join.table !== table) continue;
127
+ const name = prefixOf(rule.name)[0] + key;
128
+ for (const page of pages) if (page.member === member && page.joined.has(name)) kick(page, name);
129
+ }
130
+ },
131
+ drop(member, code, reason = "") { for (const page of [...pages]) if (page.member === member) code === undefined ? page.cut() : page.close(code, reason); },
132
+ revoke(member) { for (const page of [...pages]) if (page.member === member) page.close(1008, "access_removed"); },
133
+ signOut(member) { signedOut.add(member); },
134
+ full(seconds, what = "connections") {
135
+ if (what === "joins") joinsFullUntil = now() + seconds * 1000;
136
+ else fullUntil = now() + seconds * 1000;
137
+ },
138
+ advance(ms) { skew += ms; },
139
+ };
140
+ const rule = (name: string, member: string) => name.length <= 128 && channelPattern.test(name) ? (options.channels ?? []).find(r => matches(r, name, member)) : undefined;
141
+ const declared = (name: string) => rule(name, name.slice(name.lastIndexOf(":") + 1)) !== undefined;
142
+ const allows = (r: FakeChannelRule, member: string) => !Array.isArray(r.join) || r.join.includes(roleOf(member) ?? "");
143
+ // fed says whether a feed's rows go to the channel.
144
+ const fed = (name: string) => (options.feeds ?? []).some(f => {
145
+ const [prefix, variable] = prefixOf(f.channel);
146
+ return variable ? name.startsWith(prefix) && !name.slice(prefix.length).includes(":") : name === prefix;
147
+ });
148
+ // state is a channel, its memory and the change log aged to now.
149
+ const state = (name: string): ChannelState => {
150
+ let ch = channels.get(name);
151
+ if (!ch) channels.set(name, ch = { seq: 0, kept: [], forgotten: 0, present: new Map() });
152
+ while (ch.kept[0] && ch.kept[0].at <= now() - memoryFor) ch.forgotten = ch.kept.shift()!.seq;
153
+ while (log[0] && log[0].at <= now() - logFor) logForgotten = log.shift()!.pos;
154
+ return ch;
155
+ };
156
+ function publish(channel: string, event: string, payload: unknown, pos?: number): number {
157
+ const ch = state(channel);
158
+ const frame = { op: "msg", ch: channel, event, payload, seq: ++ch.seq, ...(pos === undefined ? {} : { pos }) };
159
+ ch.kept.push({ seq: ch.seq, at: now(), frame });
160
+ realtime.published.push({ channel, event, payload, seq: ch.seq });
161
+ for (const page of pages) if (page.joined.has(channel)) page.write(frame);
162
+ return ch.seq;
163
+ }
164
+ function untrack(page: Page, name: string) {
165
+ const ch = channels.get(name), p = ch?.present.get(page.member);
166
+ if (!ch || !p || !page.tracked.delete(name) || --p.tracked > 0) return;
167
+ ch.present.delete(page.member);
168
+ for (const other of pages) if (other.joined.has(name) && other.member !== page.member) other.write({ op: "presence", ch: name, joins: [], leaves: [page.member] });
169
+ }
170
+ // part takes a page out of a channel, and forgets its focus there.
171
+ function part(page: Page, name: string) {
172
+ untrack(page, name);
173
+ page.joined.delete(name);
174
+ if (page.focus === name) page.focus = "";
175
+ }
176
+ function kick(page: Page, name: string) {
177
+ part(page, name);
178
+ page.write({ op: "kicked", ch: name });
179
+ }
180
+
181
+ // The tool's API: what the Chest's answers, with its errors.
182
+ async function api(request: IncomingMessage, response: ServerResponse, url: URL, read: (request: IncomingMessage, limit: number) => Promise<Buffer | null>, send: (response: ServerResponse, status: number, value?: unknown) => void): Promise<void> {
183
+ if (request.method === "GET" && url.pathname === "/realtime/presence") {
184
+ const name = url.searchParams.get("channel") ?? "";
185
+ if (!declared(name)) return send(response, 400, { error: "invalid_channel" });
186
+ return send(response, 200, { members: [...(channels.get(name)?.present ?? [])].map(([id, p]) => ({ id, state: p.state })) });
187
+ }
188
+ if (request.method !== "POST" || !["/realtime/publish", "/realtime/send", "/realtime/online"].includes(url.pathname)) return send(response, 404, { error: "not_found" });
189
+ const raw = await read(request, maxPayload + 1024 + 64 * 1000);
190
+ let command: Record<string, unknown>;
191
+ try { command = JSON.parse(raw?.toString() ?? "") as Record<string, unknown>; } catch { return send(response, 400, { error: "invalid_body" }); }
192
+ if (command === null || typeof command !== "object") return send(response, 400, { error: "invalid_body" });
193
+ const payload = command["payload"] ?? null;
194
+ if (Buffer.byteLength(JSON.stringify(payload)) > maxPayload) return send(response, 413, { error: "too_large" });
195
+ const members = command["members"];
196
+ if (url.pathname !== "/realtime/publish" && (!Array.isArray(members) || members.length === 0 || !members.every(m => typeof m === "string" && memberIdPattern.test(m)))) return send(response, 400, { error: Array.isArray(members) && members.length > 0 ? "invalid_id" : "invalid_body" });
197
+ const online = (ids: string[], focus?: string) => [...new Set(ids)].filter(id => [...pages].some(p => p.member === id && (focus === undefined || p.focus === focus)));
198
+ if (url.pathname === "/realtime/online") {
199
+ const channel = command["channel"];
200
+ if (channel !== undefined && (typeof channel !== "string" || channel.length > 128 || !channelPattern.test(channel))) return send(response, 400, { error: "invalid_channel" });
201
+ return send(response, 200, { online: online(members as string[]), watching: channel === undefined ? [] : online(members as string[], channel) });
202
+ }
203
+ const event = command["event"];
204
+ if (typeof event !== "string" || !eventPattern.test(event)) return send(response, 400, { error: "invalid_event" });
205
+ if (url.pathname === "/realtime/send") {
206
+ realtime.sent.push({ members: [...members as string[]], event, payload });
207
+ for (const page of pages) if ((members as string[]).includes(page.member)) page.write({ op: "direct", event, payload });
208
+ return send(response, 200, { reached: online(members as string[]) });
209
+ }
210
+ const channel = command["channel"];
211
+ if (typeof channel !== "string" || !declared(channel)) return send(response, 400, { error: "invalid_channel" });
212
+ send(response, 200, { seq: publish(channel, event, payload) });
213
+ }
214
+
215
+ // refusal is why a member's page may not connect now: no access (403),
216
+ // a session ended (401), no room (503, and in how many seconds).
217
+ const refusal = (member: string): { status: 401 | 403 | 503; retryAfter?: number } | undefined => {
218
+ if (roleOf(member) === undefined) return { status: 403 };
219
+ if (signedOut.has(member)) return { status: 401 };
220
+ if (fullUntil > now()) return { status: 503, retryAfter: Math.ceil((fullUntil - now()) / 1000) };
221
+ return undefined;
222
+ };
223
+ // A page's question without upgrading, which renews its member's session:
224
+ // may they connect now?
225
+ function probe(response: ServerResponse, url: URL, send: (response: ServerResponse, status: number, value?: unknown, headers?: Record<string, string>) => void): void {
226
+ realtime.renewals++;
227
+ const refused = refusal(url.searchParams.get("member") ?? "");
228
+ send(response, refused?.status ?? 204, undefined, refused?.retryAfter ? { "Retry-After": String(refused.retryAfter) } : {});
229
+ }
230
+
231
+ // A page's connection: the handshake, then the protocol's frames.
232
+ function upgrade(request: IncomingMessage, socket: Duplex, url: URL): void {
233
+ const member = url.searchParams.get("member") ?? "";
234
+ const key = request.headers["sec-websocket-key"];
235
+ const offered = String(request.headers["sec-websocket-protocol"] ?? "").split(",").map(s => s.trim());
236
+ const status = typeof key !== "string" || !offered.includes(protocol) ? 403 : refusal(member)?.status;
237
+ if (status !== undefined) {
238
+ socket.end(`HTTP/1.1 ${status} ${STATUS_CODES[status]}\r\nContent-Length: 0\r\n\r\n`);
239
+ return;
240
+ }
241
+ const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
242
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\nSec-WebSocket-Protocol: ${protocol}\r\n\r\n`);
243
+ const frame = (op: number, data: Buffer): Buffer => {
244
+ const head = data.length < 126 ? Buffer.from([0x80 | op, data.length]) : data.length <= 0xffff ? Buffer.from([0x80 | op, 126, data.length >> 8, data.length & 0xff]) : Buffer.concat([Buffer.from([0x80 | op, 127]), (() => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(data.length)); return b; })()]);
245
+ return Buffer.concat([head, data]);
246
+ };
247
+ const page: Page = {
248
+ member, joined: new Set(), tracked: new Set(), focus: "",
249
+ write: value => { if (!socket.destroyed) socket.write(frame(0x1, Buffer.from(JSON.stringify(value)))); },
250
+ close: (code, reason) => {
251
+ const data = Buffer.alloc(2 + Buffer.byteLength(reason));
252
+ data.writeUInt16BE(code);
253
+ data.write(reason, 2);
254
+ if (!socket.destroyed) socket.end(frame(0x8, data));
255
+ },
256
+ cut: () => socket.destroy(),
257
+ };
258
+ pages.add(page);
259
+ const forget = () => {
260
+ if (!pages.delete(page)) return;
261
+ for (const name of [...page.tracked]) untrack(page, name);
262
+ };
263
+ socket.on("close", forget);
264
+ socket.on("error", forget);
265
+ page.write({ op: "hello", epoch, member });
266
+ let pending = Buffer.alloc(0), message: Buffer[] = [];
267
+ socket.on("data", (chunk: Buffer) => {
268
+ pending = Buffer.concat([pending, chunk]);
269
+ for (;;) {
270
+ if (pending.length < 2) return;
271
+ const fin = (pending[0]! & 0x80) !== 0, op = pending[0]! & 0x0f;
272
+ let length = pending[1]! & 0x7f, at = 2;
273
+ if (length === 126) { if (pending.length < 4) return; length = pending.readUInt16BE(2); at = 4; }
274
+ else if (length === 127) { if (pending.length < 10) return; length = Number(pending.readBigUInt64BE(2)); at = 10; }
275
+ if (pending.length < at + 4 + length) return;
276
+ const mask = pending.subarray(at, at + 4), data = Buffer.from(pending.subarray(at + 4, at + 4 + length));
277
+ for (let i = 0; i < data.length; i++) data[i]! ^= mask[i & 3]!;
278
+ pending = pending.subarray(at + 4 + length);
279
+ if (op === 0x8) return page.close(1000, "");
280
+ if (op === 0x9) { socket.write(frame(0xa, data)); continue; }
281
+ if (op !== 0x1 && op !== 0x0) continue;
282
+ message.push(data);
283
+ if (!fin) continue;
284
+ const text = Buffer.concat(message).toString();
285
+ message = [];
286
+ let q: Record<string, unknown>;
287
+ try { q = JSON.parse(text) as Record<string, unknown>; } catch { page.write({ op: "error", ref: 0, code: "invalid_request" }); continue; }
288
+ handle(page, q);
289
+ }
290
+ });
291
+ }
292
+
293
+ function handle(page: Page, q: Record<string, unknown>): void {
294
+ const ref = typeof q["ref"] === "number" ? q["ref"] : 0, name = typeof q["ch"] === "string" ? q["ch"] : "";
295
+ const answer = (code?: string, extra: Record<string, unknown> = {}) => { if (ref !== 0 || code === "invalid_request") page.write(code ? { op: "error", ref, code } : { op: "ok", ref, ...extra }); };
296
+ const r = rule(name, page.member);
297
+ switch (q["op"]) {
298
+ case "ping":
299
+ return answer();
300
+ case "join": {
301
+ if (!r) return answer("invalid_channel");
302
+ if (!allows(r, page.member)) return answer("forbidden");
303
+ const table = typeof r.join === "object" && !Array.isArray(r.join) ? r.join : undefined;
304
+ if (table && !(options.membership?.(table.table, name.slice(prefixOf(r.name)[0].length), page.member) ?? false)) return answer("forbidden");
305
+ if (joinsFullUntil > now()) return answer("full");
306
+ page.joined.add(name);
307
+ // A re-join is given what it missed: from the memory when it keeps
308
+ // all of it (the tool's events too), else from the change log (the
309
+ // feeds' rows), else told resync. A channel fed by a feed says the
310
+ // position the page is at: the one it gave, or the head.
311
+ const ch = state(name), since = q["since"] as { epoch?: unknown; seq?: unknown; pos?: unknown } | undefined;
312
+ let missed: unknown[] = [], resync = false, pos = fed(name) ? head : undefined;
313
+ if (since !== undefined) {
314
+ const seq = Number(since.seq), given = Number(since.pos ?? 0);
315
+ if (since.epoch === epoch && seq >= ch.forgotten && seq <= ch.seq) missed = ch.kept.filter(k => k.seq > seq).map(k => k.frame);
316
+ else if (pos !== undefined && given > 0 && given >= logForgotten && given <= head) missed = log.filter(l => l.channel === name && l.pos > given).map(l => l.frame);
317
+ else resync = true;
318
+ if (!resync && pos !== undefined) pos = given;
319
+ }
320
+ answer(undefined, { seq: ch.seq, ...(pos === undefined ? {} : { pos }), ...(r.presence ? { presence: [...ch.present].map(([id, p]) => ({ id, state: p.state })) } : {}), ...(resync ? { resync } : {}) });
321
+ for (const frame of missed) page.write(frame);
322
+ return;
323
+ }
324
+ case "leave":
325
+ part(page, name);
326
+ return answer();
327
+ case "focus":
328
+ if (name !== "" && !page.joined.has(name)) return answer("forbidden");
329
+ page.focus = name;
330
+ return answer();
331
+ // A member's message is a peer frame, its name never dotted: never
332
+ // taken for the feeds' rows or the tool's events.
333
+ case "send": {
334
+ const payload = q["payload"] ?? null;
335
+ if (typeof q["event"] !== "string" || !peerEventPattern.test(q["event"])) return answer("invalid_event");
336
+ if (Buffer.byteLength(JSON.stringify(payload)) > maxSend) return answer("invalid_body");
337
+ if (!r || !page.joined.has(name) || !r.send || !allows(r, page.member)) return answer("forbidden");
338
+ for (const other of pages) if (other.member !== page.member && other.joined.has(name)) other.write({ op: "peer", ch: name, event: q["event"], payload, from: page.member });
339
+ return answer();
340
+ }
341
+ case "track": {
342
+ const value = q["state"];
343
+ if (value === null || typeof value !== "object" || Array.isArray(value) || Buffer.byteLength(JSON.stringify(value)) > maxState) return answer("invalid_body");
344
+ if (!r || !page.joined.has(name) || !r.presence) return answer("forbidden");
345
+ const ch = state(name);
346
+ let p = ch.present.get(page.member);
347
+ if (!p) ch.present.set(page.member, p = { state: value, tracked: 0 });
348
+ if (!page.tracked.has(name)) { page.tracked.add(name); p.tracked++; }
349
+ p.state = value;
350
+ for (const other of pages) if (other.member !== page.member && other.joined.has(name)) other.write({ op: "presence", ch: name, joins: [{ id: page.member, state: value as Present["state"] }], leaves: [] });
351
+ return answer();
352
+ }
353
+ default:
354
+ return answer("invalid_request");
355
+ }
356
+ }
357
+
358
+ // close closes every page, as the Chest stopping would.
359
+ const close = () => { for (const page of pages) page.close(1001, ""); };
360
+ return { realtime, api, probe, upgrade, close };
361
+ }