@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.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
- /** The all-activity backfill: recent chat history merged with DM history, oldest-first, capped.
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,EASL,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAML,KAAK,YAAY,EAElB,MAAM,qBAAqB,CAAC;AAI7B;;;+DAG+D;AAC/D,eAAO,MAAM,QAAQ,OAAO,CAAC;AAC7B,eAAO,MAAM,OAAO,iCAAwC,CAAC;AAI7D,eAAO,MAAM,UAAU,EAAE,YASxB,CAAC;AAoDF;;+FAE+F;AAC/F,eAAO,MAAM,sBAAsB,2BAA2B,CAAC;AAE/D;8DAC8D;AAC9D,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAsB/D,CAAC;AAEF;8FAC8F;AAC9F,MAAM,WAAW,cAAc;IAC7B,YAAY,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,EAAE,CAAC,CAAC;IACnF,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;IAClF,SAAS,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;CAC7D;AAED;;;;;;;;;;;;;;;;;;;;oGAoBoG;AACpG,wBAAgB,QAAQ,CAAC,CAAC,SAAS;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,IAAI,EAAE,SAAS,CAAC,EAAE,GAAG,CAAC,EAAE,CAE/E;AAED;;;;qDAIqD;AACrD,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,YAAY,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,YAAY,CAAA;CAAE,CAAC,EAAE,CAAC,CAkB5G;AAED;;0EAE0E;AAC1E,wBAAsB,GAAG,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA0TzD;AAgDD,wBAAgB,YAAY,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAa5F;AAED,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,YAAY,EACnB,IAAI,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GACvE,OAAO,CAAC,IAAI,CAAC,CA6Bf;AAED,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAY9F;AAgBD,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAYpE"}
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
- /** The all-activity backfill: recent chat history merged with DM history, oldest-first, capped.
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 chans = chatOnly(await ep.listChannels());
144
- // Each message is tagged with the channel this server REQUESTED, so the backfill path does
145
- // not depend on the payload claim either.
146
- const chat = (await Promise.all(chans.map(async (ch) => (await ep.channelHistory(ch.channel, { limit })).map((msg) => ({
147
- mode: "chat",
148
- channel: ch.channel,
149
- msg,
150
- }))))).flat();
151
- const dms = (await ep.dmHistory({ limit })).map((msg) => ({ mode: "unicast", msg }));
152
- const all = [...chat, ...dms].sort((a, b) => a.msg.ts - b.msg.ts);
153
- return all.slice(-limit);
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 = query.get("limit") ? Number(query.get("limit")) : 200;
372
- return json(res, await activityBackfill(ep, limit));
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
- const limit = query.get("limit") ? Number(query.get("limit")) : 500;
377
- return json(res, await ep.dmHistory({ limit }));
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 = decodeURIComponent(path.slice("/api/channels/".length, -"/history".length));
381
- const limit = query.get("limit") ? Number(query.get("limit")) : 200;
382
- return json(res, await ep.channelHistory(name, { limit }));
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
- console.error(c.red(`! ${req.method ?? "GET"} ${req.url ?? "/"} failed: ${why}`));
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(500, { "content-type": "application/json" });
782
+ res.writeHead(status, { "content-type": "application/json" });
434
783
  res.end(JSON.stringify({ error: why }));
435
784
  });
436
785
  });