diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 119510acb..482a9c200 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -118,6 +118,7 @@ import { import { mayorAuthMiddleware } from './middleware/mayor-auth.middleware'; import { townAuthMiddleware } from './middleware/town-auth.middleware'; import { orgAuthMiddleware } from './middleware/org-auth.middleware'; +import { adminAuditMiddleware } from './middleware/admin-audit.middleware'; import { timingMiddleware, instrumented } from './middleware/analytics.middleware'; import { handleGetTownConfig, handleUpdateTownConfig } from './handlers/town-config.handler'; import { @@ -432,10 +433,12 @@ app.post('/api/towns/:townId/rigs/:rigId/triage/resolve', c => app.use('/api/users/*', async (c: Context, next) => kiloAuthMiddleware(c, next) ); -// Town routes: kilo auth + town ownership check (supports both personal and org-owned towns). +// Town routes: kilo auth + admin audit + town ownership check (supports both personal and org-owned towns). app.use('/api/towns/:townId/*', async (c: Context, next) => kiloAuthMiddleware(c, async () => { - await townAuthMiddleware(c, next); + await adminAuditMiddleware(c, async () => { + await townAuthMiddleware(c, next); + }); }) ); diff --git a/cloudflare-gastown/src/middleware/admin-audit.middleware.ts b/cloudflare-gastown/src/middleware/admin-audit.middleware.ts new file mode 100644 index 000000000..367ee70b1 --- /dev/null +++ b/cloudflare-gastown/src/middleware/admin-audit.middleware.ts @@ -0,0 +1,40 @@ +import { createMiddleware } from 'hono/factory'; +import type { GastownEnv } from '../gastown.worker'; +import { writeEvent } from '../util/analytics.util'; + +/** + * Middleware that logs admin access to town routes. + * + * Must run AFTER kiloAuthMiddleware (which sets kiloIsAdmin and kiloUserId). + * Only emits an analytics event when the request is from an admin user — + * regular user traffic is unaffected. + * + * The event is written to Cloudflare Analytics Engine with: + * - event: 'admin.town_access' + * - userId: the admin's user ID + * - townId: the town being accessed + * - route: the HTTP method + path + */ +export const adminAuditMiddleware = createMiddleware(async (c, next) => { + const isAdmin = c.get('kiloIsAdmin'); + if (!isAdmin) return next(); + + const adminUserId = c.get('kiloUserId'); + const townId = c.req.param('townId'); + const method = c.req.method; + const path = c.req.path; + + console.log( + `[admin-audit] Admin ${adminUserId} accessing town ${townId ?? 'N/A'}: ${method} ${path}` + ); + + writeEvent(c.env, { + event: 'admin.town_access', + delivery: 'http', + route: `${method} ${path}`, + userId: adminUserId, + townId: townId ?? undefined, + }); + + return next(); +}); diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index c76afd093..ef6f27a0d 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -114,16 +114,7 @@ type RigOwnerStub = { deleteTown(townId: string): Promise; }; -/** - * Core ownership resolution shared by resolveRigOwnerStub and verifyTownOwnership. - * Returns the owning DO stub and, for personal towns, the town record. - */ -async function resolveTownOwnership( - env: Env, - userId: string, - townId: string, - memberships: JwtOrgMembership[] -): Promise< +type TownOwnershipResult = | { type: 'user'; stub: RigOwnerStub; @@ -136,12 +127,30 @@ async function resolveTownOwnership( }; } | { type: 'org'; stub: RigOwnerStub; orgId: string } -> { + | { type: 'admin' }; + +/** + * Core ownership resolution shared by resolveRigOwnerStub and verifyTownOwnership. + * Returns the owning DO stub and, for personal towns, the town record. + * + * Admins bypass ownership checks entirely — they can access any town for + * support/debugging purposes. The caller is responsible for restricting + * destructive mutations (delete, billing config) when the result type is 'admin'. + */ +async function resolveTownOwnership( + env: Env, + ctx: TRPCContext, + townId: string +): Promise { + const { userId, isAdmin, orgMemberships: memberships } = ctx; + // Fast path: personal town lookup const userStub = getGastownUserStub(env, userId); const personalTown = await userStub.getTownAsync(townId); if (personalTown) { if (personalTown.owner_user_id !== userId) { + // Admin bypass: allow access without ownership + if (isAdmin) return { type: 'admin' }; throw new TRPCError({ code: 'FORBIDDEN', message: 'Not your town' }); } return { type: 'user', stub: userStub, town: personalTown }; @@ -153,12 +162,16 @@ async function resolveTownOwnership( try { config = await townStub.getTownConfig(); } catch { + // Town config failed to load — the town is deleted or invalid. + // Don't return admin bypass here; the town genuinely doesn't exist. throw new TRPCError({ code: 'NOT_FOUND', message: 'Town not found' }); } if (config.owner_type === 'org' && config.organization_id) { const membership = getOrgMembership(memberships, config.organization_id); if (!membership || membership.role === 'billing_manager') { + // Admin bypass: allow access without org membership + if (isAdmin) return { type: 'admin' }; throw new TRPCError({ code: 'FORBIDDEN', message: 'Not an org member' }); } return { @@ -168,17 +181,31 @@ async function resolveTownOwnership( }; } + // No owner found — admins can still access the town for debugging + if (isAdmin) return { type: 'admin' }; throw new TRPCError({ code: 'NOT_FOUND', message: 'Town not found' }); } /** Resolve the DO stub that owns rigs/towns. Verifies access via JWT claims. */ async function resolveRigOwnerStub( env: Env, - userId: string, - townId: string, - memberships: JwtOrgMembership[] + ctx: TRPCContext, + townId: string ): Promise { - const result = await resolveTownOwnership(env, userId, townId, memberships); + const result = await resolveTownOwnership(env, ctx, townId); + if (result.type === 'admin') { + // Admin doesn't own the town — resolve the real owner from TownDO config + // so we can still read rigs/towns from the correct DO. + const townStub = getTownDOStub(env, townId); + const config = await townStub.getTownConfig(); + if (config.owner_type === 'org' && config.organization_id) { + return getGastownOrgStub(env, config.organization_id); + } + if (config.owner_user_id) { + return getGastownUserStub(env, config.owner_user_id); + } + throw new TRPCError({ code: 'NOT_FOUND', message: 'Town owner not found' }); + } return result.stub; } @@ -186,22 +213,43 @@ async function resolveRigOwnerStub( * Verify that a user has access to a town and return a record matching * RpcTownOutput (used by the getTown procedure). */ -async function verifyTownOwnership( - env: Env, - userId: string, - townId: string, - memberships: JwtOrgMembership[] -) { - const result = await resolveTownOwnership(env, userId, townId, memberships); +async function verifyTownOwnership(env: Env, ctx: TRPCContext, townId: string) { + const result = await resolveTownOwnership(env, ctx, townId); if (result.type === 'user') return result.town; + if (result.type === 'admin') { + // Admin bypass: resolve town metadata from TownDO config and the owner's DO + const townStub = getTownDOStub(env, townId); + const config = await townStub.getTownConfig(); + + // Try to look up the town name from the owner's DO + let name = townId; + if (config.owner_type === 'org' && config.organization_id) { + const orgStub = getGastownOrgStub(env, config.organization_id); + const orgTown = await orgStub.getTownAsync(townId); + if (orgTown) name = orgTown.name; + } else if (config.owner_user_id) { + const userStub = getGastownUserStub(env, config.owner_user_id); + const userTown = await userStub.getTownAsync(townId); + if (userTown) name = userTown.name; + } + + return { + id: townId, + name, + owner_user_id: config.owner_user_id ?? ctx.userId, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + } + // Fetch the org town record for name/timestamps const orgStub = getGastownOrgStub(env, result.orgId); const orgTown = await orgStub.getTownAsync(townId); return { id: townId, name: orgTown?.name ?? townId, - owner_user_id: orgTown?.created_by_user_id ?? userId, + owner_user_id: orgTown?.created_by_user_id ?? ctx.userId, created_at: orgTown?.created_at ?? new Date().toISOString(), updated_at: orgTown?.updated_at ?? new Date().toISOString(), }; @@ -210,13 +258,14 @@ async function verifyTownOwnership( /** * Verify that a user has access to a rig — either through their personal DO * or through an org that owns the rig's town (checked via JWT claims). + * + * For admins viewing another user's town, pass `townIdHint` so the function + * can resolve the real owner from TownDO config and look up the rig in + * their DO. */ -async function verifyRigOwnership( - env: Env, - userId: string, - rigId: string, - memberships: JwtOrgMembership[] -) { +async function verifyRigOwnership(env: Env, ctx: TRPCContext, rigId: string, townIdHint?: string) { + const { userId, isAdmin, orgMemberships: memberships } = ctx; + // Fast path: personal rig lookup const userStub = getGastownUserStub(env, userId); const personalRig = await userStub.getRigAsync(rigId); @@ -232,6 +281,28 @@ async function verifyRigOwnership( if (orgRig) return orgRig; } + // Admin bypass: resolve the real owner from TownDO config so we can + // look up the rig in their DO. + if (isAdmin && townIdHint) { + const townStub = getTownDOStub(env, townIdHint); + let config; + try { + config = await townStub.getTownConfig(); + } catch { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Town not found' }); + } + + if (config.owner_type === 'org' && config.organization_id) { + const orgStub = getGastownOrgStub(env, config.organization_id); + const orgRig = await orgStub.getRigAsync(rigId); + if (orgRig) return orgRig; + } else if (config.owner_user_id) { + const ownerStub = getGastownUserStub(env, config.owner_user_id); + const ownerRig = await ownerStub.getRigAsync(rigId); + if (ownerRig) return ownerRig; + } + } + throw new TRPCError({ code: 'NOT_FOUND', message: 'Rig not found' }); } @@ -288,24 +359,64 @@ export const gastownRouter = router({ .input(z.object({ townId: z.string().uuid() })) .output(RpcTownOutput) .query(async ({ ctx, input }) => { - return verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + return verifyTownOwnership(ctx.env, ctx, input.townId); + }), + + /** + * Check whether the current user is an admin viewing a town they don't own. + * Used by the frontend to show an admin banner. + */ + checkAdminAccess: gastownProcedure + .input(z.object({ townId: z.string().uuid() })) + .output( + z.object({ + isAdminViewing: z.boolean(), + ownerUserId: z.string().nullable(), + ownerOrgId: z.string().nullable(), + }) + ) + .query(async ({ ctx, input }) => { + if (!ctx.isAdmin) return { isAdminViewing: false, ownerUserId: null, ownerOrgId: null }; + const ownership = await resolveTownOwnership(ctx.env, ctx, input.townId); + if (ownership.type === 'admin') { + // Admin is viewing a town they don't own — resolve the real owner + const townStub = getTownDOStub(ctx.env, input.townId); + const config = await townStub.getTownConfig(); + return { + isAdminViewing: true, + ownerUserId: config.owner_user_id ?? null, + ownerOrgId: config.organization_id ?? null, + }; + } + return { isAdminViewing: false, ownerUserId: null, ownerOrgId: null }; }), deleteTown: gastownProcedure .input(z.object({ townId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { - const ownership = await resolveTownOwnership( - ctx.env, - ctx.userId, - input.townId, - ctx.orgMemberships - ); + // Admins cannot delete towns — they should use admin intervention endpoints + if (ctx.isAdmin) { + const ownership = await resolveTownOwnership(ctx.env, ctx, input.townId); + if (ownership.type === 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Admins cannot delete towns they do not own', + }); + } + } + const ownership = await resolveTownOwnership(ctx.env, ctx, input.townId); if (ownership.type === 'org') { const membership = getOrgMembership(ctx.orgMemberships, ownership.orgId); if (!membership || membership.role !== 'owner') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only org owners can delete towns' }); } } + if (ownership.type === 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Admins cannot delete towns they do not own', + }); + } const ownerStub = ownership.stub; // Destroy the Town DO (agents, container, alarms, storage). @@ -333,12 +444,13 @@ export const gastownRouter = router({ .output(RpcRigOutput) .mutation(async ({ ctx, input }) => { const user = userFromCtx(ctx); - const ownership = await resolveTownOwnership( - ctx.env, - user.id, - input.townId, - ctx.orgMemberships - ); + const ownership = await resolveTownOwnership(ctx.env, ctx, input.townId); + if (ownership.type === 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Admins cannot create rigs on towns they do not own', + }); + } const ownerStub = ownership.stub; const townStub = getTownDOStub(ctx.env, input.townId); @@ -416,20 +528,15 @@ export const gastownRouter = router({ .input(z.object({ townId: z.string().uuid() })) .output(z.array(RpcRigOutput)) .query(async ({ ctx, input }) => { - const ownerStub = await resolveRigOwnerStub( - ctx.env, - ctx.userId, - input.townId, - ctx.orgMemberships - ); + const ownerStub = await resolveRigOwnerStub(ctx.env, ctx, input.townId); return ownerStub.listRigs(input.townId); }), getRig: gastownProcedure - .input(z.object({ rigId: z.string().uuid() })) + .input(z.object({ rigId: z.string().uuid(), townId: z.string().uuid().optional() })) .output(RpcRigDetailOutput) .query(async ({ ctx, input }) => { - const rig = await verifyRigOwnership(ctx.env, ctx.userId, input.rigId, ctx.orgMemberships); + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); const townStub = getTownDOStub(ctx.env, rig.town_id); // Sequential to avoid "excessively deep" type inference with Rpc.Promisified DO stubs. const agentList = await townStub.listAgents({ rig_id: rig.id }); @@ -440,13 +547,14 @@ export const gastownRouter = router({ deleteRig: gastownProcedure .input(z.object({ rigId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { - const rig = await verifyRigOwnership(ctx.env, ctx.userId, input.rigId, ctx.orgMemberships); - const ownership = await resolveTownOwnership( - ctx.env, - ctx.userId, - rig.town_id, - ctx.orgMemberships - ); + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId); + const ownership = await resolveTownOwnership(ctx.env, ctx, rig.town_id); + if (ownership.type === 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Admins cannot delete rigs on towns they do not own', + }); + } if (ownership.type === 'org') { const membership = getOrgMembership(ctx.orgMemberships, ownership.orgId); if (!membership || membership.role !== 'owner') { @@ -468,20 +576,27 @@ export const gastownRouter = router({ .input( z.object({ rigId: z.string().uuid(), + townId: z.string().uuid().optional(), status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']).optional(), }) ) .output(z.array(RpcBeadOutput)) .query(async ({ ctx, input }) => { - const rig = await verifyRigOwnership(ctx.env, ctx.userId, input.rigId, ctx.orgMemberships); + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); const townStub = getTownDOStub(ctx.env, rig.town_id); return townStub.listBeads({ rig_id: rig.id, status: input.status }); }), deleteBead: gastownProcedure - .input(z.object({ rigId: z.string().uuid(), beadId: z.string().uuid() })) + .input( + z.object({ + rigId: z.string().uuid(), + beadId: z.string().uuid(), + townId: z.string().uuid().optional(), + }) + ) .mutation(async ({ ctx, input }) => { - const rig = await verifyRigOwnership(ctx.env, ctx.userId, input.rigId, ctx.orgMemberships); + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); const townStub = getTownDOStub(ctx.env, rig.town_id); await townStub.deleteBead(input.beadId); }), @@ -492,6 +607,7 @@ export const gastownRouter = router({ .object({ rigId: z.string().uuid(), beadId: z.string().uuid(), + townId: z.string().uuid().optional(), title: z.string().min(1).optional(), body: z.string().nullable().optional(), status: z.enum(['open', 'in_progress', 'in_review', 'closed', 'failed']).optional(), @@ -516,7 +632,7 @@ export const gastownRouter = router({ ) .output(RpcBeadOutput) .mutation(async ({ ctx, input }) => { - const rig = await verifyRigOwnership(ctx.env, ctx.userId, input.rigId, ctx.orgMemberships); + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); const townStub = getTownDOStub(ctx.env, rig.town_id); // Verify the bead belongs to this rig @@ -528,25 +644,31 @@ export const gastownRouter = router({ throw new TRPCError({ code: 'FORBIDDEN', message: 'Bead does not belong to this rig' }); } - const { rigId: _rigId, beadId, ...fields } = input; + const { rigId: _rigId, beadId, townId: _townId, ...fields } = input; return townStub.updateBead(beadId, fields, ctx.userId); }), // ── Agents ────────────────────────────────────────────────────────── listAgents: gastownProcedure - .input(z.object({ rigId: z.string().uuid() })) + .input(z.object({ rigId: z.string().uuid(), townId: z.string().uuid().optional() })) .output(z.array(RpcAgentOutput)) .query(async ({ ctx, input }) => { - const rig = await verifyRigOwnership(ctx.env, ctx.userId, input.rigId, ctx.orgMemberships); + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); const townStub = getTownDOStub(ctx.env, rig.town_id); return townStub.listAgents({ rig_id: rig.id }); }), deleteAgent: gastownProcedure - .input(z.object({ rigId: z.string().uuid(), agentId: z.string().uuid() })) + .input( + z.object({ + rigId: z.string().uuid(), + agentId: z.string().uuid(), + townId: z.string().uuid().optional(), + }) + ) .mutation(async ({ ctx, input }) => { - const rig = await verifyRigOwnership(ctx.env, ctx.userId, input.rigId, ctx.orgMemberships); + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); const townStub = getTownDOStub(ctx.env, rig.town_id); await townStub.deleteAgent(input.agentId); }), @@ -565,7 +687,7 @@ export const gastownRouter = router({ .output(RpcSlingResultOutput) .mutation(async ({ ctx, input }) => { const user = userFromCtx(ctx); - const rig = await verifyRigOwnership(ctx.env, user.id, input.rigId, ctx.orgMemberships); + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId); // Best-effort: refresh git credentials using the town owner's identity const townConfig = await getTownDOStub(ctx.env, rig.town_id).getTownConfig(); @@ -605,7 +727,7 @@ export const gastownRouter = router({ ) .output(RpcMayorSendResultOutput) .mutation(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); const townStub = getTownDOStub(ctx.env, input.townId); return townStub.sendMayorMessage(input.message, input.model, input.uiContext); @@ -615,7 +737,7 @@ export const gastownRouter = router({ .input(z.object({ townId: z.string().uuid() })) .output(RpcMayorStatusOutput) .query(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); const townStub = getTownDOStub(ctx.env, input.townId); return townStub.getMayorStatus(); }), @@ -624,7 +746,7 @@ export const gastownRouter = router({ .input(z.object({ townId: z.string().uuid() })) .output(RpcAlarmStatusOutput) .query(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); const townStub = getTownDOStub(ctx.env, input.townId); return townStub.getAlarmStatus(); }), @@ -633,12 +755,7 @@ export const gastownRouter = router({ .input(z.object({ townId: z.string().uuid() })) .output(RpcMayorSendResultOutput) .mutation(async ({ ctx, input }) => { - const ownerStub = await resolveRigOwnerStub( - ctx.env, - ctx.userId, - input.townId, - ctx.orgMemberships - ); + const ownerStub = await resolveRigOwnerStub(ctx.env, ctx, input.townId); // Best-effort: refresh git credentials using the town owner's identity const townConfig = await getTownDOStub(ctx.env, input.townId).getTownConfig(); @@ -671,7 +788,7 @@ export const gastownRouter = router({ .input(z.object({ agentId: z.string().uuid(), townId: z.string().uuid() })) .output(RpcStreamTicketOutput) .query(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); // Proxy to container control server to get a stream ticket const containerStub = getTownContainerStub(ctx.env, input.townId); @@ -701,7 +818,7 @@ export const gastownRouter = router({ .input(z.object({ townId: z.string().uuid(), agentId: z.string().uuid() })) .output(RpcPtySessionOutput) .mutation(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); // Proxy to container control server to create a PTY session const containerStub = getTownContainerStub(ctx.env, input.townId); @@ -737,7 +854,7 @@ export const gastownRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); const containerStub = getTownContainerStub(ctx.env, input.townId); const response = await containerStub.fetch( @@ -762,15 +879,28 @@ export const gastownRouter = router({ .input(z.object({ townId: z.string().uuid() })) .output(RpcTownConfigSchema) .query(async ({ ctx, input }) => { - const ownership = await resolveTownOwnership( - ctx.env, - ctx.userId, - input.townId, - ctx.orgMemberships - ); + const ownership = await resolveTownOwnership(ctx.env, ctx, input.townId); const townStub = getTownDOStub(ctx.env, input.townId); const config = await townStub.getTownConfig(); + // Admins see masked secrets (read-only access, no secret exposure) + if (ownership.type === 'admin') { + const mask = (s?: string) => (s ? '****' + s.slice(-4) : undefined); + return { + ...config, + kilocode_token: mask(config.kilocode_token), + github_cli_pat: mask(config.github_cli_pat), + git_auth: { + ...config.git_auth, + github_token: mask(config.git_auth?.github_token), + gitlab_token: mask(config.git_auth?.gitlab_token), + }, + env_vars: Object.fromEntries( + Object.entries(config.env_vars).map(([k, v]) => [k, '****' + v.slice(-4)]) + ), + }; + } + // Mask secrets for non-owner, non-creator org members if (ownership.type === 'org') { const membership = getOrgMembership(ctx.orgMemberships, ownership.orgId); @@ -806,12 +936,15 @@ export const gastownRouter = router({ ) .output(RpcTownConfigSchema) .mutation(async ({ ctx, input }) => { - const ownership = await resolveTownOwnership( - ctx.env, - ctx.userId, - input.townId, - ctx.orgMemberships - ); + const ownership = await resolveTownOwnership(ctx.env, ctx, input.townId); + + // Admins cannot modify town config via this endpoint (read-only access) + if (ownership.type === 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Admins cannot modify town configuration for towns they do not own', + }); + } // Strip ownership fields — only the system (createTown flows) should set these const { @@ -854,7 +987,13 @@ export const gastownRouter = router({ refreshContainerToken: gastownProcedure .input(z.object({ townId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + const ownership = await resolveTownOwnership(ctx.env, ctx, input.townId); + if (ownership.type === 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Admins cannot refresh container tokens for towns they do not own', + }); + } const townStub = getTownDOStub(ctx.env, input.townId); await townStub.forceRefreshContainerToken(); }), @@ -865,6 +1004,7 @@ export const gastownRouter = router({ .input( z.object({ rigId: z.string().uuid(), + townId: z.string().uuid().optional(), beadId: z.string().uuid().optional(), since: z.string().optional(), limit: z.number().int().positive().max(500).default(100), @@ -872,7 +1012,7 @@ export const gastownRouter = router({ ) .output(z.array(RpcBeadEventOutput)) .query(async ({ ctx, input }) => { - const rig = await verifyRigOwnership(ctx.env, ctx.userId, input.rigId, ctx.orgMemberships); + const rig = await verifyRigOwnership(ctx.env, ctx, input.rigId, input.townId); const townStub = getTownDOStub(ctx.env, rig.town_id); return townStub.listBeadEvents({ beadId: input.beadId, @@ -891,7 +1031,7 @@ export const gastownRouter = router({ ) .output(z.array(RpcBeadEventOutput)) .query(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); const townStub = getTownDOStub(ctx.env, input.townId); return townStub.listBeadEvents({ since: input.since, @@ -910,7 +1050,7 @@ export const gastownRouter = router({ ) .output(RpcMergeQueueDataOutput) .query(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); const townStub = getTownDOStub(ctx.env, input.townId); return townStub.getMergeQueueData({ rigId: input.rigId, @@ -927,7 +1067,7 @@ export const gastownRouter = router({ ) .output(z.array(RpcConvoyDetailOutput)) .query(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); const townStub = getTownDOStub(ctx.env, input.townId); return townStub.listConvoysDetailed(); }), @@ -941,7 +1081,7 @@ export const gastownRouter = router({ ) .output(RpcConvoyDetailOutput.nullable()) .query(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); const townStub = getTownDOStub(ctx.env, input.townId); return townStub.getConvoyStatus(input.convoyId); }), @@ -955,7 +1095,7 @@ export const gastownRouter = router({ ) .output(RpcConvoyDetailOutput.nullable()) .mutation(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); const townStub = getTownDOStub(ctx.env, input.townId); const convoy = await townStub.closeConvoy(input.convoyId); if (!convoy) return null; @@ -972,7 +1112,7 @@ export const gastownRouter = router({ ) .output(RpcConvoyDetailOutput.nullable()) .mutation(async ({ ctx, input }) => { - await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + await verifyTownOwnership(ctx.env, ctx, input.townId); const townStub = getTownDOStub(ctx.env, input.townId); await townStub.startConvoy(input.convoyId); const status = await townStub.getConvoyStatus(input.convoyId); diff --git a/cloudflare-gastown/test/unit/admin-access.test.ts b/cloudflare-gastown/test/unit/admin-access.test.ts new file mode 100644 index 000000000..fe4da9fad --- /dev/null +++ b/cloudflare-gastown/test/unit/admin-access.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Hono } from 'hono'; +import type { GastownEnv } from '../../src/gastown.worker'; +import { adminAuditMiddleware } from '../../src/middleware/admin-audit.middleware'; + +// Minimal env stub for tests +function testEnv(overrides: Partial = {}): Env { + return { + ENVIRONMENT: 'test', + GASTOWN_AE: { + writeDataPoint: vi.fn(), + }, + ...overrides, + } as unknown as Env; +} + +describe('adminAuditMiddleware', () => { + function createApp() { + const app = new Hono(); + // Set up auth context variables before the audit middleware runs. + // In production, kiloAuthMiddleware does this. + app.use('/api/towns/:townId/*', async (c, next) => { + // Simulates kiloAuthMiddleware setting these values + const isAdmin = c.req.header('X-Test-Is-Admin') === 'true'; + const userId = c.req.header('X-Test-User-Id') ?? 'unknown'; + c.set('kiloIsAdmin', isAdmin); + c.set('kiloUserId', userId); + return next(); + }); + app.use('/api/towns/:townId/*', adminAuditMiddleware); + app.get('/api/towns/:townId/config', c => c.json({ ok: true, townId: c.req.param('townId') })); + return app; + } + + it('does not log for non-admin requests', async () => { + const app = createApp(); + const env = testEnv(); + const res = await app.request( + 'http://localhost/api/towns/town-123/config', + { + headers: { + 'X-Test-Is-Admin': 'false', + 'X-Test-User-Id': 'user-1', + }, + }, + env + ); + expect(res.status).toBe(200); + // @ts-expect-error -- mock function + expect(env.GASTOWN_AE.writeDataPoint).not.toHaveBeenCalled(); + }); + + it('logs admin access with correct event data', async () => { + const app = createApp(); + const env = testEnv(); + const res = await app.request( + 'http://localhost/api/towns/town-456/config', + { + headers: { + 'X-Test-Is-Admin': 'true', + 'X-Test-User-Id': 'admin-user-1', + }, + }, + env + ); + expect(res.status).toBe(200); + // @ts-expect-error -- mock function + const writeDataPoint = env.GASTOWN_AE.writeDataPoint; + expect(writeDataPoint).toHaveBeenCalledOnce(); + + const call = writeDataPoint.mock.calls[0][0]; + // blob1 = event name + expect(call.blobs[0]).toBe('admin.town_access'); + // blob2 = userId + expect(call.blobs[1]).toBe('admin-user-1'); + // blob3 = delivery + expect(call.blobs[2]).toBe('http'); + // blob4 = route (method + path) + expect(call.blobs[3]).toContain('GET'); + expect(call.blobs[3]).toContain('/api/towns/town-456/config'); + // blob6 = townId + expect(call.blobs[5]).toBe('town-456'); + }); + + it('passes through to the handler after logging', async () => { + const app = createApp(); + const env = testEnv(); + const res = await app.request( + 'http://localhost/api/towns/my-town/config', + { + headers: { + 'X-Test-Is-Admin': 'true', + 'X-Test-User-Id': 'admin-user-2', + }, + }, + env + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ ok: true, townId: 'my-town' }); + }); +}); + +// NOTE: Tests for townAuthMiddleware admin bypass require the Cloudflare +// workers runtime (cloudflare:workers) and must be run in the integration +// test environment (pnpm test:integration). The admin bypass is already +// tested by the existing townAuthMiddleware implementation: +// - townAuthMiddleware: if (c.get('kiloIsAdmin')) return next(); +// - townOwnershipMiddleware: if (c.get('kiloIsAdmin')) return next(); diff --git a/packages/trpc/dist/index.d.ts b/packages/trpc/dist/index.d.ts index 928a12eb2..fee524e77 100644 --- a/packages/trpc/dist/index.d.ts +++ b/packages/trpc/dist/index.d.ts @@ -3394,6 +3394,7 @@ type AdminKiloclawInstance = { sandbox_id: string; created_at: string; destroyed_at: string | null; + suspended_at: string | null; user_email: string | null; }; @@ -8068,6 +8069,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ expiresAt: string; daysRemaining: number; } | null; + activeInstanceId: string | null; }; meta: object; }>; @@ -9761,6 +9763,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ sandbox_id: string; created_at: string; destroyed_at: string | null; + suspended_at: string | null; user_email: string | null; }; meta: object; @@ -9772,7 +9775,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ sortBy?: "created_at" | "destroyed_at" | undefined; sortOrder?: "asc" | "desc" | undefined; search?: string | undefined; - status?: "active" | "all" | "destroyed" | undefined; + status?: "active" | "all" | "suspended" | "destroyed" | undefined; }; output: { instances: AdminKiloclawInstance[]; @@ -9793,6 +9796,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ overview: { totalInstances: number; activeInstances: number; + suspendedInstances: number; destroyedInstances: number; uniqueUsers: number; last24hCreated: number; @@ -9941,7 +9945,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ message: string; id: string; created_at: string; - action: "kiloclaw.volume.reassociate" | "kiloclaw.subscription.update_trial_end" | "kiloclaw.machine.start" | "kiloclaw.machine.stop" | "kiloclaw.instance.destroy" | "kiloclaw.gateway.start" | "kiloclaw.gateway.stop" | "kiloclaw.gateway.restart" | "kiloclaw.config.restore" | "kiloclaw.doctor.run"; + action: "kiloclaw.volume.reassociate" | "kiloclaw.subscription.update_trial_end" | "kiloclaw.subscription.reset_trial" | "kiloclaw.machine.start" | "kiloclaw.machine.stop" | "kiloclaw.instance.destroy" | "kiloclaw.gateway.start" | "kiloclaw.gateway.stop" | "kiloclaw.gateway.restart" | "kiloclaw.config.restore" | "kiloclaw.doctor.run"; actor_id: string | null; actor_email: string | null; actor_name: string | null; @@ -10530,7 +10534,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ list: _trpc_server.TRPCQueryProcedure<{ input: { page?: number | undefined; - limit?: 10 | 100 | 50 | 25 | undefined; + limit?: 100 | 50 | 10 | 25 | undefined; }; output: { requests: { @@ -10545,7 +10549,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ }[]; pagination: { page: number; - limit: 10 | 100 | 50 | 25; + limit: 100 | 50 | 10 | 25; total: number; totalPages: number; }; @@ -11515,7 +11519,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ input: { cursor?: string | undefined; limit?: number | undefined; - createdOnPlatform?: string | undefined; + createdOnPlatform?: string | string[] | undefined; orderBy?: "updated_at" | "created_at" | undefined; organizationId?: string | null | undefined; }; @@ -11543,7 +11547,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ search_string: string; limit?: number | undefined; offset?: number | undefined; - createdOnPlatform?: string | undefined; + createdOnPlatform?: string | string[] | undefined; organizationId?: string | null | undefined; }; output: { @@ -15753,7 +15757,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ input: { cursor?: string | undefined; limit?: number | undefined; - createdOnPlatform?: string | undefined; + createdOnPlatform?: string | string[] | undefined; orderBy?: "updated_at" | "created_at" | undefined; organizationId?: string | null | undefined; includeSubSessions?: boolean | undefined; @@ -15784,7 +15788,7 @@ declare const rootRouter: _trpc_server.TRPCBuiltRouter<{ search_string: string; limit?: number | undefined; offset?: number | undefined; - createdOnPlatform?: string | undefined; + createdOnPlatform?: string | string[] | undefined; organizationId?: string | null | undefined; includeSubSessions?: boolean | undefined; }; diff --git a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx index a9b80175b..d7ae5f455 100644 --- a/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx @@ -34,6 +34,7 @@ import { formatDistanceToNow } from 'date-fns'; import { AreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { motion, AnimatePresence } from 'motion/react'; import type { GastownOutputs } from '@/lib/gastown/trpc'; +import { AdminViewingBanner } from '@/components/gastown/AdminViewingBanner'; type Agent = GastownOutputs['gastown']['listAgents'][number]; @@ -211,6 +212,7 @@ export function TownOverviewPageClient({ return (
+ {/* Top bar — sticky */}
diff --git a/src/app/(app)/gastown/[townId]/agents/page.tsx b/src/app/(app)/gastown/[townId]/agents/page.tsx index 5cd1158e1..f39570538 100644 --- a/src/app/(app)/gastown/[townId]/agents/page.tsx +++ b/src/app/(app)/gastown/[townId]/agents/page.tsx @@ -8,6 +8,6 @@ export default async function AgentsPage({ params }: { params: Promise<{ townId: const user = await getUserFromAuthOrRedirect( `/users/sign_in?callbackPath=/gastown/${townId}/agents` ); - if (!(await isGastownEnabled(user.id))) return notFound(); + if (!(await isGastownEnabled(user.id, { isAdmin: user.is_admin }))) return notFound(); return ; } diff --git a/src/app/(app)/gastown/[townId]/beads/page.tsx b/src/app/(app)/gastown/[townId]/beads/page.tsx index d2073cf70..6961e3587 100644 --- a/src/app/(app)/gastown/[townId]/beads/page.tsx +++ b/src/app/(app)/gastown/[townId]/beads/page.tsx @@ -8,6 +8,6 @@ export default async function BeadsPage({ params }: { params: Promise<{ townId: const user = await getUserFromAuthOrRedirect( `/users/sign_in?callbackPath=/gastown/${townId}/beads` ); - if (!(await isGastownEnabled(user.id))) return notFound(); + if (!(await isGastownEnabled(user.id, { isAdmin: user.is_admin }))) return notFound(); return ; } diff --git a/src/app/(app)/gastown/[townId]/mail/page.tsx b/src/app/(app)/gastown/[townId]/mail/page.tsx index 9e9649983..47cd88012 100644 --- a/src/app/(app)/gastown/[townId]/mail/page.tsx +++ b/src/app/(app)/gastown/[townId]/mail/page.tsx @@ -8,6 +8,6 @@ export default async function MailPage({ params }: { params: Promise<{ townId: s const user = await getUserFromAuthOrRedirect( `/users/sign_in?callbackPath=/gastown/${townId}/mail` ); - if (!(await isGastownEnabled(user.id))) return notFound(); + if (!(await isGastownEnabled(user.id, { isAdmin: user.is_admin }))) return notFound(); return ; } diff --git a/src/app/(app)/gastown/[townId]/merges/page.tsx b/src/app/(app)/gastown/[townId]/merges/page.tsx index 5f725426b..ad3d3ae1f 100644 --- a/src/app/(app)/gastown/[townId]/merges/page.tsx +++ b/src/app/(app)/gastown/[townId]/merges/page.tsx @@ -8,6 +8,6 @@ export default async function MergesPage({ params }: { params: Promise<{ townId: const user = await getUserFromAuthOrRedirect( `/users/sign_in?callbackPath=/gastown/${townId}/merges` ); - if (!(await isGastownEnabled(user.id))) return notFound(); + if (!(await isGastownEnabled(user.id, { isAdmin: user.is_admin }))) return notFound(); return ; } diff --git a/src/app/(app)/gastown/[townId]/observability/page.tsx b/src/app/(app)/gastown/[townId]/observability/page.tsx index 15acd063b..6cbac090c 100644 --- a/src/app/(app)/gastown/[townId]/observability/page.tsx +++ b/src/app/(app)/gastown/[townId]/observability/page.tsx @@ -12,6 +12,6 @@ export default async function ObservabilityPage({ const user = await getUserFromAuthOrRedirect( `/users/sign_in?callbackPath=/gastown/${townId}/observability` ); - if (!(await isGastownEnabled(user.id))) return notFound(); + if (!(await isGastownEnabled(user.id, { isAdmin: user.is_admin }))) return notFound(); return ; } diff --git a/src/app/(app)/gastown/[townId]/page.tsx b/src/app/(app)/gastown/[townId]/page.tsx index e72bc6cf3..7690d1c39 100644 --- a/src/app/(app)/gastown/[townId]/page.tsx +++ b/src/app/(app)/gastown/[townId]/page.tsx @@ -11,7 +11,7 @@ export default async function TownOverviewPage({ const { townId } = await params; const user = await getUserFromAuthOrRedirect(`/users/sign_in?callbackPath=/gastown/${townId}`); - if (!(await isGastownEnabled(user.id))) { + if (!(await isGastownEnabled(user.id, { isAdmin: user.is_admin }))) { return notFound(); } diff --git a/src/app/(app)/gastown/[townId]/rigs/[rigId]/page.tsx b/src/app/(app)/gastown/[townId]/rigs/[rigId]/page.tsx index 275f238c1..0247fcc13 100644 --- a/src/app/(app)/gastown/[townId]/rigs/[rigId]/page.tsx +++ b/src/app/(app)/gastown/[townId]/rigs/[rigId]/page.tsx @@ -13,7 +13,7 @@ export default async function RigDetailPage({ `/users/sign_in?callbackPath=/gastown/${townId}/rigs/${rigId}` ); - if (!(await isGastownEnabled(user.id))) { + if (!(await isGastownEnabled(user.id, { isAdmin: user.is_admin }))) { return notFound(); } diff --git a/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx b/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx index 49e48d5f9..ff3bb3b01 100644 --- a/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx @@ -29,6 +29,7 @@ import { Key, } from 'lucide-react'; import { motion } from 'motion/react'; +import { AdminViewingBanner } from '@/components/gastown/AdminViewingBanner'; type Props = { townId: string; readOnly?: boolean }; @@ -103,8 +104,12 @@ export function TownSettingsPageClient({ townId, readOnly = false }: Props) { const townQuery = useQuery(trpc.gastown.getTown.queryOptions({ townId })); const configQuery = useQuery(trpc.gastown.getTownConfig.queryOptions({ townId })); + const adminAccessQuery = useQuery(trpc.gastown.checkAdminAccess.queryOptions({ townId })); - const effectiveReadOnly = readOnly && currentUser?.id !== configQuery.data?.created_by_user_id; + // Admin viewing another user's town → force read-only + const isAdminViewing = adminAccessQuery.data?.isAdminViewing ?? false; + const effectiveReadOnly = + isAdminViewing || (readOnly && currentUser?.id !== configQuery.data?.created_by_user_id); const updateConfig = useMutation( trpc.gastown.updateTownConfig.mutationOptions({ @@ -245,6 +250,7 @@ export function TownSettingsPageClient({ townId, readOnly = false }: Props) { return (
+ {/* Top bar */}
; } diff --git a/src/app/(app)/gastown/page.tsx b/src/app/(app)/gastown/page.tsx index 837ac070e..ab0f364fd 100644 --- a/src/app/(app)/gastown/page.tsx +++ b/src/app/(app)/gastown/page.tsx @@ -6,7 +6,7 @@ import { TownListPageClient } from './TownListPageClient'; export default async function GastownPage() { const user = await getUserFromAuthOrRedirect('/users/sign_in?callbackPath=/gastown'); - if (!(await isGastownEnabled(user.id))) { + if (!(await isGastownEnabled(user.id, { isAdmin: user.is_admin }))) { return notFound(); } diff --git a/src/app/admin/components/UserAdmin/UserAdminGastown.tsx b/src/app/admin/components/UserAdmin/UserAdminGastown.tsx index 27eb0f520..4068fc3c9 100644 --- a/src/app/admin/components/UserAdmin/UserAdminGastown.tsx +++ b/src/app/admin/components/UserAdmin/UserAdminGastown.tsx @@ -116,11 +116,14 @@ function TownRow({ town }: { town: { id: string; name: string; created_at: strin
+
- +
+ + +
diff --git a/src/components/gastown/AdminViewingBanner.tsx b/src/components/gastown/AdminViewingBanner.tsx new file mode 100644 index 000000000..00f2bdda3 --- /dev/null +++ b/src/components/gastown/AdminViewingBanner.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useGastownTRPC } from '@/lib/gastown/trpc'; +import { ShieldAlert } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; + +/** + * Banner displayed when a Kilo admin is viewing a town they don't own. + * Fetches admin access status via the checkAdminAccess tRPC query. + * Renders nothing for non-admin users or when viewing their own town. + */ +export function AdminViewingBanner({ townId }: { townId: string }) { + const trpc = useGastownTRPC(); + const { data } = useQuery(trpc.gastown.checkAdminAccess.queryOptions({ townId })); + + if (!data?.isAdminViewing) return null; + + return ( + + + Viewing as admin + + This town belongs to{' '} + {data.ownerOrgId ? ( + <> + org{' '} + + {data.ownerOrgId} + + + ) : data.ownerUserId ? ( + <> + user{' '} + + {data.ownerUserId} + + + ) : ( + 'another user' + )} + . Changes to settings and destructive actions are restricted. + + + ); +} diff --git a/src/lib/gastown/feature-flags.ts b/src/lib/gastown/feature-flags.ts index 418455680..0b2ea58c1 100644 --- a/src/lib/gastown/feature-flags.ts +++ b/src/lib/gastown/feature-flags.ts @@ -11,9 +11,15 @@ const GASTOWN_ACCESS_FLAG = 'gastown-access'; * (allowlists, percentage rollout, and kill-switch are managed in the * PostHog dashboard). * + * Kilo admins always have access regardless of the feature flag. + * * See #901 for details. */ -export async function isGastownEnabled(userId: string): Promise { +export async function isGastownEnabled( + userId: string, + opts?: { isAdmin?: boolean } +): Promise { + if (opts?.isAdmin) return true; if (process.env.NODE_ENV !== 'production') { return true; } diff --git a/src/lib/gastown/types/router.d.ts b/src/lib/gastown/types/router.d.ts index 591ea8bef..91212a7cf 100644 --- a/src/lib/gastown/types/router.d.ts +++ b/src/lib/gastown/types/router.d.ts @@ -44,6 +44,21 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< }; meta: object; }>; + /** + * Check whether the current user is an admin viewing a town they don't own. + * Used by the frontend to show an admin banner. + */ + checkAdminAccess: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + }; + output: { + isAdminViewing: boolean; + ownerUserId: string | null; + ownerOrgId: string | null; + }; + meta: object; + }>; deleteTown: import('@trpc/server').TRPCMutationProcedure<{ input: { townId: string; @@ -90,6 +105,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< getRig: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; + townId?: string | undefined; }; output: { id: string; @@ -152,6 +168,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< listBeads: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; + townId?: string | undefined; status?: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open' | undefined; }; output: { @@ -184,6 +201,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< input: { rigId: string; beadId: string; + townId?: string | undefined; }; output: void; meta: object; @@ -192,6 +210,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< input: { rigId: string; beadId: string; + townId?: string | undefined; title?: string | undefined; body?: string | null | undefined; status?: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open' | undefined; @@ -230,6 +249,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< listAgents: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; + townId?: string | undefined; }; output: { id: string; @@ -252,6 +272,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< input: { rigId: string; agentId: string; + townId?: string | undefined; }; output: void; meta: object; @@ -312,6 +333,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< message: string; model?: string | undefined; rigId?: string | undefined; + uiContext?: string | undefined; }; output: { agentId: string; @@ -432,7 +454,10 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< platform_integration_id?: string | undefined; }; owner_user_id?: string | undefined; + owner_type: 'org' | 'user'; + owner_id?: string | undefined; created_by_user_id?: string | undefined; + organization_id?: string | undefined; kilocode_token?: string | undefined; default_model?: string | undefined; small_model?: string | undefined; @@ -474,6 +499,10 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< } | undefined; owner_user_id?: string | undefined; + owner_type?: 'org' | 'user' | undefined; + owner_id?: string | undefined; + created_by_user_id?: string | undefined; + organization_id?: string | undefined; kilocode_token?: string | undefined; default_model?: string | undefined; small_model?: string | undefined; @@ -509,6 +538,10 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< platform_integration_id?: string | undefined; }; owner_user_id?: string | undefined; + owner_type: 'org' | 'user'; + owner_id?: string | undefined; + created_by_user_id?: string | undefined; + organization_id?: string | undefined; kilocode_token?: string | undefined; default_model?: string | undefined; small_model?: string | undefined; @@ -529,6 +562,10 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< } | undefined; staged_convoys_default: boolean; + github_cli_pat?: string | undefined; + git_author_name?: string | undefined; + git_author_email?: string | undefined; + disable_ai_coauthor: boolean; }; meta: object; }>; @@ -542,6 +579,7 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< getBeadEvents: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; + townId?: string | undefined; beadId?: string | undefined; since?: string | undefined; limit?: number | undefined; @@ -884,6 +922,81 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< } | null; meta: object; }>; + listOrgTowns: import('@trpc/server').TRPCQueryProcedure<{ + input: { + organizationId: string; + }; + output: { + id: string; + name: string; + owner_org_id: string; + created_by_user_id: string; + created_at: string; + updated_at: string; + }[]; + meta: object; + }>; + createOrgTown: import('@trpc/server').TRPCMutationProcedure<{ + input: { + organizationId: string; + name: string; + }; + output: { + id: string; + name: string; + owner_org_id: string; + created_by_user_id: string; + created_at: string; + updated_at: string; + }; + meta: object; + }>; + deleteOrgTown: import('@trpc/server').TRPCMutationProcedure<{ + input: { + organizationId: string; + townId: string; + }; + output: void; + meta: object; + }>; + listOrgRigs: import('@trpc/server').TRPCQueryProcedure<{ + input: { + organizationId: string; + townId: string; + }; + output: { + id: string; + town_id: string; + name: string; + git_url: string; + default_branch: string; + platform_integration_id: string | null; + created_at: string; + updated_at: string; + }[]; + meta: object; + }>; + createOrgRig: import('@trpc/server').TRPCMutationProcedure<{ + input: { + organizationId: string; + townId: string; + name: string; + gitUrl: string; + defaultBranch?: string | undefined; + platformIntegrationId?: string | undefined; + }; + output: { + id: string; + town_id: string; + name: string; + git_url: string; + default_branch: string; + platform_integration_id: string | null; + created_at: string; + updated_at: string; + }; + meta: object; + }>; adminListBeads: import('@trpc/server').TRPCQueryProcedure<{ input: { townId: string; @@ -1113,79 +1226,11 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< } | null; meta: object; }>; - listOrgTowns: import('@trpc/server').TRPCQueryProcedure<{ - input: { - organizationId: string; - }; - output: { - id: string; - name: string; - owner_org_id: string; - created_by_user_id: string; - created_at: string; - updated_at: string; - }[]; - meta: object; - }>; - createOrgTown: import('@trpc/server').TRPCMutationProcedure<{ - input: { - organizationId: string; - name: string; - }; - output: { - id: string; - name: string; - owner_org_id: string; - created_by_user_id: string; - created_at: string; - updated_at: string; - }; - meta: object; - }>; - deleteOrgTown: import('@trpc/server').TRPCMutationProcedure<{ - input: { - organizationId: string; - townId: string; - }; - output: void; - meta: object; - }>; - listOrgRigs: import('@trpc/server').TRPCQueryProcedure<{ + debugAgentMetadata: import('@trpc/server').TRPCQueryProcedure<{ input: { - organizationId: string; townId: string; }; - output: { - id: string; - town_id: string; - name: string; - git_url: string; - default_branch: string; - platform_integration_id: string | null; - created_at: string; - updated_at: string; - }[]; - meta: object; - }>; - createOrgRig: import('@trpc/server').TRPCMutationProcedure<{ - input: { - organizationId: string; - townId: string; - name: string; - gitUrl: string; - defaultBranch?: string | undefined; - platformIntegrationId?: string | undefined; - }; - output: { - id: string; - town_id: string; - name: string; - git_url: string; - default_branch: string; - platform_integration_id: string | null; - created_at: string; - updated_at: string; - }; + output: never; meta: object; }>; }> @@ -1250,6 +1295,21 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute }; meta: object; }>; + /** + * Check whether the current user is an admin viewing a town they don't own. + * Used by the frontend to show an admin banner. + */ + checkAdminAccess: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + }; + output: { + isAdminViewing: boolean; + ownerUserId: string | null; + ownerOrgId: string | null; + }; + meta: object; + }>; deleteTown: import('@trpc/server').TRPCMutationProcedure<{ input: { townId: string; @@ -1296,6 +1356,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute getRig: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; + townId?: string | undefined; }; output: { id: string; @@ -1358,6 +1419,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute listBeads: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; + townId?: string | undefined; status?: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open' | undefined; }; output: { @@ -1390,6 +1452,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute input: { rigId: string; beadId: string; + townId?: string | undefined; }; output: void; meta: object; @@ -1398,6 +1461,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute input: { rigId: string; beadId: string; + townId?: string | undefined; title?: string | undefined; body?: string | null | undefined; status?: 'closed' | 'failed' | 'in_progress' | 'in_review' | 'open' | undefined; @@ -1436,6 +1500,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute listAgents: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; + townId?: string | undefined; }; output: { id: string; @@ -1458,6 +1523,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute input: { rigId: string; agentId: string; + townId?: string | undefined; }; output: void; meta: object; @@ -1518,6 +1584,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute message: string; model?: string | undefined; rigId?: string | undefined; + uiContext?: string | undefined; }; output: { agentId: string; @@ -1638,7 +1705,10 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute platform_integration_id?: string | undefined; }; owner_user_id?: string | undefined; + owner_type: 'org' | 'user'; + owner_id?: string | undefined; created_by_user_id?: string | undefined; + organization_id?: string | undefined; kilocode_token?: string | undefined; default_model?: string | undefined; small_model?: string | undefined; @@ -1680,6 +1750,10 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute } | undefined; owner_user_id?: string | undefined; + owner_type?: 'org' | 'user' | undefined; + owner_id?: string | undefined; + created_by_user_id?: string | undefined; + organization_id?: string | undefined; kilocode_token?: string | undefined; default_model?: string | undefined; small_model?: string | undefined; @@ -1715,6 +1789,10 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute platform_integration_id?: string | undefined; }; owner_user_id?: string | undefined; + owner_type: 'org' | 'user'; + owner_id?: string | undefined; + created_by_user_id?: string | undefined; + organization_id?: string | undefined; kilocode_token?: string | undefined; default_model?: string | undefined; small_model?: string | undefined; @@ -1735,6 +1813,10 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute } | undefined; staged_convoys_default: boolean; + github_cli_pat?: string | undefined; + git_author_name?: string | undefined; + git_author_email?: string | undefined; + disable_ai_coauthor: boolean; }; meta: object; }>; @@ -1748,6 +1830,7 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute getBeadEvents: import('@trpc/server').TRPCQueryProcedure<{ input: { rigId: string; + townId?: string | undefined; beadId?: string | undefined; since?: string | undefined; limit?: number | undefined; @@ -2090,6 +2173,81 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute } | null; meta: object; }>; + listOrgTowns: import('@trpc/server').TRPCQueryProcedure<{ + input: { + organizationId: string; + }; + output: { + id: string; + name: string; + owner_org_id: string; + created_by_user_id: string; + created_at: string; + updated_at: string; + }[]; + meta: object; + }>; + createOrgTown: import('@trpc/server').TRPCMutationProcedure<{ + input: { + organizationId: string; + name: string; + }; + output: { + id: string; + name: string; + owner_org_id: string; + created_by_user_id: string; + created_at: string; + updated_at: string; + }; + meta: object; + }>; + deleteOrgTown: import('@trpc/server').TRPCMutationProcedure<{ + input: { + organizationId: string; + townId: string; + }; + output: void; + meta: object; + }>; + listOrgRigs: import('@trpc/server').TRPCQueryProcedure<{ + input: { + organizationId: string; + townId: string; + }; + output: { + id: string; + town_id: string; + name: string; + git_url: string; + default_branch: string; + platform_integration_id: string | null; + created_at: string; + updated_at: string; + }[]; + meta: object; + }>; + createOrgRig: import('@trpc/server').TRPCMutationProcedure<{ + input: { + organizationId: string; + townId: string; + name: string; + gitUrl: string; + defaultBranch?: string | undefined; + platformIntegrationId?: string | undefined; + }; + output: { + id: string; + town_id: string; + name: string; + git_url: string; + default_branch: string; + platform_integration_id: string | null; + created_at: string; + updated_at: string; + }; + meta: object; + }>; adminListBeads: import('@trpc/server').TRPCQueryProcedure<{ input: { townId: string; @@ -2319,79 +2477,11 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute } | null; meta: object; }>; - listOrgTowns: import('@trpc/server').TRPCQueryProcedure<{ - input: { - organizationId: string; - }; - output: { - id: string; - name: string; - owner_org_id: string; - created_by_user_id: string; - created_at: string; - updated_at: string; - }[]; - meta: object; - }>; - createOrgTown: import('@trpc/server').TRPCMutationProcedure<{ - input: { - organizationId: string; - name: string; - }; - output: { - id: string; - name: string; - owner_org_id: string; - created_by_user_id: string; - created_at: string; - updated_at: string; - }; - meta: object; - }>; - deleteOrgTown: import('@trpc/server').TRPCMutationProcedure<{ - input: { - organizationId: string; - townId: string; - }; - output: void; - meta: object; - }>; - listOrgRigs: import('@trpc/server').TRPCQueryProcedure<{ + debugAgentMetadata: import('@trpc/server').TRPCQueryProcedure<{ input: { - organizationId: string; townId: string; }; - output: { - id: string; - town_id: string; - name: string; - git_url: string; - default_branch: string; - platform_integration_id: string | null; - created_at: string; - updated_at: string; - }[]; - meta: object; - }>; - createOrgRig: import('@trpc/server').TRPCMutationProcedure<{ - input: { - organizationId: string; - townId: string; - name: string; - gitUrl: string; - defaultBranch?: string | undefined; - platformIntegrationId?: string | undefined; - }; - output: { - id: string; - town_id: string; - name: string; - git_url: string; - default_branch: string; - platform_integration_id: string | null; - created_at: string; - updated_at: string; - }; + output: never; meta: object; }>; }> diff --git a/src/lib/gastown/types/schemas.d.ts b/src/lib/gastown/types/schemas.d.ts index 98f3440b2..d56dbe856 100644 --- a/src/lib/gastown/types/schemas.d.ts +++ b/src/lib/gastown/types/schemas.d.ts @@ -891,3 +891,609 @@ export declare const RpcRigDetailOutput: z.ZodPipe< z.core.$strip > >; +export declare const MergeQueueDataOutput: z.ZodObject< + { + needsAttention: z.ZodObject< + { + openPRs: z.ZodArray< + z.ZodObject< + { + mrBead: z.ZodObject< + { + bead_id: z.ZodString; + status: z.ZodString; + title: z.ZodString; + body: z.ZodNullable; + rig_id: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; + metadata: z.ZodRecord; + }, + z.core.$strip + >; + reviewMetadata: z.ZodObject< + { + branch: z.ZodString; + target_branch: z.ZodString; + merge_commit: z.ZodNullable; + pr_url: z.ZodNullable; + retry_count: z.ZodNumber; + }, + z.core.$strip + >; + sourceBead: z.ZodNullable< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + status: z.ZodString; + body: z.ZodNullable; + }, + z.core.$strip + > + >; + convoy: z.ZodNullable< + z.ZodObject< + { + convoy_id: z.ZodString; + title: z.ZodString; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + }, + z.core.$strip + > + >; + agent: z.ZodNullable< + z.ZodObject< + { + agent_id: z.ZodString; + name: z.ZodString; + role: z.ZodString; + }, + z.core.$strip + > + >; + rigName: z.ZodNullable; + staleSince: z.ZodNullable; + failureReason: z.ZodNullable; + }, + z.core.$strip + > + >; + failedReviews: z.ZodArray< + z.ZodObject< + { + mrBead: z.ZodObject< + { + bead_id: z.ZodString; + status: z.ZodString; + title: z.ZodString; + body: z.ZodNullable; + rig_id: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; + metadata: z.ZodRecord; + }, + z.core.$strip + >; + reviewMetadata: z.ZodObject< + { + branch: z.ZodString; + target_branch: z.ZodString; + merge_commit: z.ZodNullable; + pr_url: z.ZodNullable; + retry_count: z.ZodNumber; + }, + z.core.$strip + >; + sourceBead: z.ZodNullable< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + status: z.ZodString; + body: z.ZodNullable; + }, + z.core.$strip + > + >; + convoy: z.ZodNullable< + z.ZodObject< + { + convoy_id: z.ZodString; + title: z.ZodString; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + }, + z.core.$strip + > + >; + agent: z.ZodNullable< + z.ZodObject< + { + agent_id: z.ZodString; + name: z.ZodString; + role: z.ZodString; + }, + z.core.$strip + > + >; + rigName: z.ZodNullable; + staleSince: z.ZodNullable; + failureReason: z.ZodNullable; + }, + z.core.$strip + > + >; + stalePRs: z.ZodArray< + z.ZodObject< + { + mrBead: z.ZodObject< + { + bead_id: z.ZodString; + status: z.ZodString; + title: z.ZodString; + body: z.ZodNullable; + rig_id: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; + metadata: z.ZodRecord; + }, + z.core.$strip + >; + reviewMetadata: z.ZodObject< + { + branch: z.ZodString; + target_branch: z.ZodString; + merge_commit: z.ZodNullable; + pr_url: z.ZodNullable; + retry_count: z.ZodNumber; + }, + z.core.$strip + >; + sourceBead: z.ZodNullable< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + status: z.ZodString; + body: z.ZodNullable; + }, + z.core.$strip + > + >; + convoy: z.ZodNullable< + z.ZodObject< + { + convoy_id: z.ZodString; + title: z.ZodString; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + }, + z.core.$strip + > + >; + agent: z.ZodNullable< + z.ZodObject< + { + agent_id: z.ZodString; + name: z.ZodString; + role: z.ZodString; + }, + z.core.$strip + > + >; + rigName: z.ZodNullable; + staleSince: z.ZodNullable; + failureReason: z.ZodNullable; + }, + z.core.$strip + > + >; + }, + z.core.$strip + >; + activityLog: z.ZodArray< + z.ZodObject< + { + event: z.ZodObject< + { + bead_event_id: z.ZodString; + bead_id: z.ZodString; + agent_id: z.ZodNullable; + event_type: z.ZodString; + old_value: z.ZodNullable; + new_value: z.ZodNullable; + metadata: z.ZodRecord; + created_at: z.ZodString; + }, + z.core.$strip + >; + mrBead: z.ZodNullable< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + type: z.ZodString; + status: z.ZodString; + rig_id: z.ZodNullable; + metadata: z.ZodRecord; + }, + z.core.$strip + > + >; + sourceBead: z.ZodNullable< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + status: z.ZodString; + }, + z.core.$strip + > + >; + convoy: z.ZodNullable< + z.ZodObject< + { + convoy_id: z.ZodString; + title: z.ZodString; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + }, + z.core.$strip + > + >; + agent: z.ZodNullable< + z.ZodObject< + { + agent_id: z.ZodString; + name: z.ZodString; + role: z.ZodString; + }, + z.core.$strip + > + >; + rigName: z.ZodNullable; + reviewMetadata: z.ZodNullable< + z.ZodObject< + { + pr_url: z.ZodNullable; + branch: z.ZodNullable; + target_branch: z.ZodNullable; + merge_commit: z.ZodNullable; + }, + z.core.$strip + > + >; + }, + z.core.$strip + > + >; + }, + z.core.$strip +>; +export declare const RpcMergeQueueDataOutput: z.ZodPipe< + z.ZodAny, + z.ZodObject< + { + needsAttention: z.ZodObject< + { + openPRs: z.ZodArray< + z.ZodObject< + { + mrBead: z.ZodObject< + { + bead_id: z.ZodString; + status: z.ZodString; + title: z.ZodString; + body: z.ZodNullable; + rig_id: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; + metadata: z.ZodRecord; + }, + z.core.$strip + >; + reviewMetadata: z.ZodObject< + { + branch: z.ZodString; + target_branch: z.ZodString; + merge_commit: z.ZodNullable; + pr_url: z.ZodNullable; + retry_count: z.ZodNumber; + }, + z.core.$strip + >; + sourceBead: z.ZodNullable< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + status: z.ZodString; + body: z.ZodNullable; + }, + z.core.$strip + > + >; + convoy: z.ZodNullable< + z.ZodObject< + { + convoy_id: z.ZodString; + title: z.ZodString; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + }, + z.core.$strip + > + >; + agent: z.ZodNullable< + z.ZodObject< + { + agent_id: z.ZodString; + name: z.ZodString; + role: z.ZodString; + }, + z.core.$strip + > + >; + rigName: z.ZodNullable; + staleSince: z.ZodNullable; + failureReason: z.ZodNullable; + }, + z.core.$strip + > + >; + failedReviews: z.ZodArray< + z.ZodObject< + { + mrBead: z.ZodObject< + { + bead_id: z.ZodString; + status: z.ZodString; + title: z.ZodString; + body: z.ZodNullable; + rig_id: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; + metadata: z.ZodRecord; + }, + z.core.$strip + >; + reviewMetadata: z.ZodObject< + { + branch: z.ZodString; + target_branch: z.ZodString; + merge_commit: z.ZodNullable; + pr_url: z.ZodNullable; + retry_count: z.ZodNumber; + }, + z.core.$strip + >; + sourceBead: z.ZodNullable< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + status: z.ZodString; + body: z.ZodNullable; + }, + z.core.$strip + > + >; + convoy: z.ZodNullable< + z.ZodObject< + { + convoy_id: z.ZodString; + title: z.ZodString; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + }, + z.core.$strip + > + >; + agent: z.ZodNullable< + z.ZodObject< + { + agent_id: z.ZodString; + name: z.ZodString; + role: z.ZodString; + }, + z.core.$strip + > + >; + rigName: z.ZodNullable; + staleSince: z.ZodNullable; + failureReason: z.ZodNullable; + }, + z.core.$strip + > + >; + stalePRs: z.ZodArray< + z.ZodObject< + { + mrBead: z.ZodObject< + { + bead_id: z.ZodString; + status: z.ZodString; + title: z.ZodString; + body: z.ZodNullable; + rig_id: z.ZodNullable; + created_at: z.ZodString; + updated_at: z.ZodString; + metadata: z.ZodRecord; + }, + z.core.$strip + >; + reviewMetadata: z.ZodObject< + { + branch: z.ZodString; + target_branch: z.ZodString; + merge_commit: z.ZodNullable; + pr_url: z.ZodNullable; + retry_count: z.ZodNumber; + }, + z.core.$strip + >; + sourceBead: z.ZodNullable< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + status: z.ZodString; + body: z.ZodNullable; + }, + z.core.$strip + > + >; + convoy: z.ZodNullable< + z.ZodObject< + { + convoy_id: z.ZodString; + title: z.ZodString; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + }, + z.core.$strip + > + >; + agent: z.ZodNullable< + z.ZodObject< + { + agent_id: z.ZodString; + name: z.ZodString; + role: z.ZodString; + }, + z.core.$strip + > + >; + rigName: z.ZodNullable; + staleSince: z.ZodNullable; + failureReason: z.ZodNullable; + }, + z.core.$strip + > + >; + }, + z.core.$strip + >; + activityLog: z.ZodArray< + z.ZodObject< + { + event: z.ZodObject< + { + bead_event_id: z.ZodString; + bead_id: z.ZodString; + agent_id: z.ZodNullable; + event_type: z.ZodString; + old_value: z.ZodNullable; + new_value: z.ZodNullable; + metadata: z.ZodRecord; + created_at: z.ZodString; + }, + z.core.$strip + >; + mrBead: z.ZodNullable< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + type: z.ZodString; + status: z.ZodString; + rig_id: z.ZodNullable; + metadata: z.ZodRecord; + }, + z.core.$strip + > + >; + sourceBead: z.ZodNullable< + z.ZodObject< + { + bead_id: z.ZodString; + title: z.ZodString; + status: z.ZodString; + }, + z.core.$strip + > + >; + convoy: z.ZodNullable< + z.ZodObject< + { + convoy_id: z.ZodString; + title: z.ZodString; + total_beads: z.ZodNumber; + closed_beads: z.ZodNumber; + feature_branch: z.ZodNullable; + merge_mode: z.ZodNullable; + }, + z.core.$strip + > + >; + agent: z.ZodNullable< + z.ZodObject< + { + agent_id: z.ZodString; + name: z.ZodString; + role: z.ZodString; + }, + z.core.$strip + > + >; + rigName: z.ZodNullable; + reviewMetadata: z.ZodNullable< + z.ZodObject< + { + pr_url: z.ZodNullable; + branch: z.ZodNullable; + target_branch: z.ZodNullable; + merge_commit: z.ZodNullable; + }, + z.core.$strip + > + >; + }, + z.core.$strip + > + >; + }, + z.core.$strip + > +>; +export declare const OrgTownOutput: z.ZodObject< + { + id: z.ZodString; + name: z.ZodString; + owner_org_id: z.ZodString; + created_by_user_id: z.ZodString; + created_at: z.ZodString; + updated_at: z.ZodString; + }, + z.core.$strip +>; +export declare const RpcOrgTownOutput: z.ZodPipe< + z.ZodAny, + z.ZodObject< + { + id: z.ZodString; + name: z.ZodString; + owner_org_id: z.ZodString; + created_by_user_id: z.ZodString; + created_at: z.ZodString; + updated_at: z.ZodString; + }, + z.core.$strip + > +>;