@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
|
@@ -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
|
+
}
|
package/client/src/signed.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|