From 4ba1d42aee1953de86dbac2cf32935569a7f7c10 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 28 Feb 2026 12:09:00 -0600 Subject: [PATCH 1/7] fix(gastown): address PR #544 review hardening items (#686) - Add .catch() error logging on fire-and-forget dispatchAgent in slingBead - Validate SDK session.create response with Zod schema instead of duck-typing - Add tests verifying registerAgent stores rig_id correctly - Store town config per-request via Hono context with Zod validation - Emit notification_failed bead events when escalation mayor notification fails - Wrap git credential write in try/catch during rig creation for resilience - Verify /api/gastown/git-credentials route exists (confirmed) - Return 403 (not 400) for cross-town access attempts via resolveTownId --- .../container/src/control-server.ts | 48 +++++-- .../container/src/process-manager.ts | 19 ++- .../src/db/tables/bead-events.table.ts | 1 + .../src/db/tables/rig-bead-events.table.ts | 1 + cloudflare-gastown/src/dos/Town.do.ts | 33 ++++- .../src/handlers/rig-agent-events.handler.ts | 20 ++- .../src/handlers/rig-agents.handler.ts | 119 ++++++++++++++---- .../src/handlers/rig-bead-events.handler.ts | 11 +- .../src/handlers/rig-beads.handler.ts | 65 +++++++--- .../src/handlers/rig-escalations.handler.ts | 11 +- .../src/handlers/rig-mail.handler.ts | 11 +- .../src/handlers/rig-molecules.handler.ts | 29 +++-- .../src/handlers/rig-review-queue.handler.ts | 20 ++- .../src/middleware/auth.middleware.ts | 26 +++- .../test/integration/rig-do.test.ts | 25 ++++ src/routers/gastown-router.ts | 48 ++++--- 16 files changed, 379 insertions(+), 108 deletions(-) diff --git a/cloudflare-gastown/container/src/control-server.ts b/cloudflare-gastown/container/src/control-server.ts index 913ed4d23a..9b974f4f5f 100644 --- a/cloudflare-gastown/container/src/control-server.ts +++ b/cloudflare-gastown/container/src/control-server.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { z } from 'zod'; import { runAgent } from './agent-runner'; import { stopAgent, @@ -24,29 +25,52 @@ import type { const MAX_TICKETS = 1000; const streamTickets = new Map(); -export const app = new Hono(); +// Minimal Zod schema for the town config delivered via X-Town-Config header. +// Uses .passthrough() so unknown keys from future schema changes are preserved. +const TownConfigHeader = z.record(z.string(), z.unknown()); -// Apply town config from X-Town-Config header (sent by TownDO on every request) -let currentTownConfig: Record | null = null; +// Last-known-good town config. Updated on every request that carries the header. +// Used as a fallback by code that runs outside a request context (e.g. background tasks). +let lastKnownTownConfig: Record | null = null; /** Get the latest town config delivered via X-Town-Config header. */ export function getCurrentTownConfig(): Record | null { - return currentTownConfig; + return lastKnownTownConfig; } +type ContainerEnv = { + Variables: { + townConfig: Record; + }; +}; + +export const app = new Hono(); + +// Parse and validate town config from X-Town-Config header (sent by TownDO on +// every request). The validated config is stored both per-request on the Hono +// context and in a module-level cache for non-request code paths. app.use('*', async (c, next) => { const configHeader = c.req.header('X-Town-Config'); if (configHeader) { try { - const parsed = JSON.parse(configHeader); - currentTownConfig = parsed; - const hasToken = - typeof parsed.kilocode_token === 'string' && parsed.kilocode_token.length > 0; - console.log( - `[control-server] X-Town-Config received: hasKilocodeToken=${hasToken} keys=${Object.keys(parsed).join(',')}` - ); + const raw: unknown = JSON.parse(configHeader); + const result = TownConfigHeader.safeParse(raw); + if (result.success) { + lastKnownTownConfig = result.data; + c.set('townConfig', result.data); + const hasToken = + typeof result.data.kilocode_token === 'string' && result.data.kilocode_token.length > 0; + console.log( + `[control-server] X-Town-Config received: hasKilocodeToken=${hasToken} keys=${Object.keys(result.data).join(',')}` + ); + } else { + console.warn( + '[control-server] X-Town-Config header failed validation:', + result.error.issues + ); + } } catch { - console.warn('[control-server] X-Town-Config header malformed'); + console.warn('[control-server] X-Town-Config header malformed (invalid JSON)'); } } await next(); diff --git a/cloudflare-gastown/container/src/process-manager.ts b/cloudflare-gastown/container/src/process-manager.ts index 86fb02aae4..bb5568a4fa 100644 --- a/cloudflare-gastown/container/src/process-manager.ts +++ b/cloudflare-gastown/container/src/process-manager.ts @@ -7,6 +7,7 @@ */ import { createOpencode, type OpencodeClient } from '@kilocode/sdk'; +import { z } from 'zod'; import type { ManagedAgent, StartAgentRequest, KiloSSEEvent, KiloSSEEventData } from './types'; import { reportAgentCompleted } from './completion-reporter'; @@ -296,11 +297,21 @@ export async function startAgent( sessionCounted = true; } - // 2. Create a session + // 2. Create a session — validate the response shape so we fail fast + // if the SDK changes its return type. + const SessionResponse = z.object({ id: z.string().min(1) }).passthrough(); const sessionResult = await client.session.create({ body: {} }); - const session = sessionResult.data ?? sessionResult; - const sessionId = - typeof session === 'object' && session && 'id' in session ? String(session.id) : ''; + const rawSession: unknown = sessionResult.data ?? sessionResult; + const parsed = SessionResponse.safeParse(rawSession); + if (!parsed.success) { + console.error( + `${MANAGER_LOG} SDK session.create returned unexpected shape:`, + JSON.stringify(rawSession).slice(0, 200), + parsed.error.issues + ); + throw new Error('SDK session.create response missing required "id" field'); + } + const sessionId = parsed.data.id; agent.sessionId = sessionId; // 3. Subscribe to events (async, runs in background) diff --git a/cloudflare-gastown/src/db/tables/bead-events.table.ts b/cloudflare-gastown/src/db/tables/bead-events.table.ts index 0612dd499b..bf2aaa4ada 100644 --- a/cloudflare-gastown/src/db/tables/bead-events.table.ts +++ b/cloudflare-gastown/src/db/tables/bead-events.table.ts @@ -9,6 +9,7 @@ export const BeadEventType = z.enum([ 'status_changed', 'closed', 'escalated', + 'notification_failed', 'mail_sent', 'review_submitted', 'review_completed', diff --git a/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts b/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts index 09c131646f..fe30d2aae7 100644 --- a/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts +++ b/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts @@ -9,6 +9,7 @@ export const BeadEventType = z.enum([ 'status_changed', 'closed', 'escalated', + 'notification_failed', 'mail_sent', 'review_submitted', 'review_completed', diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 5bf2a8ccb5..3cfe7dc92b 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -597,7 +597,9 @@ export class TownDO extends DurableObject { // Fire-and-forget dispatch so the sling call returns immediately. // The alarm loop retries if this fails. - void this.dispatchAgent(hookedAgent, bead); + this.dispatchAgent(hookedAgent, bead).catch(err => + console.error(`${TOWN_LOG} slingBead: fire-and-forget dispatchAgent failed:`, err) + ); await this.armAlarmIfNeeded(); return { bead, agent: hookedAgent }; } @@ -1072,7 +1074,19 @@ export class TownDO extends DurableObject { if (input.severity !== 'low') { this.sendMayorMessage( `[Escalation:${input.severity}] rig=${input.source_rig_id} ${input.message}` - ).catch(err => console.warn(`${TOWN_LOG} routeEscalation: failed to notify mayor:`, err)); + ).catch(err => { + console.warn(`${TOWN_LOG} routeEscalation: failed to notify mayor:`, err); + beadOps.logBeadEvent(this.sql, { + beadId, + agentId: input.source_agent_id ?? null, + eventType: 'notification_failed', + metadata: { + target: 'mayor', + reason: err instanceof Error ? err.message : String(err), + severity: input.severity, + }, + }); + }); } return escalation; @@ -1539,7 +1553,20 @@ export class TownDO extends DurableObject { if (newSeverity !== 'low') { this.sendMayorMessage( `[Re-Escalation:${newSeverity}] rig=${esc.source_rig_id} ${esc.message}` - ).catch(() => {}); + ).catch(err => { + console.warn(`${TOWN_LOG} re-escalation: failed to notify mayor:`, err); + beadOps.logBeadEvent(this.sql, { + beadId: esc.id, + agentId: null, + eventType: 'notification_failed', + metadata: { + target: 'mayor', + reason: err instanceof Error ? err.message : String(err), + severity: newSeverity, + re_escalation: true, + }, + }); + }); } } } diff --git a/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts b/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts index fc69913b37..8ddba6f9df 100644 --- a/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, getTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId, resolveTownId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; const AppendEventBody = z.object({ @@ -34,8 +34,13 @@ export async function handleAppendAgentEvent(c: Context, params: { r return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); await town.appendAgentEvent(parsed.data.agent_id, parsed.data.event_type, parsed.data.data); return c.json(resSuccess({ appended: true }), 201); @@ -58,8 +63,13 @@ export async function handleGetAgentEvents( return c.json(resError('Invalid query parameters'), 400); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const events = await town.getAgentEvents( params.agentId, diff --git a/cloudflare-gastown/src/handlers/rig-agents.handler.ts b/cloudflare-gastown/src/handlers/rig-agents.handler.ts index d86c09e16f..70983438c3 100644 --- a/cloudflare-gastown/src/handlers/rig-agents.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-agents.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getTownId } from '../middleware/auth.middleware'; +import { resolveTownId } from '../middleware/auth.middleware'; import { AgentRole, AgentStatus } from '../types'; import type { GastownEnv } from '../gastown.worker'; @@ -42,8 +42,13 @@ export async function handleRegisterAgent(c: Context, params: { rigI 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const agent = await town.registerAgent({ ...parsed.data, rig_id: params.rigId }); return c.json(resSuccess(agent), 201); @@ -58,8 +63,13 @@ export async function handleListAgents(c: Context, params: { rigId: return c.json(resError('Invalid role or status filter'), 400); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const agents = await town.listAgents({ role: role?.data, @@ -73,8 +83,13 @@ export async function handleGetAgent( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const agent = await town.getAgentAsync(params.agentId); if (!agent || agent.rig_id !== params.rigId) return c.json(resError('Agent not found'), 404); @@ -96,8 +111,13 @@ export async function handleHookBead( console.log( `${AGENT_LOG} handleHookBead: rigId=${params.rigId} agentId=${params.agentId} beadId=${parsed.data.bead_id}` ); - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); await town.hookBead(params.agentId, parsed.data.bead_id); console.log(`${AGENT_LOG} handleHookBead: hooked successfully`); @@ -108,8 +128,13 @@ export async function handleUnhookBead( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); await town.unhookBead(params.agentId); return c.json(resSuccess({ unhooked: true })); @@ -119,8 +144,13 @@ export async function handlePrime( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const context = await town.prime(params.agentId); return c.json(resSuccess(context)); @@ -137,8 +167,13 @@ export async function handleAgentDone( 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); await town.agentDone(params.agentId, parsed.data); return c.json(resSuccess({ done: true })); @@ -159,8 +194,13 @@ export async function handleAgentCompleted( 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); await town.agentCompleted(params.agentId, parsed.data); return c.json(resSuccess({ completed: true })); @@ -177,8 +217,13 @@ export async function handleWriteCheckpoint( 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); await town.writeCheckpoint(params.agentId, parsed.data.data); return c.json(resSuccess({ written: true })); @@ -188,8 +233,13 @@ export async function handleCheckMail( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const messages = await town.checkMail(params.agentId); return c.json(resSuccess(messages)); @@ -203,8 +253,13 @@ export async function handleHeartbeat( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); await town.touchAgentHeartbeat(params.agentId); return c.json(resSuccess({ heartbeat: true })); @@ -230,8 +285,13 @@ export async function handleGetOrCreateAgent(c: Context, params: { r console.log( `${AGENT_LOG} handleGetOrCreateAgent: rigId=${params.rigId} role=${parsed.data.role}` ); - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const agent = await town.getOrCreateAgent(parsed.data.role, params.rigId); console.log(`${AGENT_LOG} handleGetOrCreateAgent: result=${JSON.stringify(agent).slice(0, 200)}`); @@ -242,8 +302,13 @@ export async function handleDeleteAgent( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const agent = await town.getAgentAsync(params.agentId); if (!agent || agent.rig_id !== params.rigId) return c.json(resError('Agent not found'), 404); diff --git a/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts b/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts index c685273993..b772f0bb97 100644 --- a/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; -import { getTownId } from '../middleware/auth.middleware'; +import { resolveTownId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; export async function handleListBeadEvents(c: Context, params: { rigId: string }) { @@ -14,8 +14,13 @@ export async function handleListBeadEvents(c: Context, params: { rig ? parsedLimit : undefined; - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const events = await town.listBeadEvents({ beadId, since, limit }); return c.json(resSuccess(events)); diff --git a/cloudflare-gastown/src/handlers/rig-beads.handler.ts b/cloudflare-gastown/src/handlers/rig-beads.handler.ts index 3a078d1b54..32448cba0c 100644 --- a/cloudflare-gastown/src/handlers/rig-beads.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-beads.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, getTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId, resolveTownId } from '../middleware/auth.middleware'; import { BeadType, BeadPriority, BeadStatus } from '../types'; import type { GastownEnv } from '../gastown.worker'; @@ -43,8 +43,13 @@ export async function handleCreateBead(c: Context, params: { rigId: console.log( `${HANDLER_LOG} handleCreateBead: rigId=${params.rigId} type=${parsed.data.type} title="${parsed.data.title?.slice(0, 80)}" assignee=${parsed.data.assignee_agent_id ?? 'none'}` ); - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const bead = await town.createBead({ ...parsed.data, rig_id: params.rigId }); console.log( @@ -70,8 +75,13 @@ export async function handleListBeads(c: Context, params: { rigId: s return c.json(resError('Invalid status or type filter'), 400); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const beads = await town.listBeads({ status: status?.data, @@ -89,8 +99,13 @@ export async function handleGetBead( c: Context, params: { rigId: string; beadId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const bead = await town.getBeadAsync(params.beadId); if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); @@ -112,8 +127,13 @@ export async function handleUpdateBeadStatus( if (enforced && enforced !== parsed.data.agent_id) { return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const bead = await town.updateBeadStatus(params.beadId, parsed.data.status, parsed.data.agent_id); return c.json(resSuccess(bead)); @@ -134,8 +154,13 @@ export async function handleCloseBead( if (enforced && enforced !== parsed.data.agent_id) { return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const bead = await town.closeBead(params.beadId, parsed.data.agent_id); return c.json(resSuccess(bead)); @@ -159,8 +184,13 @@ export async function handleSlingBead(c: Context, params: { rigId: s console.log( `${HANDLER_LOG} handleSlingBead: rigId=${params.rigId} title="${parsed.data.title?.slice(0, 80)}" metadata=${JSON.stringify(parsed.data.metadata)}` ); - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const result = await town.slingBead({ ...parsed.data, rigId: params.rigId }); console.log( @@ -173,8 +203,13 @@ export async function handleDeleteBead( c: Context, params: { rigId: string; beadId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const bead = await town.getBeadAsync(params.beadId); if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); diff --git a/cloudflare-gastown/src/handlers/rig-escalations.handler.ts b/cloudflare-gastown/src/handlers/rig-escalations.handler.ts index 67b1b1eddb..3f6a83fcef 100644 --- a/cloudflare-gastown/src/handlers/rig-escalations.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-escalations.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getTownId } from '../middleware/auth.middleware'; +import { resolveTownId } from '../middleware/auth.middleware'; import { BeadPriority } from '../types'; import type { GastownEnv } from '../gastown.worker'; @@ -22,8 +22,13 @@ export async function handleCreateEscalation(c: Context, params: { r 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const escalation = await town.routeEscalation({ townId, diff --git a/cloudflare-gastown/src/handlers/rig-mail.handler.ts b/cloudflare-gastown/src/handlers/rig-mail.handler.ts index b77862da53..b078076b01 100644 --- a/cloudflare-gastown/src/handlers/rig-mail.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-mail.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, getTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId, resolveTownId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; const SendMailBody = z.object({ @@ -25,8 +25,13 @@ export async function handleSendMail(c: Context, params: { rigId: st if (enforced && enforced !== parsed.data.from_agent_id) { return c.json(resError('from_agent_id does not match authenticated agent'), 403); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); await town.sendMail(parsed.data); return c.json(resSuccess({ sent: true }), 201); diff --git a/cloudflare-gastown/src/handlers/rig-molecules.handler.ts b/cloudflare-gastown/src/handlers/rig-molecules.handler.ts index 2216b6bf59..243330fc16 100644 --- a/cloudflare-gastown/src/handlers/rig-molecules.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-molecules.handler.ts @@ -3,15 +3,20 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getTownId } from '../middleware/auth.middleware'; +import { resolveTownId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; export async function handleGetMoleculeCurrentStep( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const step = await town.getMoleculeCurrentStep(params.agentId); if (!step) return c.json(resError('No active molecule for this agent'), 404); @@ -35,8 +40,13 @@ export async function handleAdvanceMoleculeStep( ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const result = await town.advanceMoleculeStep(params.agentId, parsed.data.summary); return c.json(resSuccess(result)); @@ -66,8 +76,13 @@ export async function handleCreateMolecule(c: Context, params: { rig ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); const mol = await town.createMolecule(parsed.data.bead_id, parsed.data.formula); return c.json(resSuccess(mol), 201); diff --git a/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts b/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts index 81fff9e95e..d6330a1dd4 100644 --- a/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, getTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId, resolveTownId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; const SubmitToReviewQueueBody = z.object({ @@ -26,8 +26,13 @@ export async function handleSubmitToReviewQueue(c: Context, params: if (enforced && enforced !== parsed.data.agent_id) { return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); await town.submitToReviewQueue(parsed.data); return c.json(resSuccess({ submitted: true }), 201); @@ -50,8 +55,13 @@ export async function handleCompleteReview( 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townIdResult = resolveTownId(c); + if (townIdResult.error) + return c.json( + resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), + townIdResult.status + ); + const townId = townIdResult.townId; const town = getTownDOStub(c.env, townId); await town.completeReviewWithResult({ entry_id: params.entryId, diff --git a/cloudflare-gastown/src/middleware/auth.middleware.ts b/cloudflare-gastown/src/middleware/auth.middleware.ts index d33a6563e5..7bf69d8c87 100644 --- a/cloudflare-gastown/src/middleware/auth.middleware.ts +++ b/cloudflare-gastown/src/middleware/auth.middleware.ts @@ -76,20 +76,38 @@ export function getEnforcedAgentId(c: Context): string | null { return jwt.agentId; } +type TownIdResult = + | { townId: string; error?: undefined } + | { townId?: undefined; error: 'missing'; status: 400 } + | { townId?: undefined; error: 'forbidden'; status: 403 }; + /** * Resolve townId from the route param `:townId`, falling back to the JWT's * `townId`. When both are present, verifies they match to prevent an agent * authenticated for town A from accessing town B's data via URL manipulation. * - * Returns null if no townId is available. + * Returns a discriminated result: either the resolved townId, a 400 (no + * townId available), or a 403 (cross-town access attempt). */ -export function getTownId(c: Context): string | null { +export function resolveTownId(c: Context): TownIdResult { const fromParam = c.req.param('townId'); const jwt = c.get('agentJWT'); if (fromParam && jwt?.townId && fromParam !== jwt.townId) { - return null; + return { error: 'forbidden', status: 403 }; } - return fromParam ?? jwt?.townId ?? null; + const townId = fromParam ?? jwt?.townId; + if (!townId) return { error: 'missing', status: 400 }; + return { townId }; +} + +/** + * Convenience wrapper: resolve townId or return null. + * Preserves backward compatibility — callers that don't need to distinguish + * 400 vs 403 can keep using this. + */ +export function getTownId(c: Context): string | null { + const result = resolveTownId(c); + return result.townId ?? null; } diff --git a/cloudflare-gastown/test/integration/rig-do.test.ts b/cloudflare-gastown/test/integration/rig-do.test.ts index 6df99e6ce3..eed7a872be 100644 --- a/cloudflare-gastown/test/integration/rig-do.test.ts +++ b/cloudflare-gastown/test/integration/rig-do.test.ts @@ -106,6 +106,31 @@ describe('TownDO', () => { expect(retrieved).toMatchObject({ id: agent.id, name: 'Polecat-1' }); }); + it('should store rig_id when provided', async () => { + const rigId = `rig-${crypto.randomUUID()}`; + const agent = await town.registerAgent({ + role: 'polecat', + name: 'Polecat-RigTest', + identity: `polecat-rig-${townName}`, + rig_id: rigId, + }); + + expect(agent.rig_id).toBe(rigId); + + const retrieved = await town.getAgentAsync(agent.id); + expect(retrieved?.rig_id).toBe(rigId); + }); + + it('should store null rig_id when not provided', async () => { + const agent = await town.registerAgent({ + role: 'polecat', + name: 'Polecat-NoRig', + identity: `polecat-norig-${townName}`, + }); + + expect(agent.rig_id).toBeNull(); + }); + it('should return null for non-existent agent', async () => { const result = await town.getAgentAsync('non-existent'); expect(result).toBeNull(); diff --git a/src/routers/gastown-router.ts b/src/routers/gastown-router.ts index 09d033cac2..dd878a8c83 100644 --- a/src/routers/gastown-router.ts +++ b/src/routers/gastown-router.ts @@ -195,24 +195,38 @@ export const gastownRouter = createTRPCRouter({ // Resolve git credentials from the platform integration and store // them in the town config so agents can clone and push. + // This is a best-effort write: if it fails, the rig exists without + // git credentials. The container-side resolveGitCredentialsIfMissing + // and the refreshTownGitCredentials helper serve as recovery mechanisms. if (platformIntegrationId) { - const gitCredentials = await resolveGitCredentialsFromIntegration(platformIntegrationId); - if (gitCredentials) { - console.log( - `${LOG_PREFIX} createRig: resolved git credentials for integration=${platformIntegrationId} hasGithub=${!!gitCredentials.github_token} hasGitlab=${!!gitCredentials.gitlab_token}` - ); - await withGastownError(() => - gastown.updateTownConfig(input.townId, { - git_auth: { - ...gitCredentials, - // Store the integration ID so we can refresh tokens later - platform_integration_id: platformIntegrationId, - }, - }) - ); - } else { - console.warn( - `${LOG_PREFIX} createRig: could not resolve git credentials for integration=${platformIntegrationId}` + try { + const gitCredentials = await resolveGitCredentialsFromIntegration(platformIntegrationId); + if (gitCredentials) { + console.log( + `${LOG_PREFIX} createRig: resolved git credentials for integration=${platformIntegrationId} hasGithub=${!!gitCredentials.github_token} hasGitlab=${!!gitCredentials.gitlab_token}` + ); + await withGastownError(() => + gastown.updateTownConfig(input.townId, { + git_auth: { + ...gitCredentials, + // Store the integration ID so we can refresh tokens later + platform_integration_id: platformIntegrationId, + }, + }) + ); + } else { + console.warn( + `${LOG_PREFIX} createRig: could not resolve git credentials for integration=${platformIntegrationId}` + ); + } + } catch (credErr) { + // Rig was created successfully but git credentials could not be + // written. Log the error clearly — agents can still resolve + // credentials on-demand via the container's credential API, and + // refreshTownGitCredentials can retry later. + console.error( + `${LOG_PREFIX} createRig: rig=${rig.id} created but git credential write FAILED for integration=${platformIntegrationId}:`, + credErr ); } } From 27e154b90c9d4faa0903dc7e1d834f236066016e Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 28 Feb 2026 13:06:09 -0600 Subject: [PATCH 2/7] fix: address PR review comments - Fix misleading .passthrough() comment to accurately describe z.record() - Make townConfig type optional in ContainerEnv since header may be absent - Hoist SessionResponse Zod schema to module level to avoid repeated allocation - Wrap logBeadEvent calls in try/catch inside .catch() handlers to prevent unhandled rejections if the event log write fails --- .../container/src/control-server.ts | 4 +- .../container/src/process-manager.ts | 8 ++- cloudflare-gastown/src/dos/Town.do.ts | 56 ++++++++++++------- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/cloudflare-gastown/container/src/control-server.ts b/cloudflare-gastown/container/src/control-server.ts index 9b974f4f5f..1b0444b751 100644 --- a/cloudflare-gastown/container/src/control-server.ts +++ b/cloudflare-gastown/container/src/control-server.ts @@ -26,7 +26,7 @@ const MAX_TICKETS = 1000; const streamTickets = new Map(); // Minimal Zod schema for the town config delivered via X-Town-Config header. -// Uses .passthrough() so unknown keys from future schema changes are preserved. +// Uses z.record() so any string-keyed object is accepted and future keys are preserved. const TownConfigHeader = z.record(z.string(), z.unknown()); // Last-known-good town config. Updated on every request that carries the header. @@ -40,7 +40,7 @@ export function getCurrentTownConfig(): Record | null { type ContainerEnv = { Variables: { - townConfig: Record; + townConfig?: Record; }; }; diff --git a/cloudflare-gastown/container/src/process-manager.ts b/cloudflare-gastown/container/src/process-manager.ts index bb5568a4fa..db6af27be8 100644 --- a/cloudflare-gastown/container/src/process-manager.ts +++ b/cloudflare-gastown/container/src/process-manager.ts @@ -13,6 +13,10 @@ import { reportAgentCompleted } from './completion-reporter'; const MANAGER_LOG = '[process-manager]'; +// Validates the shape returned by client.session.create() so we fail fast +// if the SDK changes its return type. +const SessionResponse = z.object({ id: z.string().min(1) }).passthrough(); + type SDKInstance = { client: OpencodeClient; server: { url: string; close(): void }; @@ -297,9 +301,7 @@ export async function startAgent( sessionCounted = true; } - // 2. Create a session — validate the response shape so we fail fast - // if the SDK changes its return type. - const SessionResponse = z.object({ id: z.string().min(1) }).passthrough(); + // 2. Create a session const sessionResult = await client.session.create({ body: {} }); const rawSession: unknown = sessionResult.data ?? sessionResult; const parsed = SessionResponse.safeParse(rawSession); diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 3cfe7dc92b..c717ce364e 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -1076,16 +1076,23 @@ export class TownDO extends DurableObject { `[Escalation:${input.severity}] rig=${input.source_rig_id} ${input.message}` ).catch(err => { console.warn(`${TOWN_LOG} routeEscalation: failed to notify mayor:`, err); - beadOps.logBeadEvent(this.sql, { - beadId, - agentId: input.source_agent_id ?? null, - eventType: 'notification_failed', - metadata: { - target: 'mayor', - reason: err instanceof Error ? err.message : String(err), - severity: input.severity, - }, - }); + try { + beadOps.logBeadEvent(this.sql, { + beadId, + agentId: input.source_agent_id ?? null, + eventType: 'notification_failed', + metadata: { + target: 'mayor', + reason: err instanceof Error ? err.message : String(err), + severity: input.severity, + }, + }); + } catch (logErr) { + console.error( + `${TOWN_LOG} routeEscalation: failed to log notification_failed event:`, + logErr + ); + } }); } @@ -1555,17 +1562,24 @@ export class TownDO extends DurableObject { `[Re-Escalation:${newSeverity}] rig=${esc.source_rig_id} ${esc.message}` ).catch(err => { console.warn(`${TOWN_LOG} re-escalation: failed to notify mayor:`, err); - beadOps.logBeadEvent(this.sql, { - beadId: esc.id, - agentId: null, - eventType: 'notification_failed', - metadata: { - target: 'mayor', - reason: err instanceof Error ? err.message : String(err), - severity: newSeverity, - re_escalation: true, - }, - }); + try { + beadOps.logBeadEvent(this.sql, { + beadId: esc.id, + agentId: null, + eventType: 'notification_failed', + metadata: { + target: 'mayor', + reason: err instanceof Error ? err.message : String(err), + severity: newSeverity, + re_escalation: true, + }, + }); + } catch (logErr) { + console.error( + `${TOWN_LOG} re-escalation: failed to log notification_failed event:`, + logErr + ); + } }); } } From 5ebf497181bfc682ef2f9e5e7791ad4e59be8b77 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 28 Feb 2026 13:13:23 -0600 Subject: [PATCH 3/7] refactor: move townId resolution into auth middleware Eliminates the repeated 3-line resolveTownId/error-check/extract pattern from all 31 handler call sites across 8 files. The auth middleware now resolves and validates townId (returning 400 or 403 as appropriate) and sets it on the Hono context. Handlers simply call c.get('townId'). --- .../src/handlers/rig-agent-events.handler.ts | 18 +-- .../src/handlers/rig-agents.handler.ts | 105 +++--------------- .../src/handlers/rig-bead-events.handler.ts | 11 +- .../src/handlers/rig-beads.handler.ts | 58 ++-------- .../src/handlers/rig-escalations.handler.ts | 11 +- .../src/handlers/rig-mail.handler.ts | 10 +- .../src/handlers/rig-molecules.handler.ts | 25 +---- .../src/handlers/rig-review-queue.handler.ts | 18 +-- .../src/middleware/auth.middleware.ts | 26 +++-- 9 files changed, 50 insertions(+), 232 deletions(-) diff --git a/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts b/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts index 8ddba6f9df..36af7e5187 100644 --- a/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, resolveTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; const AppendEventBody = z.object({ @@ -34,13 +34,7 @@ export async function handleAppendAgentEvent(c: Context, params: { r return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.appendAgentEvent(parsed.data.agent_id, parsed.data.event_type, parsed.data.data); return c.json(resSuccess({ appended: true }), 201); @@ -63,13 +57,7 @@ export async function handleGetAgentEvents( return c.json(resError('Invalid query parameters'), 400); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const events = await town.getAgentEvents( params.agentId, diff --git a/cloudflare-gastown/src/handlers/rig-agents.handler.ts b/cloudflare-gastown/src/handlers/rig-agents.handler.ts index 70983438c3..d7e77de8cf 100644 --- a/cloudflare-gastown/src/handlers/rig-agents.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-agents.handler.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { resolveTownId } from '../middleware/auth.middleware'; import { AgentRole, AgentStatus } from '../types'; import type { GastownEnv } from '../gastown.worker'; @@ -42,13 +41,7 @@ export async function handleRegisterAgent(c: Context, params: { rigI 400 ); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const agent = await town.registerAgent({ ...parsed.data, rig_id: params.rigId }); return c.json(resSuccess(agent), 201); @@ -63,13 +56,7 @@ export async function handleListAgents(c: Context, params: { rigId: return c.json(resError('Invalid role or status filter'), 400); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const agents = await town.listAgents({ role: role?.data, @@ -83,13 +70,7 @@ export async function handleGetAgent( c: Context, params: { rigId: string; agentId: string } ) { - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const agent = await town.getAgentAsync(params.agentId); if (!agent || agent.rig_id !== params.rigId) return c.json(resError('Agent not found'), 404); @@ -111,13 +92,7 @@ export async function handleHookBead( console.log( `${AGENT_LOG} handleHookBead: rigId=${params.rigId} agentId=${params.agentId} beadId=${parsed.data.bead_id}` ); - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.hookBead(params.agentId, parsed.data.bead_id); console.log(`${AGENT_LOG} handleHookBead: hooked successfully`); @@ -128,13 +103,7 @@ export async function handleUnhookBead( c: Context, params: { rigId: string; agentId: string } ) { - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.unhookBead(params.agentId); return c.json(resSuccess({ unhooked: true })); @@ -144,13 +113,7 @@ export async function handlePrime( c: Context, params: { rigId: string; agentId: string } ) { - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const context = await town.prime(params.agentId); return c.json(resSuccess(context)); @@ -167,13 +130,7 @@ export async function handleAgentDone( 400 ); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.agentDone(params.agentId, parsed.data); return c.json(resSuccess({ done: true })); @@ -194,13 +151,7 @@ export async function handleAgentCompleted( 400 ); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.agentCompleted(params.agentId, parsed.data); return c.json(resSuccess({ completed: true })); @@ -217,13 +168,7 @@ export async function handleWriteCheckpoint( 400 ); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.writeCheckpoint(params.agentId, parsed.data.data); return c.json(resSuccess({ written: true })); @@ -233,13 +178,7 @@ export async function handleCheckMail( c: Context, params: { rigId: string; agentId: string } ) { - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const messages = await town.checkMail(params.agentId); return c.json(resSuccess(messages)); @@ -253,13 +192,7 @@ export async function handleHeartbeat( c: Context, params: { rigId: string; agentId: string } ) { - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.touchAgentHeartbeat(params.agentId); return c.json(resSuccess({ heartbeat: true })); @@ -285,13 +218,7 @@ export async function handleGetOrCreateAgent(c: Context, params: { r console.log( `${AGENT_LOG} handleGetOrCreateAgent: rigId=${params.rigId} role=${parsed.data.role}` ); - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const agent = await town.getOrCreateAgent(parsed.data.role, params.rigId); console.log(`${AGENT_LOG} handleGetOrCreateAgent: result=${JSON.stringify(agent).slice(0, 200)}`); @@ -302,13 +229,7 @@ export async function handleDeleteAgent( c: Context, params: { rigId: string; agentId: string } ) { - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const agent = await town.getAgentAsync(params.agentId); if (!agent || agent.rig_id !== params.rigId) return c.json(resError('Agent not found'), 404); diff --git a/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts b/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts index b772f0bb97..5924a5c5b1 100644 --- a/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts @@ -1,7 +1,6 @@ import type { Context } from 'hono'; import { getTownDOStub } from '../dos/Town.do'; -import { resSuccess, resError } from '../util/res.util'; -import { resolveTownId } from '../middleware/auth.middleware'; +import { resSuccess } from '../util/res.util'; import type { GastownEnv } from '../gastown.worker'; export async function handleListBeadEvents(c: Context, params: { rigId: string }) { @@ -14,13 +13,7 @@ export async function handleListBeadEvents(c: Context, params: { rig ? parsedLimit : undefined; - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const events = await town.listBeadEvents({ beadId, since, limit }); return c.json(resSuccess(events)); diff --git a/cloudflare-gastown/src/handlers/rig-beads.handler.ts b/cloudflare-gastown/src/handlers/rig-beads.handler.ts index 32448cba0c..615fe24786 100644 --- a/cloudflare-gastown/src/handlers/rig-beads.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-beads.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, resolveTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId } from '../middleware/auth.middleware'; import { BeadType, BeadPriority, BeadStatus } from '../types'; import type { GastownEnv } from '../gastown.worker'; @@ -43,13 +43,7 @@ export async function handleCreateBead(c: Context, params: { rigId: console.log( `${HANDLER_LOG} handleCreateBead: rigId=${params.rigId} type=${parsed.data.type} title="${parsed.data.title?.slice(0, 80)}" assignee=${parsed.data.assignee_agent_id ?? 'none'}` ); - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const bead = await town.createBead({ ...parsed.data, rig_id: params.rigId }); console.log( @@ -75,13 +69,7 @@ export async function handleListBeads(c: Context, params: { rigId: s return c.json(resError('Invalid status or type filter'), 400); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const beads = await town.listBeads({ status: status?.data, @@ -99,13 +87,7 @@ export async function handleGetBead( c: Context, params: { rigId: string; beadId: string } ) { - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const bead = await town.getBeadAsync(params.beadId); if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); @@ -127,13 +109,7 @@ export async function handleUpdateBeadStatus( if (enforced && enforced !== parsed.data.agent_id) { return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const bead = await town.updateBeadStatus(params.beadId, parsed.data.status, parsed.data.agent_id); return c.json(resSuccess(bead)); @@ -154,13 +130,7 @@ export async function handleCloseBead( if (enforced && enforced !== parsed.data.agent_id) { return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const bead = await town.closeBead(params.beadId, parsed.data.agent_id); return c.json(resSuccess(bead)); @@ -184,13 +154,7 @@ export async function handleSlingBead(c: Context, params: { rigId: s console.log( `${HANDLER_LOG} handleSlingBead: rigId=${params.rigId} title="${parsed.data.title?.slice(0, 80)}" metadata=${JSON.stringify(parsed.data.metadata)}` ); - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const result = await town.slingBead({ ...parsed.data, rigId: params.rigId }); console.log( @@ -203,13 +167,7 @@ export async function handleDeleteBead( c: Context, params: { rigId: string; beadId: string } ) { - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const bead = await town.getBeadAsync(params.beadId); if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); diff --git a/cloudflare-gastown/src/handlers/rig-escalations.handler.ts b/cloudflare-gastown/src/handlers/rig-escalations.handler.ts index 3f6a83fcef..219b8a1a7a 100644 --- a/cloudflare-gastown/src/handlers/rig-escalations.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-escalations.handler.ts @@ -1,9 +1,8 @@ import type { Context } from 'hono'; import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; -import { resSuccess, resError } from '../util/res.util'; +import { resSuccess } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { resolveTownId } from '../middleware/auth.middleware'; import { BeadPriority } from '../types'; import type { GastownEnv } from '../gastown.worker'; @@ -22,13 +21,7 @@ export async function handleCreateEscalation(c: Context, params: { r 400 ); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const escalation = await town.routeEscalation({ townId, diff --git a/cloudflare-gastown/src/handlers/rig-mail.handler.ts b/cloudflare-gastown/src/handlers/rig-mail.handler.ts index b078076b01..2c77d282af 100644 --- a/cloudflare-gastown/src/handlers/rig-mail.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-mail.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, resolveTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; const SendMailBody = z.object({ @@ -25,13 +25,7 @@ export async function handleSendMail(c: Context, params: { rigId: st if (enforced && enforced !== parsed.data.from_agent_id) { return c.json(resError('from_agent_id does not match authenticated agent'), 403); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.sendMail(parsed.data); return c.json(resSuccess({ sent: true }), 201); diff --git a/cloudflare-gastown/src/handlers/rig-molecules.handler.ts b/cloudflare-gastown/src/handlers/rig-molecules.handler.ts index 243330fc16..720e69b4a9 100644 --- a/cloudflare-gastown/src/handlers/rig-molecules.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-molecules.handler.ts @@ -3,20 +3,13 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { resolveTownId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; export async function handleGetMoleculeCurrentStep( c: Context, params: { rigId: string; agentId: string } ) { - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const step = await town.getMoleculeCurrentStep(params.agentId); if (!step) return c.json(resError('No active molecule for this agent'), 404); @@ -40,13 +33,7 @@ export async function handleAdvanceMoleculeStep( ); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const result = await town.advanceMoleculeStep(params.agentId, parsed.data.summary); return c.json(resSuccess(result)); @@ -76,13 +63,7 @@ export async function handleCreateMolecule(c: Context, params: { rig ); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const mol = await town.createMolecule(parsed.data.bead_id, parsed.data.formula); return c.json(resSuccess(mol), 201); diff --git a/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts b/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts index d6330a1dd4..0998a6e2fd 100644 --- a/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, resolveTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; const SubmitToReviewQueueBody = z.object({ @@ -26,13 +26,7 @@ export async function handleSubmitToReviewQueue(c: Context, params: if (enforced && enforced !== parsed.data.agent_id) { return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.submitToReviewQueue(parsed.data); return c.json(resSuccess({ submitted: true }), 201); @@ -55,13 +49,7 @@ export async function handleCompleteReview( 400 ); } - const townIdResult = resolveTownId(c); - if (townIdResult.error) - return c.json( - resError(townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'), - townIdResult.status - ); - const townId = townIdResult.townId; + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.completeReviewWithResult({ entry_id: params.entryId, diff --git a/cloudflare-gastown/src/middleware/auth.middleware.ts b/cloudflare-gastown/src/middleware/auth.middleware.ts index 7bf69d8c87..c1128c08c7 100644 --- a/cloudflare-gastown/src/middleware/auth.middleware.ts +++ b/cloudflare-gastown/src/middleware/auth.middleware.ts @@ -6,6 +6,7 @@ import type { GastownEnv } from '../gastown.worker'; export type AuthVariables = { agentJWT: AgentJWTPayload; + townId: string; }; import { resolveSecret } from '../util/secret.util'; @@ -14,7 +15,8 @@ import { resolveSecret } from '../util/secret.util'; * Auth middleware that requires a valid Gastown agent JWT via * `Authorization: Bearer `. * - * Sets `agentJWT` on the Hono context. + * Sets `agentJWT` and `townId` on the Hono context. Returns 403 if the + * JWT's townId doesn't match the route's `:townId` param (cross-town access). */ export const authMiddleware = createMiddleware(async (c, next) => { const authHeader = c.req.header('Authorization'); @@ -45,6 +47,16 @@ export const authMiddleware = createMiddleware(async (c, next) => { } c.set('agentJWT', result.payload); + + // Resolve and validate townId so handlers can use c.get('townId') directly. + const townIdResult = resolveTownId(c); + if (townIdResult.error) { + const message = + townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'; + return c.json(resError(message), townIdResult.status); + } + c.set('townId', townIdResult.townId); + return next(); }); @@ -89,7 +101,7 @@ type TownIdResult = * Returns a discriminated result: either the resolved townId, a 400 (no * townId available), or a 403 (cross-town access attempt). */ -export function resolveTownId(c: Context): TownIdResult { +function resolveTownId(c: Context): TownIdResult { const fromParam = c.req.param('townId'); const jwt = c.get('agentJWT'); @@ -101,13 +113,3 @@ export function resolveTownId(c: Context): TownIdResult { if (!townId) return { error: 'missing', status: 400 }; return { townId }; } - -/** - * Convenience wrapper: resolve townId or return null. - * Preserves backward compatibility — callers that don't need to distinguish - * 400 vs 403 can keep using this. - */ -export function getTownId(c: Context): string | null { - const result = resolveTownId(c); - return result.townId ?? null; -} From 486d19ff24fed52e087847259f02c9154f63cf32 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 28 Feb 2026 13:15:15 -0600 Subject: [PATCH 4/7] cleanup: remove dead townConfig context variable and getTownId wrapper - Remove c.set('townConfig', ...) which was set but never read by any handler - Remove ContainerEnv type that only existed for the unused variable - getTownId and resolveTownId were already cleaned up in previous commit --- cloudflare-gastown/container/src/control-server.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/cloudflare-gastown/container/src/control-server.ts b/cloudflare-gastown/container/src/control-server.ts index 1b0444b751..521f8ac0c7 100644 --- a/cloudflare-gastown/container/src/control-server.ts +++ b/cloudflare-gastown/container/src/control-server.ts @@ -38,17 +38,11 @@ export function getCurrentTownConfig(): Record | null { return lastKnownTownConfig; } -type ContainerEnv = { - Variables: { - townConfig?: Record; - }; -}; - -export const app = new Hono(); +export const app = new Hono(); // Parse and validate town config from X-Town-Config header (sent by TownDO on -// every request). The validated config is stored both per-request on the Hono -// context and in a module-level cache for non-request code paths. +// every request). The validated config is stored in a module-level cache +// accessible via getCurrentTownConfig(). app.use('*', async (c, next) => { const configHeader = c.req.header('X-Town-Config'); if (configHeader) { @@ -57,7 +51,6 @@ app.use('*', async (c, next) => { const result = TownConfigHeader.safeParse(raw); if (result.success) { lastKnownTownConfig = result.data; - c.set('townConfig', result.data); const hasToken = typeof result.data.kilocode_token === 'string' && result.data.kilocode_token.length > 0; console.log( From 4436bb0052c3af5565658e1faa61ebfeca2dd9bd Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 28 Feb 2026 15:20:04 -0600 Subject: [PATCH 5/7] fix: extract townIdMiddleware so dev mode doesn't break handlers townId resolution was inside authMiddleware which is skipped in dev mode, causing c.get('townId') to return undefined for all handlers. Split into a separate townIdMiddleware that runs unconditionally before the auth gate. --- cloudflare-gastown/src/gastown.worker.ts | 8 +++-- .../src/middleware/auth.middleware.ts | 33 ++++++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 6246f749fb..a446539892 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -6,6 +6,7 @@ import { withCloudflareAccess, validateCfAccessRequest } from './middleware/cf-a import { authMiddleware, agentOnlyMiddleware, + townIdMiddleware, type AuthVariables, } from './middleware/auth.middleware'; import { @@ -134,10 +135,13 @@ app.get('/', c => c.html(dashboardHtml())); app.get('/health', c => c.json({ status: 'ok' })); -// ── Auth ──────────────────────────────────────────────────────────────── +// ── Town ID + Auth ────────────────────────────────────────────────────── // All rig routes live under /api/towns/:townId/rigs/:rigId so the townId -// is always available from the URL path. Auth middleware skipped in dev. +// is always available from the URL path. +// townIdMiddleware always runs (even in dev) so c.get('townId') is +// guaranteed for handlers. Auth middleware is skipped in dev. +app.use('/api/towns/:townId/rigs/:rigId/*', townIdMiddleware); app.use('/api/towns/:townId/rigs/:rigId/*', async (c, next) => c.env.ENVIRONMENT === 'development' ? next() : authMiddleware(c, next) ); diff --git a/cloudflare-gastown/src/middleware/auth.middleware.ts b/cloudflare-gastown/src/middleware/auth.middleware.ts index c1128c08c7..6f1b5ba20c 100644 --- a/cloudflare-gastown/src/middleware/auth.middleware.ts +++ b/cloudflare-gastown/src/middleware/auth.middleware.ts @@ -11,12 +11,31 @@ export type AuthVariables = { import { resolveSecret } from '../util/secret.util'; +/** + * Resolves `townId` from the route param `:townId` and sets it on the Hono + * context. When the request is also authenticated (agentJWT is set), + * cross-checks the JWT's townId against the route param and returns 403 on + * mismatch. + * + * Must be applied unconditionally (even in dev) so handlers can always + * call `c.get('townId')`. + */ +export const townIdMiddleware = createMiddleware(async (c, next) => { + const townIdResult = resolveTownId(c); + if (townIdResult.error) { + const message = + townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'; + return c.json(resError(message), townIdResult.status); + } + c.set('townId', townIdResult.townId); + return next(); +}); + /** * Auth middleware that requires a valid Gastown agent JWT via * `Authorization: Bearer `. * - * Sets `agentJWT` and `townId` on the Hono context. Returns 403 if the - * JWT's townId doesn't match the route's `:townId` param (cross-town access). + * Sets `agentJWT` on the Hono context. */ export const authMiddleware = createMiddleware(async (c, next) => { const authHeader = c.req.header('Authorization'); @@ -47,16 +66,6 @@ export const authMiddleware = createMiddleware(async (c, next) => { } c.set('agentJWT', result.payload); - - // Resolve and validate townId so handlers can use c.get('townId') directly. - const townIdResult = resolveTownId(c); - if (townIdResult.error) { - const message = - townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'; - return c.json(resError(message), townIdResult.status); - } - c.set('townId', townIdResult.townId); - return next(); }); From 61aa6594ee01700f48b91fc5915b5d7660b6c881 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 28 Feb 2026 15:29:05 -0600 Subject: [PATCH 6/7] fix: move cross-town JWT check into authMiddleware where JWT is available townIdMiddleware now only extracts the route param (no JWT dependency). The cross-town check (JWT townId vs route townId) is done in authMiddleware after the JWT is parsed, alongside the existing rigId check. --- .../src/middleware/auth.middleware.ts | 56 ++++++------------- 1 file changed, 17 insertions(+), 39 deletions(-) diff --git a/cloudflare-gastown/src/middleware/auth.middleware.ts b/cloudflare-gastown/src/middleware/auth.middleware.ts index 6f1b5ba20c..7ce7a016e8 100644 --- a/cloudflare-gastown/src/middleware/auth.middleware.ts +++ b/cloudflare-gastown/src/middleware/auth.middleware.ts @@ -12,22 +12,19 @@ export type AuthVariables = { import { resolveSecret } from '../util/secret.util'; /** - * Resolves `townId` from the route param `:townId` and sets it on the Hono - * context. When the request is also authenticated (agentJWT is set), - * cross-checks the JWT's townId against the route param and returns 403 on - * mismatch. + * Extracts `townId` from the route param `:townId` and sets it on the Hono + * context. Returns 400 if the param is missing. * - * Must be applied unconditionally (even in dev) so handlers can always - * call `c.get('townId')`. + * Must run unconditionally (even in dev) so handlers can always call + * `c.get('townId')`. Does NOT check JWT — cross-town validation is handled + * by `authMiddleware` which runs after this in production. */ export const townIdMiddleware = createMiddleware(async (c, next) => { - const townIdResult = resolveTownId(c); - if (townIdResult.error) { - const message = - townIdResult.error === 'forbidden' ? 'Cross-town access denied' : 'Missing townId'; - return c.json(resError(message), townIdResult.status); + const townId = c.req.param('townId'); + if (!townId) { + return c.json(resError('Missing townId'), 400); } - c.set('townId', townIdResult.townId); + c.set('townId', townId); return next(); }); @@ -35,7 +32,8 @@ export const townIdMiddleware = createMiddleware(async (c, next) => * Auth middleware that requires a valid Gastown agent JWT via * `Authorization: Bearer `. * - * Sets `agentJWT` on the Hono context. + * Sets `agentJWT` on the Hono context. Also validates the JWT's townId + * and rigId match the route params to prevent cross-town/cross-rig access. */ export const authMiddleware = createMiddleware(async (c, next) => { const authHeader = c.req.header('Authorization'); @@ -65,6 +63,12 @@ export const authMiddleware = createMiddleware(async (c, next) => { return c.json(resError('Token rigId does not match route'), 403); } + // Verify the townId in the JWT matches the route param (cross-town guard) + const townId = c.req.param('townId'); + if (townId && result.payload.townId && townId !== result.payload.townId) { + return c.json(resError('Cross-town access denied'), 403); + } + c.set('agentJWT', result.payload); return next(); }); @@ -96,29 +100,3 @@ export function getEnforcedAgentId(c: Context): string | null { if (!jwt) return null; return jwt.agentId; } - -type TownIdResult = - | { townId: string; error?: undefined } - | { townId?: undefined; error: 'missing'; status: 400 } - | { townId?: undefined; error: 'forbidden'; status: 403 }; - -/** - * Resolve townId from the route param `:townId`, falling back to the JWT's - * `townId`. When both are present, verifies they match to prevent an agent - * authenticated for town A from accessing town B's data via URL manipulation. - * - * Returns a discriminated result: either the resolved townId, a 400 (no - * townId available), or a 403 (cross-town access attempt). - */ -function resolveTownId(c: Context): TownIdResult { - const fromParam = c.req.param('townId'); - const jwt = c.get('agentJWT'); - - if (fromParam && jwt?.townId && fromParam !== jwt.townId) { - return { error: 'forbidden', status: 403 }; - } - - const townId = fromParam ?? jwt?.townId; - if (!townId) return { error: 'missing', status: 400 }; - return { townId }; -} From 0e924661752e7459d4dd1d25103758c893b5ba6a Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 28 Feb 2026 15:50:25 -0600 Subject: [PATCH 7/7] fix: remove extra truthiness check in cross-town guard to match rigId pattern --- cloudflare-gastown/src/middleware/auth.middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudflare-gastown/src/middleware/auth.middleware.ts b/cloudflare-gastown/src/middleware/auth.middleware.ts index 7ce7a016e8..fcca98f6e7 100644 --- a/cloudflare-gastown/src/middleware/auth.middleware.ts +++ b/cloudflare-gastown/src/middleware/auth.middleware.ts @@ -65,7 +65,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { // Verify the townId in the JWT matches the route param (cross-town guard) const townId = c.req.param('townId'); - if (townId && result.payload.townId && townId !== result.payload.townId) { + if (townId && townId !== result.payload.townId) { return c.json(resError('Cross-town access denied'), 403); }