@cotal-ai/web 0.24.0 → 0.25.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/dist/web/app.js +179 -57
- package/dist/web/graph.html +2 -0
- package/dist/web/graph.js +104 -22
- package/dist/web/index.html +5 -0
- package/dist/web/snapshot.js +99 -0
- package/dist/web.d.ts +121 -9
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +372 -23
- package/dist/web.js.map +1 -1
- package/package.json +4 -3
package/dist/web.d.ts
CHANGED
|
@@ -57,19 +57,131 @@ export interface ActivitySource {
|
|
|
57
57
|
export declare function chatOnly<T extends {
|
|
58
58
|
channel: string;
|
|
59
59
|
}>(rows: readonly T[]): T[];
|
|
60
|
-
/**
|
|
60
|
+
/** How long one aggregating request may take before it answers with what it has.
|
|
61
|
+
*
|
|
62
|
+
* WHY A DEADLINE AT ALL, with the measurement that set it. `/api/activity` fans out one history
|
|
63
|
+
* read per channel, and the cost of a read is the link, not the broker. Against a local broker
|
|
64
|
+
* behind a 160ms-RTT, 128 KiB/s link with 40 channels and 12000 messages: the same aggregation
|
|
65
|
+
* finished in 125ms for a reader ON the broker host and returned 500 `timeout` after 15.94s for the
|
|
66
|
+
* reader across the link; at a less constrained 256 KiB/s it SUCCEEDED after 34491ms, which is the
|
|
67
|
+
* same defect with a different ending. An unbounded aggregation has no answer for either case.
|
|
68
|
+
*
|
|
69
|
+
* WHY THIS NUMBER. It is longer than a healthy remote read of this shape (the measured
|
|
70
|
+
* `/api/channels` + a page per channel) and far shorter than a reader will sit in front of a blank
|
|
71
|
+
* panel. It is not tuned to any one link: what makes the surface honest is that it always answers
|
|
72
|
+
* and always says what it left out, not that the bound is optimal. */
|
|
73
|
+
export declare const AGGREGATION_DEADLINE_MS = 8000;
|
|
74
|
+
/** How many per-source reads are in flight at once.
|
|
75
|
+
*
|
|
76
|
+
* WHY NOT ALL OF THEM. Every source shares ONE connection to ONE broker, so past the point where
|
|
77
|
+
* the link is saturated extra concurrency buys no throughput: it spreads the same bytes over more
|
|
78
|
+
* unfinished reads, and a read that is 90% done when the deadline fires contributes nothing.
|
|
79
|
+
*
|
|
80
|
+
* THE NUMBER IS MEASURED, NOT PREFERRED, and the measurement includes what it costs. Same corpus
|
|
81
|
+
* (40 channels, 12000 chat messages, 2000 DMs), 160ms RTT, sources answered inside the 8000ms
|
|
82
|
+
* deadline, three strategies, each arm on an idle link:
|
|
83
|
+
*
|
|
84
|
+
* link fan out all 41 pool of 8 pool of 1 widening on each completion
|
|
85
|
+
* 1024 KiB/s 1 16 3
|
|
86
|
+
* 512 KiB/s 1 8 3
|
|
87
|
+
* 256 KiB/s 1 0 3
|
|
88
|
+
* 128 KiB/s 1 0 1
|
|
89
|
+
*
|
|
90
|
+
* The fan-out is the shape that shipped and it is the worst column at every speed: reading the whole
|
|
91
|
+
* set at once is why the panel was empty rather than short. A pool that starts at one and widens on
|
|
92
|
+
* each completed read was built and measured too, on the reasoning that it would adapt to a link it
|
|
93
|
+
* cannot know; it does not pay, because at a healthy link a single source is round-trip bound rather
|
|
94
|
+
* than throughput bound, so the first completion arrives too late to be useful evidence and the ramp
|
|
95
|
+
* costs more than the adaptation returns.
|
|
96
|
+
*
|
|
97
|
+
* WHAT THIS BOUND DECLINES, stated rather than left to be discovered. Below roughly 500 KiB/s at
|
|
98
|
+
* this RTT and this corpus, no source completes inside the deadline, the page reports `0 of 41`, and
|
|
99
|
+
* the browser keeps what it already had and marks it stale. The fan-out returned ONE source there,
|
|
100
|
+
* so this trades a single channel's history for a response that is bounded and that says what it
|
|
101
|
+
* left out. On a link that cannot serve the request, saying so is the answer. */
|
|
102
|
+
export declare const AGGREGATION_CONCURRENCY = 8;
|
|
103
|
+
/** A request the CALLER got wrong, so the frame answers 400 rather than the 500 it gives a server
|
|
104
|
+
* fault. Without this every malformed query reads, in the log and in the body, exactly like the
|
|
105
|
+
* dashboard breaking. */
|
|
106
|
+
export declare class BadRequest extends Error {
|
|
107
|
+
}
|
|
108
|
+
/** QUOTE A CALLER'S OWN VALUE SO AN OPERATOR CAN READ IT.
|
|
109
|
+
*
|
|
110
|
+
* `JSON.stringify` was doing two jobs at every site below and only claims one of them. It builds
|
|
111
|
+
* valid JSON, and on the way it escapes every C0 control, so `ESC` arrives as the six characters
|
|
112
|
+
* `\u001b` and a newline as `\n`. It is not a renderer for humans and never said it was: DEL, the
|
|
113
|
+
* C1 range, `U+2028`/`U+2029`, the bidi controls and the zero-width characters are all valid JSON
|
|
114
|
+
* string content and pass through untouched.
|
|
115
|
+
*
|
|
116
|
+
* MEASURED against the shipped `web()` entry before this existed, driving `/api/activity?limit=`
|
|
117
|
+
* with each codepoint percent-encoded and reading the answer as BYTES rather than through a JSON
|
|
118
|
+
* parse (a parse decodes the very thing under test and hands the input back whatever the server
|
|
119
|
+
* wrote). Six of eight arrived raw in BOTH the 400 body and the operator's stderr line: DEL,
|
|
120
|
+
* `U+0085`, `U+009B`, `U+202E`, `U+2028`, `U+2029`. `ESC` and `LF` came back escaped, which is
|
|
121
|
+
* what makes the other six a finding rather than a property of the harness.
|
|
122
|
+
*
|
|
123
|
+
* The escape is emitted as `\uXXXX`, so the message stays valid JSON on the body path and reads as
|
|
124
|
+
* the codepoint it is on the terminal path. Applied where the value is QUOTED rather than where it
|
|
125
|
+
* is written out, because the untrusted thing is the value and the message is derived from it: a
|
|
126
|
+
* guard at the two exits fences those two exits, while a guard here travels with the sentence.
|
|
127
|
+
*
|
|
128
|
+
* The loop below is per UTF-16 UNIT, not per codepoint: `u` hands the callback a whole codepoint,
|
|
129
|
+
* so an astral one (a tag character, a musical control) arrives as its surrogate pair and has to
|
|
130
|
+
* leave as two escapes. `\u1d173` is not a JSON escape, and a body carrying it would stop parsing
|
|
131
|
+
* for the caller who asked what was wrong with their request. */
|
|
132
|
+
export declare function quoteForOperator(value: string): string;
|
|
133
|
+
/** The channel name out of the path. A percent escape the decoder cannot read is the caller having
|
|
134
|
+
* typed a bad one, so it is refused as a bad request like any other malformed input. Left as a
|
|
135
|
+
* bare `URIError` it reached the request frame unrecognised and was reported as a server fault,
|
|
136
|
+
* which is the one thing the 400/500 split exists to prevent. */
|
|
137
|
+
export declare function channelNameFromPath(raw: string): string;
|
|
138
|
+
/** The `limit` out of the query. The safe-integer refusal is reachable only through the digits-only
|
|
139
|
+
* test above it, so its value cannot carry anything the quoter would escape today. It quotes
|
|
140
|
+
* anyway: the guarantee that a refusal renders its input unambiguously should hold because the
|
|
141
|
+
* quoting site holds it, not because a regex two lines up stays exactly as narrow as it is this
|
|
142
|
+
* morning. */
|
|
143
|
+
export declare function historyLimit(query: URLSearchParams, fallback: number): number;
|
|
144
|
+
/** One aggregated page, and what it is missing. `partial` and the counts are ALWAYS present, so a
|
|
145
|
+
* page that ran out of time cannot be mistaken for a complete one by omission. The shape that made
|
|
146
|
+
* `{"error":"timeout"}` indistinguishable from data is exactly this mistake one layer up. */
|
|
147
|
+
export interface ActivityPage {
|
|
148
|
+
entries: ({
|
|
149
|
+
mode: "chat";
|
|
150
|
+
channel: string;
|
|
151
|
+
msg: CotalMessage;
|
|
152
|
+
} | {
|
|
153
|
+
mode: "unicast";
|
|
154
|
+
msg: CotalMessage;
|
|
155
|
+
})[];
|
|
156
|
+
/** True iff at least one source did not answer within the deadline. */
|
|
157
|
+
partial: boolean;
|
|
158
|
+
/** Sources that answered, out of sources asked (channels + the DM backlog). */
|
|
159
|
+
read: number;
|
|
160
|
+
of: number;
|
|
161
|
+
/** Every source that did not answer, NAMED. A count alone tells a reader something is missing and
|
|
162
|
+
* not what, which on a dashboard is the difference between "one channel is slow" and "the space
|
|
163
|
+
* is empty". */
|
|
164
|
+
missing: string[];
|
|
165
|
+
deadlineMs: number;
|
|
166
|
+
}
|
|
167
|
+
/** The all-activity backfill: recent chat history merged with DM history, oldest-first, capped, and
|
|
168
|
+
* BOUNDED.
|
|
169
|
+
*
|
|
170
|
+
* WHAT CHANGED AND WHY, because the previous shape had two failure modes and no good one. It fanned
|
|
171
|
+
* out under `Promise.all` and awaited the DM backlog after it, so (1) one channel's rejection
|
|
172
|
+
* discarded every channel that had already answered and became the route's 500, and (2) there was no
|
|
173
|
+
* upper bound at all: the caller waited for the slowest read however long that took. Measured across
|
|
174
|
+
* a 160ms link, the first produced `500 {"error":"timeout"}` after 15.94s and the second produced a
|
|
175
|
+
* 34-second success. Neither is an answer a dashboard can render.
|
|
176
|
+
*
|
|
177
|
+
* Now every source - each channel AND the DM backlog, which used to be serialized after them - races
|
|
178
|
+
* one shared deadline. Sources that answered are merged; sources that refused or ran late are NAMED
|
|
179
|
+
* in the page. The page is never a 500 and never silently short.
|
|
61
180
|
*
|
|
62
181
|
* Extracted from the route so the filter above is reachable by a test that can see WHICH channels
|
|
63
182
|
* were asked for, which is the only evidence that separates filtering before the fetch from
|
|
64
183
|
* filtering after it. The route is a thin caller. */
|
|
65
|
-
export declare function activityBackfill(ep: ActivitySource, limit: number): Promise<
|
|
66
|
-
mode: "chat";
|
|
67
|
-
channel: string;
|
|
68
|
-
msg: CotalMessage;
|
|
69
|
-
} | {
|
|
70
|
-
mode: "unicast";
|
|
71
|
-
msg: CotalMessage;
|
|
72
|
-
})[]>;
|
|
184
|
+
export declare function activityBackfill(ep: ActivitySource, limit: number, deadlineMs?: number, concurrency?: number): Promise<ActivityPage>;
|
|
73
185
|
/** A live observability dashboard for a space, served over HTTP + SSE. A read-only
|
|
74
186
|
* observer endpoint (invisible to peers) feeds the page presence, channel history,
|
|
75
187
|
* and a live message stream — no manager required. Bound to loopback. */
|
package/dist/web.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../src/web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,
|
|
1
|
+
{"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../src/web.ts"],"names":[],"mappings":"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"}
|
package/dist/web.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createServer } from "node:http";
|
|
|
3
3
|
import { closeSync, fstatSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
-
import { CotalEndpoint, deliveryOf, isEventChannel, parseSubject, spacePrefix, mintCreds, newIdentity, clearChannel, } from "@cotal-ai/core";
|
|
6
|
+
import { CotalEndpoint, deliveryOf, isEventChannel, parseSubject, spacePrefix, mintCreds, newIdentity, clearChannel, assertValidChannel, } from "@cotal-ai/core";
|
|
7
7
|
import { c, connectOrExit, localProcessPath, userViewAuth, userViewAuthOrExit, } from "@cotal-ai/workspace";
|
|
8
8
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
/** The dashboard's default port and its branded address. The server binds loopback
|
|
@@ -103,6 +103,11 @@ export const PAGE = {
|
|
|
103
103
|
// has no merge to order; giving it the machine anyway would imply an ordering guarantee on a
|
|
104
104
|
// surface where nothing consumes one.
|
|
105
105
|
"/event-order.js": { path: join(here, "web/event-order.js"), type: jsType },
|
|
106
|
+
// Keep-last-good + the refusal guard, shared by BOTH pages so they cannot disagree about what a
|
|
107
|
+
// failed poll does to what is already on screen. Served to `/` and `/graph` alike: the wipe was
|
|
108
|
+
// measured on the graph page and the corrupted feed on the console page, and one page keeping its
|
|
109
|
+
// snapshot while the other drops it is the state this file exists to prevent.
|
|
110
|
+
"/snapshot.js": { path: join(here, "web/snapshot.js"), type: jsType },
|
|
106
111
|
"/md.js": { path: join(here, "web/md.js"), type: jsType },
|
|
107
112
|
"/app.js": { path: join(here, "web/app.js"), type: jsType },
|
|
108
113
|
"/graph": { path: join(here, "web/graph.html"), type: "text/html; charset=utf-8" },
|
|
@@ -134,23 +139,324 @@ export const PAGE = {
|
|
|
134
139
|
export function chatOnly(rows) {
|
|
135
140
|
return rows.filter((row) => !isEventChannel(row.channel));
|
|
136
141
|
}
|
|
137
|
-
/**
|
|
142
|
+
/** How long one aggregating request may take before it answers with what it has.
|
|
143
|
+
*
|
|
144
|
+
* WHY A DEADLINE AT ALL, with the measurement that set it. `/api/activity` fans out one history
|
|
145
|
+
* read per channel, and the cost of a read is the link, not the broker. Against a local broker
|
|
146
|
+
* behind a 160ms-RTT, 128 KiB/s link with 40 channels and 12000 messages: the same aggregation
|
|
147
|
+
* finished in 125ms for a reader ON the broker host and returned 500 `timeout` after 15.94s for the
|
|
148
|
+
* reader across the link; at a less constrained 256 KiB/s it SUCCEEDED after 34491ms, which is the
|
|
149
|
+
* same defect with a different ending. An unbounded aggregation has no answer for either case.
|
|
150
|
+
*
|
|
151
|
+
* WHY THIS NUMBER. It is longer than a healthy remote read of this shape (the measured
|
|
152
|
+
* `/api/channels` + a page per channel) and far shorter than a reader will sit in front of a blank
|
|
153
|
+
* panel. It is not tuned to any one link: what makes the surface honest is that it always answers
|
|
154
|
+
* and always says what it left out, not that the bound is optimal. */
|
|
155
|
+
export const AGGREGATION_DEADLINE_MS = 8_000;
|
|
156
|
+
/** How many per-source reads are in flight at once.
|
|
157
|
+
*
|
|
158
|
+
* WHY NOT ALL OF THEM. Every source shares ONE connection to ONE broker, so past the point where
|
|
159
|
+
* the link is saturated extra concurrency buys no throughput: it spreads the same bytes over more
|
|
160
|
+
* unfinished reads, and a read that is 90% done when the deadline fires contributes nothing.
|
|
161
|
+
*
|
|
162
|
+
* THE NUMBER IS MEASURED, NOT PREFERRED, and the measurement includes what it costs. Same corpus
|
|
163
|
+
* (40 channels, 12000 chat messages, 2000 DMs), 160ms RTT, sources answered inside the 8000ms
|
|
164
|
+
* deadline, three strategies, each arm on an idle link:
|
|
165
|
+
*
|
|
166
|
+
* link fan out all 41 pool of 8 pool of 1 widening on each completion
|
|
167
|
+
* 1024 KiB/s 1 16 3
|
|
168
|
+
* 512 KiB/s 1 8 3
|
|
169
|
+
* 256 KiB/s 1 0 3
|
|
170
|
+
* 128 KiB/s 1 0 1
|
|
171
|
+
*
|
|
172
|
+
* The fan-out is the shape that shipped and it is the worst column at every speed: reading the whole
|
|
173
|
+
* set at once is why the panel was empty rather than short. A pool that starts at one and widens on
|
|
174
|
+
* each completed read was built and measured too, on the reasoning that it would adapt to a link it
|
|
175
|
+
* cannot know; it does not pay, because at a healthy link a single source is round-trip bound rather
|
|
176
|
+
* than throughput bound, so the first completion arrives too late to be useful evidence and the ramp
|
|
177
|
+
* costs more than the adaptation returns.
|
|
178
|
+
*
|
|
179
|
+
* WHAT THIS BOUND DECLINES, stated rather than left to be discovered. Below roughly 500 KiB/s at
|
|
180
|
+
* this RTT and this corpus, no source completes inside the deadline, the page reports `0 of 41`, and
|
|
181
|
+
* the browser keeps what it already had and marks it stale. The fan-out returned ONE source there,
|
|
182
|
+
* so this trades a single channel's history for a response that is bounded and that says what it
|
|
183
|
+
* left out. On a link that cannot serve the request, saying so is the answer. */
|
|
184
|
+
export const AGGREGATION_CONCURRENCY = 8;
|
|
185
|
+
/** The sentinel a source resolves to when the deadline beat it. */
|
|
186
|
+
const LATE = Symbol("late");
|
|
187
|
+
/** A promise that resolves at `ms`, plus the handle to cancel its timer. `unref` alone is not
|
|
188
|
+
* enough: an 8-second timer in a long-lived server would hold a poll's worth of state per request. */
|
|
189
|
+
function deadline(ms) {
|
|
190
|
+
let timer;
|
|
191
|
+
const until = new Promise((resolve) => {
|
|
192
|
+
timer = setTimeout(() => resolve(LATE), ms);
|
|
193
|
+
timer.unref();
|
|
194
|
+
});
|
|
195
|
+
return { until, done: () => clearTimeout(timer) };
|
|
196
|
+
}
|
|
197
|
+
/** Race one source against the request's deadline.
|
|
198
|
+
*
|
|
199
|
+
* THE WORK IS ABANDONED, NOT CANCELLED, and that is stated rather than implied: a JetStream read in
|
|
200
|
+
* flight has no cancel, so a late read keeps running until it finishes and its ephemeral consumer is
|
|
201
|
+
* reclaimed by its own inactivity threshold. "Bounded" here means the RESPONSE is bounded. Claiming
|
|
202
|
+
* it bounds broker work would be the silent half of the defect this deadline exists to fix. */
|
|
203
|
+
async function within(p, until) {
|
|
204
|
+
return Promise.race([p, until]);
|
|
205
|
+
}
|
|
206
|
+
/** A request the CALLER got wrong, so the frame answers 400 rather than the 500 it gives a server
|
|
207
|
+
* fault. Without this every malformed query reads, in the log and in the body, exactly like the
|
|
208
|
+
* dashboard breaking. */
|
|
209
|
+
export class BadRequest extends Error {
|
|
210
|
+
}
|
|
211
|
+
/** THE LIMIT, PARSED ONCE, because three routes each re-deriving
|
|
212
|
+
* `query.get("limit") ? Number(...) : N` is how they came to disagree about the same parameter.
|
|
213
|
+
*
|
|
214
|
+
* MEASURED ON THE SHIPPED ROUTES, against a real broker, before this existed:
|
|
215
|
+
* ?limit=abc `Number("abc")` is NaN and every comparison against NaN is false, so core's
|
|
216
|
+
* `limit <= 0` guard does not fire and the widening search's two exits can
|
|
217
|
+
* never be true. No answer after 30s, and the ABANDONED request kept consuming
|
|
218
|
+
* half a core with its caller long gone, invisible because the process keeps
|
|
219
|
+
* serving everything else.
|
|
220
|
+
* ?limit=Infinity passes the same guard, and `slice(-Infinity)` is the whole array: a channel's
|
|
221
|
+
* entire retained history from a one word request. `1e999` is the same value.
|
|
222
|
+
* ?limit=2.5 silently truncated to 2.
|
|
223
|
+
* ?limit=" 5" accepted as 5, because `Number()` trims whitespace.
|
|
224
|
+
*
|
|
225
|
+
* So the accepted form is the narrow one: a plain run of digits naming a safe integer. `0` keeps
|
|
226
|
+
* meaning zero, which is what it already did and what a caller expects; an absent or empty
|
|
227
|
+
* parameter keeps meaning the route's own default, the one shape the old parse got right.
|
|
228
|
+
*
|
|
229
|
+
* Everything else is REFUSED rather than clamped. A clamp would answer a request nobody made, and
|
|
230
|
+
* the caller who wrote `limit=2.5` would never learn that the page they read was not the page they
|
|
231
|
+
* asked for. */
|
|
232
|
+
/** Codepoints `JSON.stringify` leaves RAW that PRODUCE NO GLYPH OF THEIR OWN, or that reorder the
|
|
233
|
+
* text around them, stated as Unicode PROPERTIES rather than as a hand list. That wording is
|
|
234
|
+
* narrower than "change what a reader sees" on purpose, and the narrowing is a review finding:
|
|
235
|
+
* the looser phrase admits every combining mark, and the paragraph at the end of this comment is
|
|
236
|
+
* why escaping those would be this issue pointed the other way. The first version of this WAS a hand list, and review
|
|
237
|
+
* found it missing U+061C, U+2060, the variation selectors and the tag characters, every one of
|
|
238
|
+
* which is exactly the thing the list said it closed. A list is a claim about a set nobody
|
|
239
|
+
* maintains; the property IS the set, and it moves with the Unicode version the runtime carries.
|
|
240
|
+
*
|
|
241
|
+
* Two properties, because neither contains the other and both name the same harm from a different
|
|
242
|
+
* side. `Default_Ignorable_Code_Point` is the renders-as-nothing family: the soft hyphen, the
|
|
243
|
+
* zero-width characters, the word joiner, the variation selectors, the tag characters and the BOM.
|
|
244
|
+
* `Cf` is the format family: characters with no glyph of their own that change how the text around
|
|
245
|
+
* them is read, which is where the interlinear annotation controls U+FFF9 to U+FFFB live. Review
|
|
246
|
+
* found those three arriving raw against a class that had only the first property, and they are the
|
|
247
|
+
* clearest case of the harm: they mark a span as base text plus its gloss, so a reader whose
|
|
248
|
+
* terminal does not implement them sees the two runs concatenated into a sentence nobody wrote.
|
|
249
|
+
* Measured, the second property adds 32 codepoints and not one of them is a letter or a digit.
|
|
250
|
+
*
|
|
251
|
+
* `Bidi_Control` is deliberately absent: measured on this runtime, all twelve of its codepoints,
|
|
252
|
+
* U+061C and the isolates included, are already default-ignorable, so naming it would be a second
|
|
253
|
+
* name for one set. The suite pins those twelve by hand, so a Unicode version that separated them
|
|
254
|
+
* goes red rather than quietly leaving a reordering character raw.
|
|
255
|
+
*
|
|
256
|
+
* What no property covers is DEL and the C1 controls, which are `Cc`, and U+2028/U+2029, which are
|
|
257
|
+
* line and paragraph separators, so those are named. C0 is absent because `JSON.stringify` already
|
|
258
|
+
* escapes all of it.
|
|
259
|
+
*
|
|
260
|
+
* NOT IN THIS CLASS, and deliberately: a VISIBLE character that merely resembles another. A Cyrillic
|
|
261
|
+
* small a is a letter, it renders as itself, and escaping it would make a refusal about a name a
|
|
262
|
+
* human typed unreadable, which is this issue pointed the other way. Confusables are a different
|
|
263
|
+
* problem with a different answer, and quoting for a human to read is not it.
|
|
264
|
+
*
|
|
265
|
+
* NOT IN THIS CLASS EITHER, and this one review reached by finding U+0338 COMBINING LONG SOLIDUS
|
|
266
|
+
* OVERLAY arriving raw and asking whether it belonged: a COMBINING MARK. It produces a visible
|
|
267
|
+
* mark on a visible base, and the property that carries it, `gc=Mn`, is the same one carrying the
|
|
268
|
+
* acute accent in a name written in NFD, the Devanagari vowel signs, the Arabic and Hebrew points
|
|
269
|
+
* and the Vietnamese tones. Measured on this runtime, marks are 2543 codepoints and only 263 of
|
|
270
|
+
* them are already in the class, so escaping them would take about 2280 codepoints of ordinary
|
|
271
|
+
* written language and render an accented name as its escapes. A mark CAN build a confusable
|
|
272
|
+
* (U+0338 over `=` renders as a not-equals sign, so a quoted `a=b` can display as `a` not-equals
|
|
273
|
+
* `b`), which is a real harm and the same one the paragraph above declines: it is unbounded, it
|
|
274
|
+
* needs no combining mark to exist, and its answer is normalization or confusable detection
|
|
275
|
+
* rather than making every script that writes with marks unreadable. The suite asserts both
|
|
276
|
+
* exclusions rather than only describing them. */
|
|
277
|
+
const INVISIBLE_AFTER_JSON = /[\p{Default_Ignorable_Code_Point}\p{gc=Cf}\u007f-\u009f\u2028\u2029]/gu;
|
|
278
|
+
/** QUOTE A CALLER'S OWN VALUE SO AN OPERATOR CAN READ IT.
|
|
279
|
+
*
|
|
280
|
+
* `JSON.stringify` was doing two jobs at every site below and only claims one of them. It builds
|
|
281
|
+
* valid JSON, and on the way it escapes every C0 control, so `ESC` arrives as the six characters
|
|
282
|
+
* `\u001b` and a newline as `\n`. It is not a renderer for humans and never said it was: DEL, the
|
|
283
|
+
* C1 range, `U+2028`/`U+2029`, the bidi controls and the zero-width characters are all valid JSON
|
|
284
|
+
* string content and pass through untouched.
|
|
285
|
+
*
|
|
286
|
+
* MEASURED against the shipped `web()` entry before this existed, driving `/api/activity?limit=`
|
|
287
|
+
* with each codepoint percent-encoded and reading the answer as BYTES rather than through a JSON
|
|
288
|
+
* parse (a parse decodes the very thing under test and hands the input back whatever the server
|
|
289
|
+
* wrote). Six of eight arrived raw in BOTH the 400 body and the operator's stderr line: DEL,
|
|
290
|
+
* `U+0085`, `U+009B`, `U+202E`, `U+2028`, `U+2029`. `ESC` and `LF` came back escaped, which is
|
|
291
|
+
* what makes the other six a finding rather than a property of the harness.
|
|
292
|
+
*
|
|
293
|
+
* The escape is emitted as `\uXXXX`, so the message stays valid JSON on the body path and reads as
|
|
294
|
+
* the codepoint it is on the terminal path. Applied where the value is QUOTED rather than where it
|
|
295
|
+
* is written out, because the untrusted thing is the value and the message is derived from it: a
|
|
296
|
+
* guard at the two exits fences those two exits, while a guard here travels with the sentence.
|
|
297
|
+
*
|
|
298
|
+
* The loop below is per UTF-16 UNIT, not per codepoint: `u` hands the callback a whole codepoint,
|
|
299
|
+
* so an astral one (a tag character, a musical control) arrives as its surrogate pair and has to
|
|
300
|
+
* leave as two escapes. `\u1d173` is not a JSON escape, and a body carrying it would stop parsing
|
|
301
|
+
* for the caller who asked what was wrong with their request. */
|
|
302
|
+
export function quoteForOperator(value) {
|
|
303
|
+
return JSON.stringify(value).replace(INVISIBLE_AFTER_JSON, (ch) => {
|
|
304
|
+
let out = "";
|
|
305
|
+
for (let i = 0; i < ch.length; i++)
|
|
306
|
+
out += "\\u" + ch.charCodeAt(i).toString(16).padStart(4, "0");
|
|
307
|
+
return out;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/** The channel name out of the path. A percent escape the decoder cannot read is the caller having
|
|
311
|
+
* typed a bad one, so it is refused as a bad request like any other malformed input. Left as a
|
|
312
|
+
* bare `URIError` it reached the request frame unrecognised and was reported as a server fault,
|
|
313
|
+
* which is the one thing the 400/500 split exists to prevent. */
|
|
314
|
+
export function channelNameFromPath(raw) {
|
|
315
|
+
let name;
|
|
316
|
+
try {
|
|
317
|
+
name = decodeURIComponent(raw);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
throw new BadRequest(`channel name ${quoteForOperator(raw)} is not valid percent-encoded text`);
|
|
321
|
+
}
|
|
322
|
+
return canonicalChannel(name);
|
|
323
|
+
}
|
|
324
|
+
/** A caller's channel name, refused unless it is ALREADY the name the wire uses.
|
|
325
|
+
*
|
|
326
|
+
* The wire builds a channel's subject through `token()`, which rewrites anything outside
|
|
327
|
+
* `[A-Za-z0-9_-]` to `_` rather than refusing it, so a name a caller invented and a real channel
|
|
328
|
+
* collide: `abc` + U+202E and `abc_` are one channel on the wire while the dashboard answers with
|
|
329
|
+
* whichever the caller typed. Measured against the shipped routes on a local broker before this
|
|
330
|
+
* existed, with one message seeded on `abc_`: the history read under the first name returned the
|
|
331
|
+
* second's message, and the delete route purged the second while answering
|
|
332
|
+
* `{"ok":true,"channel":"abc<U+202E>","purged":1}`. Rendering that answer readably would have made
|
|
333
|
+
* the lie legible without removing it, which is why this refuses the name instead.
|
|
334
|
+
*
|
|
335
|
+
* Core already owns the rule, written for the same aliasing gap on the ACL side; the dashboard
|
|
336
|
+
* simply never asked it. Its message is rebuilt here rather than passed through, because core
|
|
337
|
+
* quotes the raw name with `JSON.stringify` and this route is the one place that must not. */
|
|
338
|
+
function canonicalChannel(name) {
|
|
339
|
+
try {
|
|
340
|
+
return assertValidChannel(name);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
throw new BadRequest(`channel name ${quoteForOperator(name)} is not a channel: dotted segments of [A-Za-z0-9_-], ` +
|
|
344
|
+
`and a name the wire would rewrite would address a different channel than it names`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** The `limit` out of the query. The safe-integer refusal is reachable only through the digits-only
|
|
348
|
+
* test above it, so its value cannot carry anything the quoter would escape today. It quotes
|
|
349
|
+
* anyway: the guarantee that a refusal renders its input unambiguously should hold because the
|
|
350
|
+
* quoting site holds it, not because a regex two lines up stays exactly as narrow as it is this
|
|
351
|
+
* morning. */
|
|
352
|
+
export function historyLimit(query, fallback) {
|
|
353
|
+
const raw = query.get("limit");
|
|
354
|
+
if (raw === null || raw === "")
|
|
355
|
+
return fallback;
|
|
356
|
+
if (!/^[0-9]+$/.test(raw))
|
|
357
|
+
throw new BadRequest(`limit must be a whole number of messages, received ${quoteForOperator(raw)}`);
|
|
358
|
+
const n = Number(raw);
|
|
359
|
+
if (!Number.isSafeInteger(n))
|
|
360
|
+
throw new BadRequest(`limit ${quoteForOperator(raw)} is larger than this server can count exactly`);
|
|
361
|
+
return n;
|
|
362
|
+
}
|
|
363
|
+
/** The all-activity backfill: recent chat history merged with DM history, oldest-first, capped, and
|
|
364
|
+
* BOUNDED.
|
|
365
|
+
*
|
|
366
|
+
* WHAT CHANGED AND WHY, because the previous shape had two failure modes and no good one. It fanned
|
|
367
|
+
* out under `Promise.all` and awaited the DM backlog after it, so (1) one channel's rejection
|
|
368
|
+
* discarded every channel that had already answered and became the route's 500, and (2) there was no
|
|
369
|
+
* upper bound at all: the caller waited for the slowest read however long that took. Measured across
|
|
370
|
+
* a 160ms link, the first produced `500 {"error":"timeout"}` after 15.94s and the second produced a
|
|
371
|
+
* 34-second success. Neither is an answer a dashboard can render.
|
|
372
|
+
*
|
|
373
|
+
* Now every source - each channel AND the DM backlog, which used to be serialized after them - races
|
|
374
|
+
* one shared deadline. Sources that answered are merged; sources that refused or ran late are NAMED
|
|
375
|
+
* in the page. The page is never a 500 and never silently short.
|
|
138
376
|
*
|
|
139
377
|
* Extracted from the route so the filter above is reachable by a test that can see WHICH channels
|
|
140
378
|
* were asked for, which is the only evidence that separates filtering before the fetch from
|
|
141
379
|
* filtering after it. The route is a thin caller. */
|
|
142
|
-
export async function activityBackfill(ep, limit) {
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
380
|
+
export async function activityBackfill(ep, limit, deadlineMs = AGGREGATION_DEADLINE_MS, concurrency = AGGREGATION_CONCURRENCY) {
|
|
381
|
+
const clock = deadline(deadlineMs);
|
|
382
|
+
try {
|
|
383
|
+
// The channel list is inside the deadline too: it is a broker read like any other, and a request
|
|
384
|
+
// that could hang here would be bounded everywhere except its first step. There is no partial
|
|
385
|
+
// page to serve without it, so this one is a refusal rather than a partial: `0 of 0` would claim
|
|
386
|
+
// the space has no channels, which is a different answer and the wrong one.
|
|
387
|
+
//
|
|
388
|
+
// BOTH ENDINGS ARE NAMED, and the second is why this is not just a `within` call. The registry
|
|
389
|
+
// read has its OWN timeout inside the client, shorter than this deadline: measured across a
|
|
390
|
+
// 128 KiB/s link it rejected with the broker's bare `timeout` after 5s, before the deadline
|
|
391
|
+
// could fire, and that word travelled through the generic 500 handler to the browser as
|
|
392
|
+
// `{"error":"timeout"}` - five characters of cause for a panel that went blank. A refusal the
|
|
393
|
+
// reader cannot act on is the defect this change exists to remove, so the reason is wrapped in
|
|
394
|
+
// the name of the read that produced it.
|
|
395
|
+
const listed = await within(ep.listChannels().catch((e) => {
|
|
396
|
+
throw new Error(`the channel list could not be read: ${e instanceof Error ? e.message : String(e)}`);
|
|
397
|
+
}), clock.until);
|
|
398
|
+
if (listed === LATE)
|
|
399
|
+
throw new Error(`the channel list did not arrive within ${deadlineMs}ms`);
|
|
400
|
+
const chans = chatOnly(listed);
|
|
401
|
+
const sources = [
|
|
402
|
+
...chans.map((ch) => ({
|
|
403
|
+
name: `#${ch.channel}`,
|
|
404
|
+
// Each message is tagged with the channel this server REQUESTED, so the backfill path does
|
|
405
|
+
// not depend on the payload claim either.
|
|
406
|
+
read: async () => (await ep.channelHistory(ch.channel, { limit })).map((msg) => ({ mode: "chat", channel: ch.channel, msg })),
|
|
407
|
+
})),
|
|
408
|
+
{
|
|
409
|
+
name: "direct messages",
|
|
410
|
+
read: async () => (await ep.dmHistory({ limit })).map((msg) => ({ mode: "unicast", msg })),
|
|
411
|
+
},
|
|
412
|
+
];
|
|
413
|
+
// A POOL, NOT A FAN-OUT. Workers pull from a shared cursor, so at most `concurrency` reads are
|
|
414
|
+
// in flight and the rest wait their turn. A worker that finds the deadline already past does not
|
|
415
|
+
// start another read: the page is closed, and issuing broker work for it would be waste with a
|
|
416
|
+
// guaranteed-discarded result.
|
|
417
|
+
const settled = new Array(sources.length).fill(LATE);
|
|
418
|
+
let next = 0;
|
|
419
|
+
let expired = false;
|
|
420
|
+
void clock.until.then(() => { expired = true; });
|
|
421
|
+
const worker = async () => {
|
|
422
|
+
for (;;) {
|
|
423
|
+
const i = next++;
|
|
424
|
+
if (i >= sources.length || expired)
|
|
425
|
+
return;
|
|
426
|
+
try {
|
|
427
|
+
const r = await within(sources[i].read(), clock.until);
|
|
428
|
+
if (r !== LATE)
|
|
429
|
+
settled[i] = r;
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
// A source that FAILED is missing for the same reason a late one is: it has nothing to
|
|
433
|
+
// contribute. It is named the same way, and it no longer takes the whole page with it.
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, sources.length) }, worker));
|
|
438
|
+
const entries = [];
|
|
439
|
+
const missing = [];
|
|
440
|
+
for (let i = 0; i < settled.length; i++) {
|
|
441
|
+
const r = settled[i];
|
|
442
|
+
if (r === LATE)
|
|
443
|
+
missing.push(sources[i].name);
|
|
444
|
+
else
|
|
445
|
+
entries.push(...r);
|
|
446
|
+
}
|
|
447
|
+
entries.sort((a, b) => a.msg.ts - b.msg.ts);
|
|
448
|
+
return {
|
|
449
|
+
entries: entries.slice(-limit),
|
|
450
|
+
partial: missing.length > 0,
|
|
451
|
+
read: sources.length - missing.length,
|
|
452
|
+
of: sources.length,
|
|
453
|
+
missing,
|
|
454
|
+
deadlineMs,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
clock.done();
|
|
459
|
+
}
|
|
154
460
|
}
|
|
155
461
|
/** A live observability dashboard for a space, served over HTTP + SSE. A read-only
|
|
156
462
|
* observer endpoint (invisible to peers) feeds the page presence, channel history,
|
|
@@ -368,18 +674,54 @@ export async function web(args) {
|
|
|
368
674
|
// fetched message is still at or above it, until none can extend above the cutoff. That is
|
|
369
675
|
// worth doing, with a test encoding the counterexample above, and it is not this change.
|
|
370
676
|
// Correctness first: fetch a full page per channel and merge.
|
|
371
|
-
const limit =
|
|
372
|
-
|
|
677
|
+
const limit = historyLimit(query, 200);
|
|
678
|
+
const page = await activityBackfill(ep, limit);
|
|
679
|
+
// A partial page is worth SAYING on the server too: the operator watching this log is the one
|
|
680
|
+
// who can tell a slow link from a broken channel, and the browser's marker never reaches them.
|
|
681
|
+
if (page.partial)
|
|
682
|
+
console.error(c.yellow(`~ ${req.method ?? "GET"} ${path} partial: ${page.read}/${page.of} sources within ${page.deadlineMs}ms, missing ${page.missing.join(", ")}`));
|
|
683
|
+
return json(res, page);
|
|
373
684
|
}
|
|
374
685
|
if (path === "/api/dms") {
|
|
375
686
|
// DM history for the Direct-messages lens (god-view); the client groups it by peer/pair.
|
|
376
|
-
|
|
377
|
-
|
|
687
|
+
//
|
|
688
|
+
// BOUNDED LIKE THE AGGREGATION, AND A REFUSAL RATHER THAN A PARTIAL. This is ONE read of one
|
|
689
|
+
// subject, so there is no subset to serve when it runs long: it either produced the page or it
|
|
690
|
+
// produced nothing. Measured across a 160ms link it took 16.59s, which is a 200 nobody is still
|
|
691
|
+
// waiting for. A named 503 at the deadline lets the browser keep the DM list it already has and
|
|
692
|
+
// say it is stale, which is strictly more than a page that arrives after the reader gave up.
|
|
693
|
+
const limit = historyLimit(query, 500);
|
|
694
|
+
const clock = deadline(AGGREGATION_DEADLINE_MS);
|
|
695
|
+
try {
|
|
696
|
+
const dms = await within(ep.dmHistory({ limit }), clock.until);
|
|
697
|
+
if (dms === LATE)
|
|
698
|
+
return json(res, { error: `direct messages: the read did not finish within ${AGGREGATION_DEADLINE_MS}ms` }, 503);
|
|
699
|
+
return json(res, dms);
|
|
700
|
+
}
|
|
701
|
+
finally {
|
|
702
|
+
clock.done();
|
|
703
|
+
}
|
|
378
704
|
}
|
|
379
705
|
if (path.startsWith("/api/channels/") && path.endsWith("/history")) {
|
|
380
|
-
const name =
|
|
381
|
-
const limit =
|
|
382
|
-
|
|
706
|
+
const name = channelNameFromPath(path.slice("/api/channels/".length, -"/history".length));
|
|
707
|
+
const limit = historyLimit(query, 200);
|
|
708
|
+
// BOUNDED LIKE ITS SIBLINGS, and deliberately the SAME bound rather than a new one. This is
|
|
709
|
+
// one read of one channel, so like `/api/dms` it has no subset to serve when it runs long: a
|
|
710
|
+
// named 503 lets the open channel keep the messages it already has and say they are stale,
|
|
711
|
+
// which beats a page that arrives after the reader gave up. Measured on a modelled link
|
|
712
|
+
// before this existed: 11360ms here, on a link where `/api/dms` already refused at 8005ms.
|
|
713
|
+
// The console page re-reads this route on every poll, so an unbounded read here is one slow
|
|
714
|
+
// channel holding the view open indefinitely.
|
|
715
|
+
const clock = deadline(AGGREGATION_DEADLINE_MS);
|
|
716
|
+
try {
|
|
717
|
+
const page = await within(ep.channelHistory(name, { limit }), clock.until);
|
|
718
|
+
if (page === LATE)
|
|
719
|
+
return json(res, { error: `#${name}: the read did not finish within ${AGGREGATION_DEADLINE_MS}ms` }, 503);
|
|
720
|
+
return json(res, page);
|
|
721
|
+
}
|
|
722
|
+
finally {
|
|
723
|
+
clock.done();
|
|
724
|
+
}
|
|
383
725
|
}
|
|
384
726
|
// Delete a channel and its content. The only write path on this otherwise read-only
|
|
385
727
|
// dashboard, so it's POST-gated and guarded by a confirm in the UI. Uses the manager cred
|
|
@@ -393,6 +735,10 @@ export async function web(args) {
|
|
|
393
735
|
res.end(JSON.stringify({ error: "channel required" }));
|
|
394
736
|
return;
|
|
395
737
|
}
|
|
738
|
+
// BEFORE the purge, not after: this is the destructive route, and an aliasing name here
|
|
739
|
+
// deletes a channel the caller did not name. Throws a BadRequest, which the request frame
|
|
740
|
+
// turns into the same 400 and the same operator line every other refusal gets.
|
|
741
|
+
canonicalChannel(channel);
|
|
396
742
|
try {
|
|
397
743
|
// User mode mints a one-shot channel-purger VIEW per delete — the ledger is re-checked at
|
|
398
744
|
// this click, and a mid-session revoke becomes this handler's 400, never a dead dashboard.
|
|
@@ -427,10 +773,13 @@ export async function web(args) {
|
|
|
427
773
|
const httpServer = createServer((req, res) => {
|
|
428
774
|
void handleRequest(req, res).catch((e) => {
|
|
429
775
|
const why = e instanceof Error ? e.message : String(e);
|
|
430
|
-
|
|
776
|
+
// A caller error and a server fault are different facts and must not share a status. Before
|
|
777
|
+
// this split, a malformed query read in the log exactly like the dashboard breaking.
|
|
778
|
+
const status = e instanceof BadRequest ? 400 : 500;
|
|
779
|
+
console.error(c[status === 400 ? "yellow" : "red"](`${status === 400 ? "~" : "!"} ${req.method ?? "GET"} ${req.url ?? "/"} ${status === 400 ? "refused" : "failed"}: ${why}`));
|
|
431
780
|
if (res.headersSent)
|
|
432
781
|
return void res.end();
|
|
433
|
-
res.writeHead(
|
|
782
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
434
783
|
res.end(JSON.stringify({ error: why }));
|
|
435
784
|
});
|
|
436
785
|
});
|