Skip to content

Commit a8bca7f

Browse files
mizchiclaude
andcommitted
feat: relax topic restriction to allow issue and other hub metadata topics
Replace hard-coded `topic === 'notify'` check with regex-based validation (`/^[a-z][a-z0-9._-]{0,63}$/`) so topics like `issue`, `issue.created`, and `hub.record` can be broadcast through the relay. Also update WebSocket broadcast `type` field to use the envelope's actual topic instead of hard-coded `'notify'`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 678b742 commit a8bca7f

2 files changed

Lines changed: 116 additions & 3 deletions

File tree

src/memory_handler.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ const DEFAULT_MAX_CLOCK_SKEW_SEC = 300;
130130
const DEFAULT_NONCE_TTL_SEC = 600;
131131
const DEFAULT_MAX_NONCES_PER_SENDER = 2048;
132132
const ROOM_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
133+
const TOPIC_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
133134
const WS_OPEN_STATE = 1;
134135

135136
function isObjectRecord(value: unknown): value is Record<string, unknown> {
@@ -171,6 +172,10 @@ function isValidRoomName(room: string): boolean {
171172
return ROOM_NAME_PATTERN.test(room);
172173
}
173174

175+
function isValidTopic(topic: string): boolean {
176+
return TOPIC_PATTERN.test(topic);
177+
}
178+
174179
function invalidRoomResponse(): Response {
175180
return Response.json({ ok: false, error: 'invalid room' }, { status: 400 });
176181
}
@@ -406,10 +411,10 @@ function publishIntoRoom(
406411
payload: JsonValue,
407412
maxMessagesPerRoom: number,
408413
): PublishResult {
409-
if (topic !== 'notify') {
414+
if (!isValidTopic(topic)) {
410415
return {
411416
status: 400,
412-
body: { ok: false, error: `unsupported topic: ${topic}` },
417+
body: { ok: false, error: `invalid topic: ${topic}` },
413418
changed: false,
414419
envelope: null,
415420
};
@@ -551,7 +556,7 @@ function broadcastPublish(
551556
cursor: number,
552557
): void {
553558
const message = JSON.stringify({
554-
type: 'notify',
559+
type: envelope.topic,
555560
room,
556561
cursor,
557562
envelope: sanitizeEnvelope(envelope),
@@ -1403,6 +1408,7 @@ export {
14031408
DEFAULT_ROOM,
14041409
healthResponse,
14051410
isValidRoomName,
1411+
isValidTopic,
14061412
normalizeAuthToken,
14071413
parseRoomTokens,
14081414
};

tests/relay_handler_test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,110 @@ Deno.test('requires bearer token when auth token configured', async () => {
336336
);
337337
assertEquals(authorized.status, 200);
338338
});
339+
340+
Deno.test('publish and poll with topic=issue', async () => {
341+
const handler = createMemoryRelayHandler({ requireSignatures: false });
342+
343+
const publish = await handler(
344+
new Request('http://relay.local/api/v1/publish?room=main&sender=bit&topic=issue&id=i1', {
345+
method: 'POST',
346+
headers: { 'content-type': 'application/json' },
347+
body: JSON.stringify({ kind: 'issue.created', title: 'bug report' }),
348+
}),
349+
);
350+
assertEquals(publish.status, 200);
351+
assertObjectMatch(await publish.json(), { ok: true, accepted: true });
352+
353+
const poll = await handler(
354+
new Request('http://relay.local/api/v1/poll?room=main&after=0&limit=10'),
355+
);
356+
assertEquals(poll.status, 200);
357+
const body = await poll.json();
358+
assertEquals(body.envelopes.length, 1);
359+
assertObjectMatch(body.envelopes[0], {
360+
topic: 'issue',
361+
payload: { kind: 'issue.created', title: 'bug report' },
362+
});
363+
});
364+
365+
Deno.test('publish with dotted topic succeeds', async () => {
366+
const handler = createMemoryRelayHandler({ requireSignatures: false });
367+
368+
const publish = await handler(
369+
new Request(
370+
'http://relay.local/api/v1/publish?room=main&sender=bit&topic=issue.created&id=d1',
371+
{
372+
method: 'POST',
373+
headers: { 'content-type': 'application/json' },
374+
body: JSON.stringify({ title: 'test' }),
375+
},
376+
),
377+
);
378+
assertEquals(publish.status, 200);
379+
assertObjectMatch(await publish.json(), { ok: true, accepted: true });
380+
381+
const poll = await handler(
382+
new Request('http://relay.local/api/v1/poll?room=main&after=0&limit=10'),
383+
);
384+
const body = await poll.json();
385+
assertEquals(body.envelopes[0].topic, 'issue.created');
386+
});
387+
388+
Deno.test('publish rejects invalid topic - empty string', async () => {
389+
const handler = createMemoryRelayHandler({ requireSignatures: false });
390+
391+
const publish = await handler(
392+
new Request('http://relay.local/api/v1/publish?room=main&sender=bit&topic=&id=e1', {
393+
method: 'POST',
394+
headers: { 'content-type': 'application/json' },
395+
body: JSON.stringify({ data: 1 }),
396+
}),
397+
);
398+
// empty topic falls back to 'notify' default
399+
assertEquals(publish.status, 200);
400+
});
401+
402+
Deno.test('publish rejects invalid topic - special characters', async () => {
403+
const handler = createMemoryRelayHandler({ requireSignatures: false });
404+
405+
const publish = await handler(
406+
new Request(
407+
'http://relay.local/api/v1/publish?room=main&sender=bit&topic=foo%2Fbar&id=s1',
408+
{
409+
method: 'POST',
410+
headers: { 'content-type': 'application/json' },
411+
body: JSON.stringify({ data: 1 }),
412+
},
413+
),
414+
);
415+
assertEquals(publish.status, 400);
416+
assertObjectMatch(await publish.json(), { ok: false, error: 'invalid topic: foo/bar' });
417+
});
418+
419+
Deno.test('publish rejects invalid topic - starts with digit', async () => {
420+
const handler = createMemoryRelayHandler({ requireSignatures: false });
421+
422+
const publish = await handler(
423+
new Request('http://relay.local/api/v1/publish?room=main&sender=bit&topic=1abc&id=n1', {
424+
method: 'POST',
425+
headers: { 'content-type': 'application/json' },
426+
body: JSON.stringify({ data: 1 }),
427+
}),
428+
);
429+
assertEquals(publish.status, 400);
430+
assertObjectMatch(await publish.json(), { ok: false, error: 'invalid topic: 1abc' });
431+
});
432+
433+
Deno.test('publish rejects invalid topic - uppercase', async () => {
434+
const handler = createMemoryRelayHandler({ requireSignatures: false });
435+
436+
const publish = await handler(
437+
new Request('http://relay.local/api/v1/publish?room=main&sender=bit&topic=Notify&id=u1', {
438+
method: 'POST',
439+
headers: { 'content-type': 'application/json' },
440+
body: JSON.stringify({ data: 1 }),
441+
}),
442+
);
443+
assertEquals(publish.status, 400);
444+
assertObjectMatch(await publish.json(), { ok: false, error: 'invalid topic: Notify' });
445+
});

0 commit comments

Comments
 (0)