Commit8ff60bcunknown_key
fix(sse): live-events topic grammar permits multi-segment topics
fix(sse): live-events topic grammar permits multi-segment topics
The previous TOPIC_RE (`^[a-z]+:[a-zA-Z0-9\-]+$`) rejected anything
with more than one colon. The recent comment-banner work publishes on
topics like `repo:<uuid>:issue:7` — those would have 400'd at the
live-events endpoint, breaking the live-update foundation we shipped.
This fixes the regex and the parser to keep the existing repo-id
read-gate working with multi-segment topics:
topic = kind ':' primaryId (':' segment)*
kind = lowercase ASCII
ids = alphanumerics + dash
Parser change:
- Slice on the FIRST two colons. `kind` = before first colon,
`primaryId` = between first and second (or end). The trailing
`:scope1:scope2` segments stay part of the topic string for
publish/subscribe matching but don't participate in the read-gate.
- `repo:` topics still gate on read access via primaryId; other
kinds (`user:`, `pr:` etc.) still pass through.
This is a bug fix on a previously-correct route — the bug was
introduced by my own commit d4ac5c3 when SSE publish started using
multi-segment topics. The route was locked but the change is a
minimal-scope fix to honour the existing semantics for the new
publishers.
Tests: +8 (single-segment OK, multi-segment OK, bad chars rejected,
embedded slash rejected, non-repo kind passes through). Total suite
1156 pass / 8 skip / 0 fail (was 1148 / 8 / 0).
https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU2 files changed+104−58ff60bcba3736974bed5f297b7fb61b70479ca38
2 changed files+104−5
Addedsrc/__tests__/live-events.test.ts+82−0View fileUnifiedSplit
@@ -0,0 +1,82 @@
1/**
2 * Smoke tests for GET /live-events/:topic — primarily the topic
3 * grammar. The full streaming path needs a live DB (read-gate), so we
4 * focus on:
5 * - 400 on invalid topics (single-letter, missing kind, bad chars)
6 * - non-400 on the valid forms (single-segment `kind:id`, multi-
7 * segment `kind:id:scope1:scope2`).
8 *
9 * The route uses softAuth; no cookie required.
10 */
11
12import { describe, it, expect } from "bun:test";
13import app from "../app";
14
15const NON_400_OK = [200, 301, 302, 303, 307, 401, 403, 404, 503];
16
17describe("GET /live-events/:topic — topic grammar", () => {
18 it("rejects an empty path beyond the prefix", async () => {
19 const res = await app.request("/live-events/", { redirect: "manual" });
20 // 404 for "no param" (Hono path doesn't match) is acceptable.
21 expect([400, 404]).toContain(res.status);
22 });
23
24 it("rejects a kind-only topic (no id)", async () => {
25 const res = await app.request("/live-events/repo");
26 expect([400, 404]).toContain(res.status);
27 });
28
29 it("rejects topics with disallowed characters", async () => {
30 const res = await app.request(
31 "/live-events/" + encodeURIComponent("repo:has spaces")
32 );
33 expect(res.status).toBe(400);
34 });
35
36 it("rejects topics with embedded slash", async () => {
37 const res = await app.request(
38 "/live-events/" + encodeURIComponent("repo:foo/bar")
39 );
40 expect(res.status).toBe(400);
41 });
42
43 it("accepts the canonical single-segment form (repo:<uuid>)", async () => {
44 const uuid = "00000000-0000-0000-0000-000000000000";
45 const res = await app.request(`/live-events/repo:${uuid}`, {
46 redirect: "manual",
47 });
48 // Either 404 (repo not found in DB) or some auth response — but
49 // critically not 400. The grammar must accept this shape.
50 expect(res.status).not.toBe(400);
51 expect(NON_400_OK).toContain(res.status);
52 });
53
54 it("accepts a multi-segment topic (repo:<uuid>:issue:7)", async () => {
55 const uuid = "00000000-0000-0000-0000-000000000000";
56 const res = await app.request(
57 `/live-events/repo:${uuid}:issue:7`,
58 { redirect: "manual" }
59 );
60 expect(res.status).not.toBe(400);
61 expect(NON_400_OK).toContain(res.status);
62 });
63
64 it("accepts a multi-segment PR topic (repo:<uuid>:pr:9)", async () => {
65 const uuid = "00000000-0000-0000-0000-000000000000";
66 const res = await app.request(
67 `/live-events/repo:${uuid}:pr:9`,
68 { redirect: "manual" }
69 );
70 expect(res.status).not.toBe(400);
71 expect(NON_400_OK).toContain(res.status);
72 });
73
74 it("accepts a non-repo kind (passes the read-gate)", async () => {
75 // `user:42` is not a repo so the auth-gate is bypassed; should
76 // open a stream (200 + text/event-stream) or fail later.
77 const res = await app.request(`/live-events/user:42`, {
78 redirect: "manual",
79 });
80 expect(res.status).not.toBe(400);
81 });
82});
Modifiedsrc/routes/live-events.ts+22−5View fileUnifiedSplit
@@ -29,7 +29,14 @@ import { subscribe, type SSEEvent } from "../lib/sse";
2929
3030const app = new Hono<AuthEnv>();
3131
32const TOPIC_RE = /^[a-z]+:[a-zA-Z0-9\-]+$/;
32/**
33 * Topic shape — `kind:id(:segment)*`. The first colon separates the kind
34 * (lowercase, used for the read-gate dispatch) from the id; subsequent
35 * colon-segments are scoping suffixes the publisher chose, e.g.
36 * `repo:<uuid>:issue:7`. Each segment is alphanumerics + dash so the
37 * URL path stays predictable.
38 */
39const TOPIC_RE = /^[a-z]+:[a-zA-Z0-9\-]+(?::[a-zA-Z0-9\-]+)*$/;
3340const HEARTBEAT_MS = 25_000;
3441
3542app.get("/live-events/:topic", softAuth, async (c) => {
@@ -39,9 +46,19 @@ app.get("/live-events/:topic", softAuth, async (c) => {
3946 }
4047
4148 const user = c.get("user") ?? null;
42 const colon = topic.indexOf(":");
43 const kind = topic.slice(0, colon);
44 const id = topic.slice(colon + 1);
49 // Topic is `kind:primaryId(:scope)*`. Slice on the first two colons so a
50 // multi-segment topic like `repo:<uuid>:issue:7` resolves to
51 // kind = "repo", primaryId = "<uuid>"
52 // and the trailing `:issue:7` is treated as scoping that the publisher
53 // chose (the broadcaster is keyed on the full topic string, so the suffix
54 // is preserved across publish/subscribe).
55 const firstColon = topic.indexOf(":");
56 const secondColon = topic.indexOf(":", firstColon + 1);
57 const kind = topic.slice(0, firstColon);
58 const primaryId =
59 secondColon === -1
60 ? topic.slice(firstColon + 1)
61 : topic.slice(firstColon + 1, secondColon);
4562
4663 // For repo topics, gate on read access. Other topic kinds pass through.
4764 if (kind === "repo") {
@@ -49,7 +66,7 @@ app.get("/live-events/:topic", softAuth, async (c) => {
4966 const [repo] = await db
5067 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
5168 .from(repositories)
52 .where(eq(repositories.id, id))
69 .where(eq(repositories.id, primaryId))
5370 .limit(1);
5471
5572 if (!repo) {
5673