@thehammer/danx-dashboard-mcp 0.1.87 → 0.1.88

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 CHANGED
@@ -60,10 +60,10 @@ CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version>
60
60
  ```
61
61
 
62
62
  - **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE, and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANXBOT_DISPATCH_TOKEN` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
63
- - **No client-side reach check (DX-2920).** `bridge` used to prove board reach itself, before ever opening the stream (`GET /api/plans/mine?fields=cards` + a per-board `GET /api/issues/:id/problems`). That check is DELETED — the dashboard (DX-2863) now enforces the identical requirement server-side, and a second client-side copy of the same check was a duplicate source of truth, not a safety net: it refuses a mint (`403 issuer_cannot_read_plan_boards`), refuses stream admission (the SAME 403), and ends a live stream (`event: end`, `{"reason":"scope_narrowed","boards":[…]}`) the instant it stops being true. `bridge` only MAPS those refusals now — see "Stopping" below — it never independently verifies anything before streaming. Once a ticket is minted, `bridge` emits, once, `{"type":"ready"}` — no `boards` field, since reach is no longer proven client-side.
63
+ - **Reach check.** After minting, `bridge` proves the credential can read every board the connected plan's cards live on (`GET /api/plans/mine?fields=cards`, then `GET /api/issues/:id/problems` for one card per board — the routes that run the same board-allowlist + `read` check the stream itself applies). A board it cannot read stops it with `board_unreadable` rather than streaming on and dropping that board's events. Then, once, `{"type":"ready","boards":[…]}`.
64
64
  - **Ticket.** `bridge` mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`, 10 s timeout) and keeps it in the process. A ticket authorizes reading that one session's event stream and nothing else, and is only issued while the session is connected to a plan. Minting a new one ends the previous listener.
65
65
  - **Output — JSON Lines.** One `{"type":"event","id":<id|null>,"text":"…"}` per event on the connected plan's cards, where `text` is `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… opened a problem: "…"`, `… blocked the card: "…"`, or `… unblocked the card`. An event it cannot read still produces one, with a `could not read event` text. Nothing for keep-alives, reconnects or re-mints. The session's own writes are never echoed back to it.
66
- - **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected` (a `409 session_not_connected` at mint OR at stream admission, or a live `end("not_connected")` — all three mapped identically), `unauthorized` (401/403 for a reason OTHER than an unreadable board), `mint_refused` (any other non-transient mint refusal), `mint_bad_response`, `board_unreadable` (a `403 issuer_cannot_read_plan_boards` at mint OR at stream admission, naming the unreadable boards — DX-2920), `scope_narrowed` (a LIVE stream's `end("scope_narrowed", boards)` — the server's `end()` REQUIRES `boards` for this reason at the type level, `bridge` parses them off the wire and names them in `detail`/`fix`, never a generic message), `bad_end_payload` (a `scope_narrowed` end whose `boards` array was missing or malformed — a protocol error surfaced loudly, never silently downgraded to a boards-less `scope_narrowed`), `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row with no more specific classification). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
66
+ - **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected`, `unauthorized` (401/403), `mint_refused` (any other non-transient refusal), `mint_bad_response`, `board_unreadable` / `scope_check_failed` (the reach check above), `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
67
67
  - **Reconnect and resume.** A read-idle timeout (three missed keep-alives) turns a silently dead connection into a drop. Capped exponential backoff (1s → 30s) that resets only after a healthy connection, with `Last-Event-ID`, so a dashboard restart replays what was missed and nothing is emitted twice. `--resume-ids` carries the same guarantee across a process restart: the ids already delivered seed the duplicate guard, and the highest is the first `Last-Event-ID`. The dashboard floors that replay at the later of the session's first ticket and when it joined its current plan, so a restart loses nothing and a plan move replays nothing from before the move.
68
68
 
69
69
  ## Build + test
package/dist/bridge.js CHANGED
@@ -20,59 +20,29 @@
20
20
  * credential from its session's tools streamed happily and relayed nothing.
21
21
  * 3. Mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`).
22
22
  * The ticket stays in this process.
23
- * 4. DX-2920 — board-coverage reach is a SERVER-ENFORCED property now, never a
24
- * client-side verify: the stream admits an event only when the ticket
25
- * issuer's board allowlist covers that event's board (`isVisibleToStream`,
26
- * dashboard), and the dashboard itself (DX-2863) refuses to hand out a
27
- * ticket, refuses to admit a connection, and ends a live stream the instant
28
- * that stops being true — `403 issuer_cannot_read_plan_boards` at MINT
29
- * (`mintTicket`) AND at stream ADMISSION (`classifyAdmissionRefusal`,
30
- * reading `listen.ts`'s structured `refusal`) are BOTH mapped to
31
- * `board_unreadable`, naming the boards; `409 session_not_connected` at
32
- * mint AND at admission are both mapped to `not_connected`; and
33
- * `end("scope_narrowed", boards)` / `end("not_connected")` cover the
34
- * same two failures once the stream is already live — `end`'s own
35
- * overloaded signature in `plan-session-stream.ts` REQUIRES `boards` for
36
- * `scope_narrowed` at the type level (round 3), and `listen.ts` parses
37
- * them off the wire the same way it parses `reason`, so this module can
38
- * name them in `scope_narrowed`'s detail/fix exactly like it already
39
- * does for `board_unreadable`. This module no longer runs its own
40
- * duplicate board-by-board check before streaming — see DX-2920 and the
41
- * deleted `verifyPlanBoardsReadable`.
23
+ * 4. VERIFIES THE REACH IT JUST BOUGHT: the credential must be able to read
24
+ * every board the connected plan's cards live on, because the stream admits
25
+ * an event only when the ticket issuer's board allowlist covers that
26
+ * event's board (`isVisibleToStream`, dashboard). A board it cannot read is
27
+ * a board whose events would vanish without a word, so it is a terminal,
28
+ * named outcome instead.
42
29
  * 5. Streams through `runListener` (`listen.ts`), which reconnects within the
43
30
  * ticket, and re-mints when that ticket's lease runs out or a fresh ticket
44
31
  * is refused once.
45
- * 6. Writes JSON Lines to stdout: ONE `{type:"ready"}` once a ticket has been
46
- * minted and streaming is about to start, `{type:"event", id, text}` per
47
- * event, and ONE final `{type:"stopped", reason, detail, fix}` before exiting.
32
+ * 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards}` once the stream
33
+ * is live and verified, `{type:"event", id, text}` per event, and ONE final
34
+ * `{type:"stopped", reason, detail, fix}` before exiting.
48
35
  *
49
36
  * TERMINAL OUTCOMES — each ends the process with its stop record, never a retry:
50
37
  * - `no_connection_record` this session has no connection record to read;
51
38
  * - `credential_unavailable` its declared credential cannot be resolved here;
52
39
  * - `credential_mismatch` what resolved is not the server's credential;
53
- * - `not_connected` the mint, or the stream's admission / keep-alive,
54
- * answered `session_not_connected` (409 at mint and
55
- * at admission; `end("not_connected")` once live);
56
- * - `unauthorized` the mint answered 401 / 403 for a reason OTHER
57
- * than an unreadable board;
40
+ * - `not_connected` the mint answered 409 `session_not_connected`;
41
+ * - `unauthorized` the mint or a scope read answered 401 / 403;
58
42
  * - `mint_refused` any other non-transient mint refusal (400, 404, …);
59
43
  * - `mint_bad_response` a 2xx mint whose body is not a ticket;
60
- * - `board_unreadable` the mint OR the stream's admission was refused
61
- * `403 issuer_cannot_read_plan_boards`, naming the
62
- * boards this credential cannot read (DX-2920);
63
- * - `scope_narrowed` a LIVE stream ended because the plan grew, or the
64
- * credential's allowlist shrank past, a board this
65
- * credential can no longer read — `boards` names
66
- * them, parsed from the dashboard's own `end`
67
- * payload by `listen.ts` the same way `reason`
68
- * itself is parsed, and `detail`/`fix` are built
69
- * dynamically from them (DX-2920 round 3 —
70
- * `boardUnreadableDetail` / `scopeNarrowedFix`),
71
- * never a static generic message;
72
- * - `bad_end_payload` a `scope_narrowed` end whose `boards` array was
73
- * missing or malformed — a PROTOCOL error, never
74
- * silently downgraded to a boards-less
75
- * `scope_narrowed` stop (DX-2920 round 3);
44
+ * - `board_unreadable` the credential cannot read a board of this plan;
45
+ * - `scope_check_failed` the plan's boards could not be established;
76
46
  * - `superseded` / `replaced` (exit 0) — another listener now holds the session;
77
47
  * - `revoked` the dashboard revoked the ticket or its issuer;
78
48
  * - `refused` freshly minted tickets were refused twice in a row.
@@ -84,78 +54,14 @@
84
54
  * session's own MCP server declared, resolved here from that declaration.
85
55
  */
86
56
  import { homedir } from "node:os";
87
- import { errorCodeOf } from "./json-body.js";
88
57
  import { oneLine } from "./one-line.js";
89
58
  import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
90
59
  import { readSessionConnection } from "./session-connection.js";
91
60
  import { DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
92
- /**
93
- * DX-2730 D4 — WHICH EVENTS WAKE THE SESSION, NOW THAT EVERY ONE OF THEM CAN.
94
- *
95
- * Every `ListenEvent` carries the origin its writer set (`operator` / `agent` /
96
- * `machine`, or `null` for the idle-nudge exemption and an event this listener
97
- * could not parse — see `ListenEvent.origin`'s docblock in `listen.ts`). This
98
- * module is the ONLY place that decides what happens next:
99
- *
100
- * - `operator` (a real human) and `null` (nudge / unreadable) emit AT ONCE,
101
- * exactly as every event did before this card — a session must never wait
102
- * to hear from the person it is working with, and a fault or a nudge must
103
- * never be hidden behind a delay either.
104
- * - `agent` and `machine` are HELD. Every dispatched agent's own writes and
105
- * every server-side guard/reviewer/recovery write share this treatment:
106
- * gate reviews, triage notes, auto-blocks. Held events are combined into
107
- * ONE digest record, flushed the moment either `DIGEST_INTERVAL_MS`
108
- * elapses with no operator event, or an operator event arrives (flushed
109
- * just ahead of it, never after — a session must never read the digest
110
- * as if it postdates the operator's own message).
111
- *
112
- * NOTHING HELD IS EVER DROPPED, AND NOTHING HELD IS EVER DOUBLE-DELIVERED
113
- * EITHER — across a full process restart OR a same-process re-mint. An id is
114
- * added to `delivered` (this run's own resume cursor, seeded from
115
- * `options.resumeIds` and handed to the next mint/re-mint as `--resume-ids`)
116
- * ONLY at the moment it is actually written to stdout — never at the moment it
117
- * is merely held. So a process that dies mid-hold has, by construction, never
118
- * marked those ids delivered: the next process starts from the SAME cursor,
119
- * the dashboard's replay resends exactly the events this process never
120
- * finished relaying, and they are held and flushed again there. This is "the
121
- * existing resumeIds/delivered-id cursor" the card's AC names — no new
122
- * persistence was needed, only NOT marking a held event delivered until it is
123
- * actually flushed.
124
- *
125
- * THE SAME REPLAY CAN ALSO ARRIVE WITHIN ONE STILL-RUNNING PROCESS, on a
126
- * same-process re-mint (a real `lease_expired`, not merely a dropped
127
- * connection): a fresh `runListener()` call starts a FRESH internal duplicate
128
- * guard, seeded only from the `resumeIds` this call hands it — and a held id
129
- * was, by the paragraph above, never in `delivered`. Left unhandled this
130
- * would let the SAME held event be counted into TWO digests once flushed
131
- * twice. `heldIds()` closes this: every `runListener()` call is seeded with
132
- * `[...delivered, ...heldIds()]`, so the fresh listener's own guard
133
- * (`listen.ts`'s `delivered` Set) already knows about a held id and silently
134
- * drops a replay of it before it ever reaches `routeEvent` again —
135
- * `routeEvent`'s own id check on `held` is the second, cheaper layer behind
136
- * that, for defense in depth.
137
- *
138
- * A digest record rides the exact same `{type:"event", id, text}` shape a
139
- * single event does (`id` is the highest id it combines, or `null` if every
140
- * combined event carried none) — the plugin that relays bridge output
141
- * (`plan-event-bridge.mjs`, outside this repo) already forwards any such
142
- * record generically, so a digest needs no change on that side.
143
- *
144
- * A REAL TIMER, NOT `deps.sleep`. Unlike every other timing decision in this
145
- * module (mint backoff, reconnect backoff), the digest interval is not tied to
146
- * stream activity — it must fire even while the stream sits idle between
147
- * events — so it is a plain `setTimeout`, mockable with fake timers in tests,
148
- * rather than routed through the deliberately stream-driven `deps.sleep`.
149
- */
150
- export const DIGEST_INTERVAL_MS = 10 * 60_000;
151
- const DIGEST_PREFIX = "[danx-dashboard bridge]";
152
- /** ONE combined record for every event held since the last flush. */
153
- function formatDigest(entries) {
154
- const header = `${DIGEST_PREFIX} digest — ${entries.length} agent/machine event${entries.length === 1 ? "" : "s"} since the last update:`;
155
- return [header, ...entries.map((e) => e.text)].join("\n");
156
- }
157
61
  export const BRIDGE_SUBCOMMAND = "bridge";
158
62
  export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
63
+ export const PLAN_MINE_PATH = "/api/plans/mine";
64
+ export const ISSUES_PATH = "/api/issues";
159
65
  export const SESSION_ID_HEADER = "x-danx-session-id";
160
66
  /** How long one dashboard request may take before it counts as a transient failure. */
161
67
  export const REQUEST_TIMEOUT_MS = 10_000;
@@ -163,7 +69,10 @@ export const MINT_INITIAL_BACKOFF_MS = 1_000;
163
69
  export const MINT_MAX_BACKOFF_MS = 60_000;
164
70
  /** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
165
71
  export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
72
+ /** One page of the connected plan's cards, for the board-reach check. The server's ceiling. */
73
+ export const PLAN_CARDS_PAGE_SIZE = 1_000;
166
74
  const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
75
+ const TAKEOVER_REASONS = new Set(["superseded", "replaced"]);
167
76
  const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
168
77
  `[--resume-ids <event-id>[,<event-id>...]]`;
169
78
  /**
@@ -186,11 +95,7 @@ export const STOP_FIXES = {
186
95
  mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
187
96
  mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
188
97
  board_unreadable: "scope this session's dashboard credential to read that board, or connect a plan whose cards all live on boards it can read",
189
- // DX-2920 (round 3) — a `scope_narrowed` end whose `boards` array was
190
- // missing or invalid (`listen.ts`'s parse-time check). Named separately
191
- // from `scope_narrowed` itself, which always carries boards and never
192
- // reaches this generic map at all.
193
- bad_end_payload: "call plan_connect again to mint a new listener ticket for this session",
98
+ scope_check_failed: "check the dashboard is reachable and this session is connected to a plan, then call plan_connect again",
194
99
  // A newer ticket exists (superseded) or another connection is already using
195
100
  // this one (replaced) — THIS process is ending because the session is
196
101
  // already served elsewhere, not because anything is broken.
@@ -222,11 +127,6 @@ export function parseBridgeArgs(argv, env) {
222
127
  }
223
128
  /** A start this process cannot make, named the way the session will be told about it. */
224
129
  export class BridgeStartError extends Error {
225
- // DX-2920 (round 4) — `ListenStopReason`, not `string`: a start-time failure
226
- // is always one of `no_connection_record` / `credential_unavailable` /
227
- // `credential_mismatch`, never `scope_narrowed` (there is no stream yet), so
228
- // this can be the closed reason type the eventual `write({type:"stopped",
229
- // reason: start.reason, ...})` needs it to be.
230
130
  reason;
231
131
  fix;
232
132
  constructor(reason, detail, fix) {
@@ -275,43 +175,15 @@ export function resolveBridgeOptions(args, env, options = {}) {
275
175
  resumeIds: args.resumeIds,
276
176
  };
277
177
  }
278
- /**
279
- * DX-2920 — `body.boards` off a `403 issuer_cannot_read_plan_boards` refusal
280
- * (`unreadableBoardsRefusal`, dashboard `plan-scope.ts`), or `[]` when the body
281
- * doesn't carry a readable list (defensive — never trust the wire shape blindly).
282
- */
283
- function boardsOf(body) {
284
- if (typeof body !== "object" || body === null)
285
- return [];
286
- const boards = body.boards;
287
- return Array.isArray(boards) ? boards.filter((b) => typeof b === "string") : [];
288
- }
289
- /**
290
- * DX-2920 — the ONE `board_unreadable` detail-line builder, shared by the mint
291
- * 403 classification and the stream admission's identical 403, so the two can
292
- * never drift into two different ideas of how to phrase "these boards".
293
- */
294
- function boardUnreadableDetail(boards, fallback) {
295
- return boards.length > 0
296
- ? `this session's dashboard credential cannot read board(s) ${boards.join(", ")} on the connected plan`
297
- : fallback;
298
- }
299
- /**
300
- * DX-2920 (round 3) — `scope_narrowed`'s fix, built dynamically from the
301
- * boards `listen.ts` parsed off the dashboard's own `end` payload (AC 30661:
302
- * "naming the missing boards and how to widen scope"), replacing the static
303
- * generic `STOP_FIXES` entry round 2 shipped before the server actually sent
304
- * a board list.
305
- */
306
- function scopeNarrowedFix(boards) {
307
- return boards.length > 0
308
- ? `widen this session's dashboard credential to cover board(s) ${boards.join(", ")}, or connect a plan whose cards all live on boards it can read`
309
- : "widen this session's dashboard credential to cover every board the connected plan's cards live on, or connect a plan whose cards all live on boards it can read";
178
+ function errorCodeOf(body) {
179
+ return typeof body === "object" && body !== null && typeof body.error === "string"
180
+ ? body.error
181
+ : null;
310
182
  }
311
183
  /**
312
184
  * One authenticated request, with a timeout, classifying the failures that are
313
- * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by
314
- * every request this module makes so they can never disagree about which
185
+ * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by the
186
+ * mint and the board-reach check so the two can never disagree about which
315
187
  * failures are worth retrying.
316
188
  */
317
189
  async function request(options, deps, what, url, init = { method: "GET" }) {
@@ -387,19 +259,6 @@ export async function mintTicket(options, deps) {
387
259
  if (status === 409 && errorCodeOf(body) === "session_not_connected") {
388
260
  return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
389
261
  }
390
- // DX-2920 (AC 30660) — this credential IS accepted; it just cannot read every
391
- // board the connected plan covers (`unreadableBoardsRefusal`, dashboard
392
- // `plan-scope.ts`). Calling that `unauthorized` told the session to fix its
393
- // credential, which was the wrong remedy for a scope problem — this is
394
- // `board_unreadable`, naming the boards, checked BEFORE the generic 401/403
395
- // fallback below so it never falls into it.
396
- if (status === 403 && errorCodeOf(body) === "issuer_cannot_read_plan_boards") {
397
- return {
398
- kind: "terminal",
399
- reason: "board_unreadable",
400
- detail: boardUnreadableDetail(boardsOf(body), `ticket mint HTTP 403: issuer_cannot_read_plan_boards ${oneLine(text, 300)}`),
401
- };
402
- }
403
262
  if (status === 401 || status === 403) {
404
263
  return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
405
264
  }
@@ -418,145 +277,121 @@ export async function mintTicket(options, deps) {
418
277
  leaseMs: ticket.leaseMs,
419
278
  };
420
279
  }
280
+ /** One card per board of the connected plan, read a page at a time. */
281
+ function cardsPageOf(body) {
282
+ if (typeof body !== "object" || body === null)
283
+ return "the body is not a JSON object";
284
+ const b = body;
285
+ if (!Array.isArray(b.cards))
286
+ return "cards is not an array";
287
+ if (typeof b.cards_total !== "number" || !Number.isSafeInteger(b.cards_total))
288
+ return "cards_total is not an integer";
289
+ const cards = [];
290
+ for (const card of b.cards) {
291
+ if (typeof card !== "object" || card === null)
292
+ return "a card is not an object";
293
+ const c = card;
294
+ if (typeof c.id !== "string" || typeof c.boardId !== "string")
295
+ return "a card has no id / boardId";
296
+ cards.push({ id: c.id, boardId: c.boardId });
297
+ }
298
+ return { cards, total: b.cards_total };
299
+ }
421
300
  /**
422
- * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
423
- * outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
424
- * shape matches one the mint route already gives its OWN name to. Two cases:
301
+ * Prove this credential can read every board the connected plan's cards live on.
425
302
  *
426
- * - `409 session_not_connected` — the SAME shape the mint route uses for "no
427
- * plan connected" (`plan-session-stream.ts`, DX-2863 review finding M2). A
428
- * session with no plan connected is the identical terminal state whether
429
- * the dashboard says so at mint or at the stream's own admission check,
430
- * and the session should see ONE remedy either way.
431
- * - `403 issuer_cannot_read_plan_boards` — the SAME shape the mint route's
432
- * board-coverage refusal uses (AC 30660's fix 4 — a ticket minted before a
433
- * board became unreadable can still be refused ADMISSION for it later; that
434
- * refusal deserves the same `board_unreadable` naming as the mint-time one,
435
- * not a re-mint-and-retry loop that just hits the identical refusal again).
303
+ * WHY THIS EXISTS AT ALL. The stream admits an event only when the ticket
304
+ * issuer's board allowlist covers the event's board and the issuer still holds
305
+ * `read` (`isVisibleToStream` / `resolveIssuerScope`, dashboard). A credential
306
+ * short of that streams exactly as happily as a correct one and simply never
307
+ * relays those boards' events — silence that looks identical to "nothing has
308
+ * happened yet".
436
309
  *
437
- * Classifies by STATUS + ERROR CODE, never by re-deriving them from the
438
- * flattened `detail` string — the second-source-of-truth shape this card's fix
439
- * round closed (the regex-based `isSessionNotConnectedRefusal` it replaced).
310
+ * WHY THESE TWO ROUTES. `GET /api/plans/mine?fields=cards` is the only read that
311
+ * names the connected plan's cards, and `GET /api/issues/:id/problems` is a card
312
+ * read that runs the SAME board-allowlist + `read` check the stream does
313
+ * (`withV2Handler` + `resolveIssueBoardId`). Deliberately NOT `GET /api/issues`
314
+ * or `GET /api/issues/:id`: neither checks the caller's board allowlist at all,
315
+ * so a check built on them would pass precisely where the stream drops events.
440
316
  */
441
- function classifyAdmissionRefusal(refusal) {
442
- if (refusal === undefined)
443
- return null;
444
- if (refusal.status === 409 && refusal.errorCode === "session_not_connected") {
445
- return { reason: "not_connected", detail: "the session is not connected to a plan" };
317
+ export async function verifyPlanBoardsReadable(options, deps) {
318
+ const firstCardPerBoard = new Map();
319
+ let offset = 0;
320
+ for (;;) {
321
+ const url = `${options.dashboardUrl}${PLAN_MINE_PATH}?fields=cards&cards_limit=${PLAN_CARDS_PAGE_SIZE}&cards_offset=${offset}`;
322
+ const outcome = await request(options, deps, "plan read", url);
323
+ if (outcome.kind === "transient")
324
+ return outcome;
325
+ const { status, text, body } = outcome;
326
+ if (status === 409 && errorCodeOf(body) === "session_not_connected") {
327
+ return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
328
+ }
329
+ if (status === 401 || status === 403) {
330
+ return { kind: "terminal", reason: "unauthorized", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
331
+ }
332
+ if (status < 200 || status >= 300) {
333
+ return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
334
+ }
335
+ const page = cardsPageOf(body);
336
+ if (typeof page === "string") {
337
+ return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status}: ${page}` };
338
+ }
339
+ for (const card of page.cards) {
340
+ if (!firstCardPerBoard.has(card.boardId))
341
+ firstCardPerBoard.set(card.boardId, card.id);
342
+ }
343
+ offset += page.cards.length;
344
+ if (page.cards.length === 0 || offset >= page.total)
345
+ break;
446
346
  }
447
- if (refusal.status === 403 && refusal.errorCode === "issuer_cannot_read_plan_boards") {
448
- return {
449
- reason: "board_unreadable",
450
- detail: boardUnreadableDetail(boardsOf(refusal.body), "this session's dashboard credential cannot read a board on the connected plan"),
451
- };
347
+ for (const [boardId, cardId] of firstCardPerBoard) {
348
+ // `?board=` is not read by this route's auth check (it derives the board from
349
+ // `cardId`'s own `board_id` — `resolveIssueBoardId`) — kept only so the URL and
350
+ // the `board read of ${boardId}` label above it name the same board at a glance.
351
+ const url = `${options.dashboardUrl}${ISSUES_PATH}/${encodeURIComponent(cardId)}/problems?board=${encodeURIComponent(boardId)}`;
352
+ const outcome = await request(options, deps, `board read of ${boardId}`, url);
353
+ if (outcome.kind === "transient")
354
+ return outcome;
355
+ const { status, text } = outcome;
356
+ if (status === 403) {
357
+ return {
358
+ kind: "terminal",
359
+ reason: "board_unreadable",
360
+ detail: `this session's dashboard credential cannot read board ${boardId}, which the connected plan's card ` +
361
+ `${cardId} lives on — the event stream drops that board's events silently (${oneLine(text, 200)})`,
362
+ };
363
+ }
364
+ if (status === 401) {
365
+ return { kind: "terminal", reason: "unauthorized", detail: `board read of ${boardId} HTTP 401 ${oneLine(text, 200)}` };
366
+ }
367
+ if (status === 404) {
368
+ // The card moved or was deleted between the two reads. Nothing is wrong
369
+ // with the credential — re-read the plan rather than accuse it.
370
+ return { kind: "transient", detail: `card ${cardId} vanished between the plan read and the board read` };
371
+ }
372
+ if (status < 200 || status >= 300) {
373
+ return {
374
+ kind: "terminal",
375
+ reason: "scope_check_failed",
376
+ detail: `board read of ${boardId} HTTP ${status} ${oneLine(text, 200)}`,
377
+ };
378
+ }
452
379
  }
453
- return null;
380
+ return { kind: "ok", boards: [...firstCardPerBoard.keys()] };
454
381
  }
455
382
  /**
456
- * Mint, stream, re-mint — until a terminal outcome, which it writes as the one
457
- * `stopped` record. Returns 0 when another listener took over, 1 otherwise.
383
+ * Mint, verify, stream, re-mint — until a terminal outcome, which it writes as
384
+ * the one `stopped` record. Returns 0 when another listener took over, 1 otherwise.
458
385
  */
459
386
  export async function runBridge(options, deps) {
460
387
  const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
461
- function stop(reason, detail, code, extra) {
462
- // DX-2730 D4 — every terminal return goes through here, so this is the ONE
463
- // place that cancels a pending digest timer. CANCELLED, never flushed: for
464
- // `superseded`/`replaced` a newer process is already streaming this same
465
- // session and will receive (and hold, and eventually flush) these same
466
- // held events itself via the dashboard's replay — flushing here too would
467
- // double-deliver the digest. For every other terminal reason, whatever is
468
- // still held was never marked delivered (see `emitNow`), so a future
469
- // process picks it up the same way — a live, un-fired `setTimeout` would
470
- // otherwise also keep this process's event loop alive for up to
471
- // `DIGEST_INTERVAL_MS` after it should have exited.
472
- if (digestTimer !== null) {
473
- clearTimeout(digestTimer);
474
- digestTimer = null;
475
- }
476
- if (reason === "scope_narrowed") {
477
- if (extra?.boards === undefined) {
478
- throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
479
- }
480
- deps.write({ type: "stopped", reason, detail, boards: extra.boards, fix: scopeNarrowedFix(extra.boards) });
481
- return code;
482
- }
483
- deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix: extra?.fix ?? fixForStop(reason) });
388
+ const stop = (reason, detail, code) => {
389
+ deps.write({ type: "stopped", reason, detail, fix: fixForStop(reason) });
484
390
  return code;
485
- }
391
+ };
486
392
  let backoff = MINT_INITIAL_BACKOFF_MS;
487
393
  let refusedInRow = 0;
488
- let readyEmitted = false;
489
- // DX-2730 D4 — digest state, declared OUTSIDE the mint/reconnect loop below
490
- // so a re-mint (a lapsed lease, still the SAME process) never loses a
491
- // pending hold — see the module docblock for the restart case, which the
492
- // resume cursor handles instead.
493
- let held = [];
494
- let digestTimer = null;
495
- function markDelivered(id) {
496
- if (id === null)
497
- return;
498
- delivered.push(id);
499
- if (delivered.length > DELIVERED_ID_MEMORY)
500
- delivered.shift();
501
- }
502
- /** Write an event downstream at once, and mark its id delivered — never before now. */
503
- function emitNow(output) {
504
- markDelivered(output.id);
505
- deps.write(output);
506
- }
507
- /** Combine every held event into ONE record and emit it, if there is anything to combine. */
508
- function flushDigest() {
509
- if (digestTimer !== null) {
510
- clearTimeout(digestTimer);
511
- digestTimer = null;
512
- }
513
- if (held.length === 0)
514
- return;
515
- const entries = held;
516
- held = [];
517
- const ids = entries.map((e) => e.id).filter((id) => id !== null);
518
- emitNow({ type: "event", id: ids.length === 0 ? null : Math.max(...ids), text: formatDigest(entries), origin: null });
519
- }
520
- /** Every id currently held, unflushed — also fed back into the next `runListener` call's
521
- * `resumeIds` below, so a re-mint's fresh listener knows these were already seen and its
522
- * OWN duplicate guard (`listen.ts`'s `delivered` Set) silently drops a replay of one,
523
- * rather than it reaching here a second time. */
524
- function heldIds() {
525
- return held.map((e) => e.id).filter((id) => id !== null);
526
- }
527
- /**
528
- * DX-2730 D4 — the ONE place that decides immediate vs. held-for-digest.
529
- * `operator` and `null` (nudge / unreadable — see `ListenEvent.origin`'s
530
- * docblock) flush any pending digest first (never let a digest arrive AFTER
531
- * the operator event that should have superseded it) and then emit at once.
532
- * `agent` / `machine` are appended to the hold, arming the interval timer
533
- * only for the FIRST held event since the last flush — a later addition
534
- * must never push the deadline out, or the "at most every 10 minutes"
535
- * guarantee would erode into "10 minutes after the last event," which could
536
- * never fire while events keep arriving.
537
- *
538
- * DEDUPED BY ID (defense in depth): a held event's id is not added to
539
- * `delivered` until it is actually flushed (see `emitNow`), which is what
540
- * lets a crashed-and-restarted PROCESS recover it via the dashboard's own
541
- * replay. The same replay can also legitimately re-arrive WITHIN one still-
542
- * running process — a re-mint after a real `lease_expired` starts a fresh
543
- * `runListener` whose own duplicate guard is seeded from `resumeIds` alone,
544
- * and a still-held id was never in `delivered` to seed it with. `resumeIds`
545
- * below closes that for the ordinary case by handing the fresh listener
546
- * every held id too; this check is the second, cheaper layer for anything
547
- * that reaches here anyway — never trust a single guard to hold alone.
548
- */
549
- function routeEvent(output) {
550
- if (output.origin === "agent" || output.origin === "machine") {
551
- if (output.id !== null && heldIds().includes(output.id))
552
- return;
553
- held.push(output);
554
- digestTimer ??= setTimeout(flushDigest, DIGEST_INTERVAL_MS);
555
- return;
556
- }
557
- flushDigest();
558
- emitNow(output);
559
- }
394
+ let verified = false;
560
395
  for (;;) {
561
396
  const minted = await mintTicket(options, deps);
562
397
  if (minted.kind === "terminal")
@@ -566,29 +401,24 @@ export async function runBridge(options, deps) {
566
401
  backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
567
402
  continue;
568
403
  }
569
- // DX-2920 — `ready` fires once, on the FIRST successful mint, now that
570
- // board reach is proven by the dashboard's own mint refusal rather than a
571
- // client-side check run here (see the module docblock + the deleted
572
- // `verifyPlanBoardsReadable`). A re-mint (a lapsed lease) never repeats it.
573
- if (!readyEmitted) {
574
- readyEmitted = true;
575
- deps.write({ type: "ready" });
404
+ // The reach check runs on the FIRST ticket only: it is a property of the
405
+ // credential and the plan, and a re-mint (a lapsed lease) changes neither.
406
+ if (!verified) {
407
+ const scope = await verifyPlanBoardsReadable(options, deps);
408
+ if (scope.kind === "terminal")
409
+ return stop(scope.reason, scope.detail, 1);
410
+ if (scope.kind === "transient") {
411
+ await deps.sleep(backoff);
412
+ backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
413
+ continue;
414
+ }
415
+ verified = true;
416
+ deps.write({ type: "ready", boards: scope.boards });
576
417
  }
577
418
  backoff = MINT_INITIAL_BACKOFF_MS;
578
419
  const run = { stopped: null, emitted: false };
579
420
  const startedAt = deps.now();
580
- await runListener(
581
- // DX-2730 D4 — `heldIds()` rides along with `delivered`: a held-but-
582
- // not-yet-flushed event was never added to `delivered` (see `emitNow`),
583
- // so without this a fresh listener after a re-mint would not recognize
584
- // a replay of it as already-seen and would hold (and eventually
585
- // digest) it a second time. See `routeEvent`'s docblock. (If this
586
- // combined list ever exceeded `DELIVERED_ID_MEMORY`, `runListener`'s own
587
- // `slice(-DELIVERED_ID_MEMORY)` would evict the OLDEST entries first —
588
- // i.e. `delivered`'s ids before `heldIds()`'s, which is the right order:
589
- // a held id is always the more recent of the two and the one a near-
590
- // term replay is actually likely to touch.)
591
- { streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered, ...heldIds()] }, {
421
+ await runListener({ streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered] }, {
592
422
  ...deps,
593
423
  write: (output) => {
594
424
  if (output.type === "stopped") {
@@ -596,34 +426,23 @@ export async function runBridge(options, deps) {
596
426
  return;
597
427
  }
598
428
  run.emitted = true;
599
- routeEvent(output);
429
+ if (output.id !== null) {
430
+ delivered.push(output.id);
431
+ if (delivered.length > DELIVERED_ID_MEMORY)
432
+ delivered.shift();
433
+ }
434
+ deps.write(output);
600
435
  },
601
436
  });
602
437
  const stopped = run.stopped;
603
438
  if (stopped === null)
604
439
  throw new Error("the stream reader ended without a stop record");
605
- // DX-2920 (round 4) — direct equality, not `TAKEOVER_REASONS.has(...)`:
606
- // `Set.has()` is a runtime check only, so it cannot narrow `stopped.reason`
607
- // for the compiler — leaving it a `Set` lookup here would keep every
608
- // downstream branch's exhaustive narrowing (which is what makes the final
609
- // fallthrough's `stop(stopped.reason, ...)` call type-check against the
610
- // closed `ListenStopReason` overload) from working.
611
- if (stopped.reason === "superseded" || stopped.reason === "replaced")
440
+ if (TAKEOVER_REASONS.has(stopped.reason))
612
441
  return stop(stopped.reason, stopped.detail, 0);
613
442
  if (stopped.reason === "lease_expired") {
614
443
  refusedInRow = 0;
615
444
  continue;
616
445
  }
617
- if (stopped.reason === "refused") {
618
- const classified = classifyAdmissionRefusal(stopped.refusal);
619
- if (classified !== null) {
620
- // DX-2920 — a NAMED admission refusal is terminal at once, exactly
621
- // like its mint-time counterpart — never counted toward the generic
622
- // refused-ticket retry budget below, which exists for an admission
623
- // refusal with no more specific explanation than "not admitted".
624
- return stop(classified.reason, classified.detail, 1);
625
- }
626
- }
627
446
  if (stopped.reason === "refused") {
628
447
  const provedHealthy = run.emitted || deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
629
448
  refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
@@ -632,15 +451,6 @@ export async function runBridge(options, deps) {
632
451
  }
633
452
  continue;
634
453
  }
635
- if (stopped.reason === "scope_narrowed") {
636
- // DX-2920 (round 4) — `stopped.boards` is REQUIRED at the type level now
637
- // (`ListenStopped`'s `scope_narrowed` member), populated by `listen.ts`'s
638
- // parse of the dashboard's own `end` payload (never a re-derivation from
639
- // `detail`) — no `?? []`, no optional check, `stopped.boards` IS
640
- // `string[]` once narrowed to this branch.
641
- const boards = stopped.boards;
642
- return stop("scope_narrowed", boardUnreadableDetail(boards, stopped.detail), 1, { boards });
643
- }
644
454
  return stop(stopped.reason, stopped.detail, 1);
645
455
  }
646
456
  }