diff --git a/hub/src/web/routes/auth.ts b/hub/src/web/routes/auth.ts index 2444b8b1..ff2870c9 100644 --- a/hub/src/web/routes/auth.ts +++ b/hub/src/web/routes/auth.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' import { SignJWT } from 'jose' -import { z } from 'zod' +import { AuthRequestSchema } from '@hapi/protocol' import { getConfiguration } from '../../configuration' import { constantTimeEquals } from '../../utils/crypto' import { parseAccessToken } from '../../utils/accessToken' @@ -9,22 +9,12 @@ import { getOrCreateOwnerId } from '../../config/ownerId' import type { WebAppEnv } from '../middleware/auth' import type { Store } from '../../store' -const telegramAuthSchema = z.object({ - initData: z.string() -}) - -const accessTokenAuthSchema = z.object({ - accessToken: z.string() -}) - -const authBodySchema = z.union([telegramAuthSchema, accessTokenAuthSchema]) - export function createAuthRoutes(jwtSecret: Uint8Array, store: Store): Hono { const app = new Hono() app.post('/auth', async (c) => { const json = await c.req.json().catch(() => null) - const parsed = authBodySchema.safeParse(json) + const parsed = AuthRequestSchema.safeParse(json) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 7bd65eea..7feb35da 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -1,25 +1,13 @@ -import { AgentFlavorSchema } from '@hapi/protocol' +import { + MachineListDirectoryRequestSchema, + MachinePathsExistsRequestSchema, + SpawnSessionRequestSchema +} from '@hapi/protocol' import { Hono } from 'hono' -import { z } from 'zod' import type { SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireMachine } from './guards' -const spawnBodySchema = z.object({ - directory: z.string().min(1), - agent: AgentFlavorSchema.optional(), - model: z.string().optional(), - effort: z.string().optional(), - modelReasoningEffort: z.string().optional(), - yolo: z.boolean().optional(), - sessionType: z.enum(['simple', 'worktree']).optional(), - worktreeName: z.string().optional() -}) - -const pathsExistsSchema = z.object({ - paths: z.array(z.string().min(1)).max(1000) -}) - export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -47,7 +35,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = spawnBodySchema.safeParse(body) + const parsed = SpawnSessionRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -80,7 +68,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = z.object({ path: z.string().min(1) }).safeParse(body) + const parsed = MachineListDirectoryRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -106,7 +94,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = pathsExistsSchema.safeParse(body) + const parsed = MachinePathsExistsRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } diff --git a/hub/src/web/routes/messages.ts b/hub/src/web/routes/messages.ts index d3745236..715950cc 100644 --- a/hub/src/web/routes/messages.ts +++ b/hub/src/web/routes/messages.ts @@ -1,47 +1,9 @@ import { Hono } from 'hono' -import { AttachmentMetadataSchema } from '@hapi/protocol/schemas' -import { z } from 'zod' +import { MessagesQuerySchema, SendMessageRequestSchema } from '@hapi/protocol' import type { SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireSessionFromParam, requireSyncEngine } from './guards' -const querySchema = z.object({ - limit: z.coerce.number().int().min(1).max(200).optional(), - beforeSeq: z.coerce.number().int().min(1).optional(), - beforeAt: z.coerce.number().int().min(0).optional(), -}).refine((data) => (data.beforeAt === undefined) === (data.beforeSeq === undefined), { - message: 'beforeAt and beforeSeq must be provided together', - path: ['beforeAt'], -}) - -const sendMessageBodySchema = z.object({ - text: z.string(), - localId: z.string().min(1).optional(), - attachments: z.array(AttachmentMetadataSchema).optional(), - scheduledAt: z.number().int().positive().nullable().optional() -}).refine( - // Scheduled messages need a localId so the ack flow (markMessagesInvoked - // by localId) can flip invoked_at after the CLI consumes them. Without - // a localId, addMessage stamps invoked_at immediately, which would - // silently swallow the schedule. - (data) => data.scheduledAt == null || typeof data.localId === 'string', - { message: 'scheduledAt requires localId', path: ['localId'] } -).refine( - // Cap scheduledAt at 7 days from now to prevent zombie rows. REST/Telegram/ - // automation callers bypass the frontend 7-day clamp, so we enforce it here. - // Evaluated at request time so Date.now() is fresh on every call. - (data) => data.scheduledAt == null || data.scheduledAt <= Date.now() + 7 * 24 * 60 * 60 * 1000, - { message: 'scheduledAt must be within 7 days from now', path: ['scheduledAt'] } -).refine( - // Attachment paths are stored under the CLI session's upload directory and - // purged on session end (cleanupUploadDir in apiSession.ts:sendSessionDeath). - // A scheduled message that matures after the CLI exits would dereference - // deleted files via the @path attachment formatter. Reject the combination - // until uploads are retained through invocation. - (data) => data.scheduledAt == null || !data.attachments?.length, - { message: 'scheduled messages with attachments are not supported', path: ['attachments'] } -) - export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -57,7 +19,7 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho } const sessionId = sessionResult.sessionId - const parsed = querySchema.safeParse(c.req.query()) + const parsed = MessagesQuerySchema.safeParse(c.req.query()) if (!parsed.success) { return c.json({ error: 'Invalid query', issues: parsed.error.flatten() }, 400) } @@ -99,7 +61,7 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho const sessionId = sessionResult.sessionId const body = await c.req.json().catch(() => null) - const parsed = sendMessageBodySchema.safeParse(body) + const parsed = SendMessageRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) } diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 672ad834..dcfc66b6 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1,50 +1,24 @@ -import { getPermissionModesForFlavor, isPermissionModeAllowedForFlavor, supportsModelChange, toSessionSummary } from '@hapi/protocol' +import { + DeleteUploadRequestSchema, + getPermissionModesForFlavor, + isPermissionModeAllowedForFlavor, + RenameSessionRequestSchema, + ResumeSessionRequestSchema, + SessionCollaborationModeRequestSchema, + SessionEffortRequestSchema, + SessionModelReasoningEffortRequestSchema, + SessionModelRequestSchema, + SessionPermissionModeRequestSchema, + supportsModelChange, + toSessionSummary, + UploadFileRequestSchema +} from '@hapi/protocol' import type { SlashCommand } from '@hapi/protocol/apiTypes' -import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { Hono } from 'hono' -import { z } from 'zod' import type { SyncEngine, Session } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireSessionFromParam, requireSyncEngine } from './guards' -const permissionModeSchema = z.object({ - mode: PermissionModeSchema -}) - -const resumeBodySchema = z.object({ - permissionMode: PermissionModeSchema.optional() -}) - -const collaborationModeSchema = z.object({ - mode: CodexCollaborationModeSchema -}) - -const modelSchema = z.object({ - model: z.string().trim().min(1).nullable() -}) - -const modelReasoningEffortSchema = z.object({ - modelReasoningEffort: z.string().trim().min(1).nullable() -}) - -const effortSchema = z.object({ - effort: z.string().trim().min(1).nullable() -}) - -const renameSessionSchema = z.object({ - name: z.string().min(1).max(255) -}) - -const uploadSchema = z.object({ - filename: z.string().min(1).max(255), - content: z.string().min(1), - mimeType: z.string().min(1).max(255) -}) - -const uploadDeleteSchema = z.object({ - path: z.string().min(1) -}) - const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 function commandsFromMetadataSlashCommands(names: readonly string[] | undefined): SlashCommand[] { @@ -136,7 +110,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = body ? resumeBodySchema.safeParse(body) : { success: true as const, data: {} } + const parsed = body ? ResumeSessionRequestSchema.safeParse(body) : { success: true as const, data: {} } if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -178,7 +152,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = uploadSchema.safeParse(body) + const parsed = UploadFileRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -216,7 +190,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = uploadDeleteSchema.safeParse(body) + const parsed = DeleteUploadRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -289,7 +263,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = permissionModeSchema.safeParse(body) + const parsed = SessionPermissionModeRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -335,7 +309,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = collaborationModeSchema.safeParse(body) + const parsed = SessionCollaborationModeRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -361,7 +335,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = modelSchema.safeParse(body) + const parsed = SessionModelRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -403,7 +377,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = modelReasoningEffortSchema.safeParse(body) + const parsed = SessionModelReasoningEffortRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -431,7 +405,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = effortSchema.safeParse(body) + const parsed = SessionEffortRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -462,7 +436,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = renameSessionSchema.safeParse(body) + const parsed = RenameSessionRequestSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body: name is required' }, 400) } diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index d02aa571..150bbdbc 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -1,9 +1,13 @@ import { z } from 'zod' import { + AttachmentMetadataSchema, + CodexCollaborationModeSchema, DecryptedMessageSchema, MachineSchema, + PermissionModeSchema, SessionSchema } from './schemas' +import { AgentFlavorSchema } from './modes' import type { DecryptedMessage, Machine, @@ -85,6 +89,123 @@ export type SpawnResponse = | { type: 'success'; sessionId: string } | { type: 'error'; message: string } +export const SessionPermissionModeRequestSchema = z.object({ + mode: PermissionModeSchema +}) + +export type SessionPermissionModeRequest = z.infer + +export const ResumeSessionRequestSchema = z.object({ + permissionMode: PermissionModeSchema.optional() +}) + +export type ResumeSessionRequest = z.infer + +export const SessionCollaborationModeRequestSchema = z.object({ + mode: CodexCollaborationModeSchema +}) + +export type SessionCollaborationModeRequest = z.infer + +export const SessionModelRequestSchema = z.object({ + model: z.string().trim().min(1).nullable() +}) + +export type SessionModelRequest = z.infer + +export const SessionModelReasoningEffortRequestSchema = z.object({ + modelReasoningEffort: z.string().trim().min(1).nullable() +}) + +export type SessionModelReasoningEffortRequest = z.infer + +export const SessionEffortRequestSchema = z.object({ + effort: z.string().trim().min(1).nullable() +}) + +export type SessionEffortRequest = z.infer + +export const RenameSessionRequestSchema = z.object({ + name: z.string().min(1).max(255) +}) + +export type RenameSessionRequest = z.infer + +export const UploadFileRequestSchema = z.object({ + filename: z.string().min(1).max(255), + content: z.string().min(1), + mimeType: z.string().min(1).max(255) +}) + +export type UploadFileRequest = z.infer + +export const DeleteUploadRequestSchema = z.object({ + path: z.string().min(1) +}) + +export type DeleteUploadRequest = z.infer + +export const MessagesQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + beforeSeq: z.coerce.number().int().min(1).optional(), + beforeAt: z.coerce.number().int().min(0).optional(), +}).refine((data) => (data.beforeAt === undefined) === (data.beforeSeq === undefined), { + message: 'beforeAt and beforeSeq must be provided together', + path: ['beforeAt'], +}) + +export type MessagesQuery = z.infer + +export const SendMessageRequestSchema = z.object({ + text: z.string(), + localId: z.string().min(1).optional(), + attachments: z.array(AttachmentMetadataSchema).optional(), + scheduledAt: z.number().int().positive().nullable().optional() +}).refine( + (data) => data.scheduledAt == null || typeof data.localId === 'string', + { message: 'scheduledAt requires localId', path: ['localId'] } +).refine( + (data) => data.scheduledAt == null || data.scheduledAt <= Date.now() + 7 * 24 * 60 * 60 * 1000, + { message: 'scheduledAt must be within 7 days from now', path: ['scheduledAt'] } +).refine( + (data) => data.scheduledAt == null || !data.attachments?.length, + { message: 'scheduled messages with attachments are not supported', path: ['attachments'] } +) + +export type SendMessageRequest = z.infer + +export const SpawnSessionRequestSchema = z.object({ + directory: z.string().min(1), + agent: AgentFlavorSchema.optional(), + model: z.string().optional(), + effort: z.string().optional(), + modelReasoningEffort: z.string().optional(), + yolo: z.boolean().optional(), + sessionType: z.enum(['simple', 'worktree']).optional(), + worktreeName: z.string().optional() +}) + +export type SpawnSessionRequest = z.infer + +export const MachineListDirectoryRequestSchema = z.object({ + path: z.string().min(1) +}) + +export type MachineListDirectoryRequest = z.infer + +export const MachinePathsExistsRequestSchema = z.object({ + paths: z.array(z.string().min(1)).max(1000) +}) + +export type MachinePathsExistsRequest = z.infer + +export const AuthRequestSchema = z.union([ + z.object({ initData: z.string() }), + z.object({ accessToken: z.string() }) +]) + +export type AuthRequest = z.infer + export type CommandResponse = { success: boolean stdout?: string