From 0042fb3df088727c6bcbcb5871513532c64660f3 Mon Sep 17 00:00:00 2001 From: kisskin Date: Fri, 10 Jul 2026 10:16:39 +0300 Subject: [PATCH] feat(contracts): M1 shared TypeScript contracts and bridge skeleton --- .gitignore | 1 + package.json | 13 + packages/bridge/package.json | 33 ++ packages/bridge/src/auth.ts | 165 ++++++++++ packages/bridge/src/hermes-client.ts | 211 ++++++++++++ packages/bridge/src/index.ts | 95 ++++++ packages/bridge/tsconfig.build.json | 10 + packages/bridge/tsconfig.json | 24 ++ packages/contracts/package.json | 28 ++ packages/contracts/src/index.ts | 97 ++++++ packages/contracts/src/schemas/api.ts | 102 ++++++ packages/contracts/src/schemas/index.ts | 3 + packages/contracts/src/schemas/kanban.ts | 89 ++++++ packages/contracts/src/schemas/telegram.ts | 46 +++ packages/contracts/src/types/api.ts | 150 +++++++++ packages/contracts/src/types/kanban.ts | 108 +++++++ packages/contracts/src/types/telegram.ts | 88 +++++ packages/contracts/tsconfig.build.json | 10 + packages/contracts/tsconfig.json | 24 ++ pnpm-lock.yaml | 353 +++++++++++++++++++++ pnpm-workspace.yaml | 3 + 21 files changed, 1653 insertions(+) create mode 100644 package.json create mode 100644 packages/bridge/package.json create mode 100644 packages/bridge/src/auth.ts create mode 100644 packages/bridge/src/hermes-client.ts create mode 100644 packages/bridge/src/index.ts create mode 100644 packages/bridge/tsconfig.build.json create mode 100644 packages/bridge/tsconfig.json create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/index.ts create mode 100644 packages/contracts/src/schemas/api.ts create mode 100644 packages/contracts/src/schemas/index.ts create mode 100644 packages/contracts/src/schemas/kanban.ts create mode 100644 packages/contracts/src/schemas/telegram.ts create mode 100644 packages/contracts/src/types/api.ts create mode 100644 packages/contracts/src/types/kanban.ts create mode 100644 packages/contracts/src/types/telegram.ts create mode 100644 packages/contracts/tsconfig.build.json create mode 100644 packages/contracts/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore index 3e663c2..a4022f4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__/ node_modules/ frontend/dist/ frontend/.vite/ +packages/*/dist/ coverage/ *.tsbuildinfo diff --git a/package.json b/package.json new file mode 100644 index 0000000..a9fee95 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "hermes-kanban-miniapp", + "private": true, + "packageManager": "pnpm@9.15.4", + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "pnpm -r build", + "typecheck": "pnpm -r typecheck", + "clean": "pnpm -r clean" + } +} diff --git a/packages/bridge/package.json b/packages/bridge/package.json new file mode 100644 index 0000000..c44e024 --- /dev/null +++ b/packages/bridge/package.json @@ -0,0 +1,33 @@ +{ + "name": "@kanban/bridge", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "clean": "rm -rf dist" + }, + "dependencies": { + "@kanban/contracts": "workspace:*", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0", + "tsx": "^4.16.0" + } +} diff --git a/packages/bridge/src/auth.ts b/packages/bridge/src/auth.ts new file mode 100644 index 0000000..e047cb8 --- /dev/null +++ b/packages/bridge/src/auth.ts @@ -0,0 +1,165 @@ +// ───────────────────────────────────────────────────────────── +// Telegram initData validation for bridge +// ADR §7: "Server validation steps" (HMAC-SHA256) +// +// NOTE: The bridge typically trusts the BFF's X-Actor-* headers +// rather than validating initData directly. This module exists +// for local-dev mode (AUTH_MODE=dev) and as a reference. +// ───────────────────────────────────────────────────────────── + +import { createHmac, timingSafeEqual } from 'node:crypto'; +import type { TelegramInitData } from '@kanban/contracts'; + +// ─── Parse initData query string ───────────────────────────── + +/** + * Parse Telegram initData query string into structured object. + * initData looks like: "query_id=...&user=...&auth_date=...&hash=..." + * The `user` field is URL-encoded JSON. + */ +export function parseInitData(raw: string): TelegramInitData { + const params = new URLSearchParams(raw); + + const hash = params.get('hash'); + if (!hash) throw new Error('Missing hash in initData'); + + const authDateStr = params.get('auth_date'); + if (!authDateStr) throw new Error('Missing auth_date in initData'); + const auth_date = parseInt(authDateStr, 10); + if (isNaN(auth_date)) throw new Error('Invalid auth_date in initData'); + + const userRaw = params.get('user'); + let user: TelegramInitData['user']; + if (userRaw) { + try { + user = JSON.parse(userRaw); + } catch { + throw new Error('Invalid user JSON in initData'); + } + } + + return { + hash, + auth_date, + query_id: params.get('query_id') ?? undefined, + user, + chat: params.get('chat') ? JSON.parse(params.get('chat')!) : undefined, + chat_type: params.get('chat_type') ?? undefined, + chat_instance: params.get('chat_instance') ?? undefined, + start_param: params.get('start_param') ?? undefined, + can_send_after: params.get('can_send_after') + ? parseInt(params.get('can_send_after')!, 10) + : undefined, + }; +} + +// ─── Validate initData ─────────────────────────────────────── + +export interface ValidateInitDataOptions { + /** Telegram bot token (server-side only!) */ + botToken: string; + /** Maximum age of initData in seconds (default: 3600) */ + maxAgeSeconds?: number; +} + +export interface ValidationResult { + valid: boolean; + user?: TelegramInitData['user']; + error?: string; +} + +/** + * Validate Telegram initData per the official HMAC-SHA256 flow. + * + * Steps (from ADR §7): + * 1. Parse query-string-like init data. + * 2. Remove and store `hash`. + * 3. Sort remaining key-value pairs alphabetically. + * 4. Join as `key=value` lines separated by \n. + * 5. Derive secret key: HMAC-SHA256 with key `WebAppData` and message `botToken`. + * 6. Compute HMAC-SHA256 of the data-check string using the derived key. + * 7. Compare hex digest to received `hash` using constant-time comparison. + * 8. Reject expired data. + */ +export function validateInitData( + raw: string, + options: ValidateInitDataOptions +): ValidationResult { + try { + const parsed = parseInitData(raw); + const maxAge = options.maxAgeSeconds ?? 3600; + + // Step 8: reject expired data + const now = Math.floor(Date.now() / 1000); + if (now - parsed.auth_date > maxAge) { + return { valid: false, error: 'initData has expired' }; + } + + // Step 2: remove hash + const receivedHash = parsed.hash; + + // Step 3-4: sort remaining pairs and join + const params = new URLSearchParams(raw); + params.delete('hash'); + const sortedEntries = [...params.entries()].sort(([a], [b]) => + a.localeCompare(b) + ); + const dataCheckString = sortedEntries + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + + // Step 5: derive secret key + const secretKey = createHmac('sha256', 'WebAppData') + .update(options.botToken) + .digest(); + + // Step 6: compute HMAC + const computedHash = createHmac('sha256', secretKey) + .update(dataCheckString) + .digest('hex'); + + // Step 7: constant-time comparison + const isValid = + computedHash.length === receivedHash.length && + timingSafeEqual( + Buffer.from(computedHash, 'hex'), + Buffer.from(receivedHash, 'hex') + ); + + if (!isValid) { + return { valid: false, error: 'HMAC mismatch' }; + } + + if (!parsed.user) { + return { valid: false, error: 'Missing user in initData' }; + } + + return { valid: true, user: parsed.user }; + } catch (err) { + return { + valid: false, + error: err instanceof Error ? err.message : 'Unknown validation error', + }; + } +} + +// ─── Dev mode helper ───────────────────────────────────────── + +export interface DevUser { + telegramId: number; + firstName: string; + role: 'operator' | 'admin'; +} + +/** + * Create a fake validated user for local dev mode. + * Only usable when AUTH_MODE=dev (checked by caller). + */ +export function createDevUser(overrides?: Partial): DevUser { + return { + telegramId: 123456789, + firstName: 'DevUser', + role: 'admin', + ...overrides, + }; +} diff --git a/packages/bridge/src/hermes-client.ts b/packages/bridge/src/hermes-client.ts new file mode 100644 index 0000000..f869db8 --- /dev/null +++ b/packages/bridge/src/hermes-client.ts @@ -0,0 +1,211 @@ +// ───────────────────────────────────────────────────────────── +// Hermes CLI client — type-safe wrapper for `hermes kanban ...` +// ADR §3.5: "uses stable hermes kanban ... --json CLI commands first" +// ADR §3.5: "fixed argv and shell: false" +// ───────────────────────────────────────────────────────────── + +import { execFile as execFileCb } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { + Board, + TaskSummary, + TaskDetail, +} from '@kanban/contracts'; + +const execFile = promisify(execFileCb); + +// ─── Types ─────────────────────────────────────────────────── + +export interface HermesClientConfig { + /** Path to hermes binary (default: 'hermes') */ + bin: string; + /** HERMES_HOME for target profile */ + home: string; + /** Default board slug */ + defaultBoard: string; + /** Per-command timeout in ms */ + timeoutMs: number; +} + +// ─── Allowed commands ──────────────────────────────────────── + +/** + * Command allowlist. Each entry defines: + * - subcommand: the `hermes kanban ` string + * - requiredArgs: positional args the caller must provide + * - optionalFlags: flags that may be appended + * + * This prevents arbitrary pass-through to the hermes CLI. + */ +type KanbanSubcommand = + | 'boards' + | 'list' + | 'show' + | 'create' + | 'comment' + | 'assign' + | 'promote' + | 'block' + | 'unblock' + | 'archive' + | 'dispatch' + | 'log'; + +const ALLOWED_SUBCOMMANDS = new Set([ + 'boards', + 'list', + 'show', + 'create', + 'comment', + 'assign', + 'promote', + 'block', + 'unblock', + 'archive', + 'dispatch', + 'log', +]); + +// ─── Client ────────────────────────────────────────────────── + +export class HermesKanbanClient { + private readonly config: HermesClientConfig; + + constructor(config: HermesClientConfig) { + this.config = config; + } + + /** + * Execute a hermes kanban subcommand with --json output. + * Uses execFile (shell: false) for safety. + */ + async exec( + subcommand: KanbanSubcommand, + args: string[], + board?: string + ): Promise { + if (!ALLOWED_SUBCOMMANDS.has(subcommand)) { + throw new Error(`Blocked disallowed subcommand: ${subcommand}`); + } + + const fullArgs = [ + 'kanban', + subcommand, + '--board', board ?? this.config.defaultBoard, + '--json', + ...args, + ]; + + const env = { + ...process.env, + HERMES_HOME: this.config.home, + }; + + try { + const { stdout, stderr } = await execFile(this.config.bin, fullArgs, { + shell: false, // CRITICAL: never use shell + timeout: this.config.timeoutMs, + env, + maxBuffer: 5 * 1024 * 1024, // 5MB + }); + + if (stderr) { + console.warn(`[hermes-client] stderr: ${redact(stderr)}`); + } + + return JSON.parse(stdout) as T; + } catch (err) { + if (err instanceof Error) { + // Redact any secrets that might leak into error messages + throw new Error(`Hermes CLI error: ${redact(err.message)}`); + } + throw err; + } + } + + // ─── Convenience methods (M2 will flesh these out) ──────── + + async listBoards(): Promise { + return this.exec('boards', []); + } + + async listTasks( + board: string, + opts?: { status?: string; assignee?: string; limit?: number } + ): Promise { + const args: string[] = []; + if (opts?.status) args.push('--status', opts.status); + if (opts?.assignee) args.push('--assignee', opts.assignee); + if (opts?.limit) args.push('--limit', String(opts.limit)); + return this.exec('list', args, board); + } + + async getTask(board: string, taskId: string): Promise { + return this.exec('show', [taskId], board); + } + + async createTask( + board: string, + payload: { title: string; body?: string; assignee?: string; priority?: number } + ): Promise { + const args = ['--title', payload.title]; + if (payload.body) args.push('--body', payload.body); + if (payload.assignee) args.push('--assignee', payload.assignee); + if (payload.priority !== undefined) args.push('--priority', String(payload.priority)); + return this.exec('create', args, board); + } + + async addComment(board: string, taskId: string, body: string): Promise { + await this.exec('comment', [taskId, '--body', body], board); + } + + async assignTask(board: string, taskId: string, assignee: string): Promise { + await this.exec('assign', [taskId, '--assignee', assignee], board); + } + + async promoteTask(board: string, taskId: string, reason?: string): Promise { + const args = [taskId]; + if (reason) args.push('--reason', reason); + await this.exec('promote', args, board); + } + + async blockTask(board: string, taskId: string, reason: string): Promise { + await this.exec('block', [taskId, '--reason', reason], board); + } + + async unblockTask(board: string, taskId: string): Promise { + await this.exec('unblock', [taskId], board); + } + + async archiveTask(board: string, taskId: string): Promise { + await this.exec('archive', [taskId], board); + } + + async dispatchBoard(board: string): Promise { + await this.exec('dispatch', [], board); + } + + async getTaskLog(board: string, taskId: string, lines?: number): Promise { + const args = [taskId]; + if (lines) args.push('--lines', String(lines)); + const result = await this.exec<{ content?: string; log?: string }>('log', args, board); + return result.content ?? result.log ?? ''; + } +} + +// ─── Redaction helper ──────────────────────────────────────── + +const SENSITIVE_PATTERNS = [ + /token[=:]\s*\S+/gi, + /secret[=:]\s*\S+/gi, + /password[=:]\s*\S+/gi, + /api[_-]?key[=:]\s*\S+/gi, +]; + +function redact(text: string): string { + let result = text; + for (const pattern of SENSITIVE_PATTERNS) { + result = result.replace(pattern, '[REDACTED]'); + } + return result; +} diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts new file mode 100644 index 0000000..4a8c731 --- /dev/null +++ b/packages/bridge/src/index.ts @@ -0,0 +1,95 @@ +// ───────────────────────────────────────────────────────────── +// @kanban/bridge — entry point +// Self-hosted Hermes Kanban Bridge (Node.js) +// ADR §5: "runs on trusted host near Hermes" +// ───────────────────────────────────────────────────────────── + +import type { HealthData } from '@kanban/contracts'; + +// ─── Configuration ─────────────────────────────────────────── + +export interface BridgeConfig { + host: string; + port: number; + bridgeToken: string; + hermesHome: string; + hermesBin: string; + commandTimeoutSeconds: number; + allowedBoards: string[]; + logTailMaxLines: number; +} + +export function loadConfig(): BridgeConfig { + return { + host: process.env.BRIDGE_HOST ?? '127.0.0.1', + port: parseInt(process.env.BRIDGE_PORT ?? '8787', 10), + bridgeToken: requireEnv('BRIDGE_TOKEN'), + hermesHome: process.env.HERMES_HOME ?? '', + hermesBin: process.env.HERMES_BIN ?? 'hermes', + commandTimeoutSeconds: parseInt(process.env.COMMAND_TIMEOUT_SECONDS ?? '30', 10), + allowedBoards: (process.env.BRIDGE_ALLOWED_BOARDS ?? 'default') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + logTailMaxLines: parseInt(process.env.LOG_TAIL_MAX_LINES ?? '500', 10), + }; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +// ─── Health check ──────────────────────────────────────────── + +export function getHealthData(version: string): HealthData { + return { + status: 'ok', + version, + bridge: { + configured: true, + reachable: true, + }, + }; +} + +// ─── Server skeleton ───────────────────────────────────────── + +/** + * Placeholder for the actual HTTP server (Express / Fastify / raw http). + * M2 will implement real endpoints. + * + * Route map (ADR §10): + * GET /bridge/v1/health + * GET /bridge/v1/boards + * GET /bridge/v1/boards/:board/tasks + * GET /bridge/v1/boards/:board/tasks/:taskId + * GET /bridge/v1/boards/:board/tasks/:taskId/log + * POST /bridge/v1/boards/:board/tasks + * POST /bridge/v1/boards/:board/tasks/:taskId/comment + * POST /bridge/v1/boards/:board/tasks/:taskId/assign + * POST /bridge/v1/boards/:board/tasks/:taskId/promote + * POST /bridge/v1/boards/:board/tasks/:taskId/block + * POST /bridge/v1/boards/:board/tasks/:taskId/unblock + * POST /bridge/v1/boards/:board/tasks/:taskId/archive + * POST /bridge/v1/boards/:board/dispatch + */ +export async function startBridge(config: BridgeConfig): Promise { + // M2: implement HTTP server with auth middleware + console.log( + `[bridge] Skeleton loaded. Would listen on ${config.host}:${config.port}` + ); +} + +// ─── Main ──────────────────────────────────────────────────── + +export const VERSION = '0.1.0'; + +if (import.meta.url === `file://${process.argv[1]}`) { + const config = loadConfig(); + console.log(`[bridge] v${VERSION} starting...`); + await startBridge(config); +} diff --git a/packages/bridge/tsconfig.build.json b/packages/bridge/tsconfig.build.json new file mode 100644 index 0000000..cdb8e9a --- /dev/null +++ b/packages/bridge/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/bridge/tsconfig.json b/packages/bridge/tsconfig.json new file mode 100644 index 0000000..e2541c4 --- /dev/null +++ b/packages/bridge/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..9180787 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,28 @@ +{ + "name": "@kanban/contracts", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..4962ed4 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,97 @@ +// ───────────────────────────────────────────────────────────── +// @kanban/contracts — barrel export +// Shared types and Zod schemas for Kanban Mini App +// ───────────────────────────────────────────────────────────── + +// TypeScript types +export type { + Board, + BoardSlug, + TaskStatus, + TaskSummary, + TaskDetail, + TaskComment, + TaskRun, + TaskEvent, + TaskAttachment, + AppRole, + AppUser, + ColumnDef, +} from './types/kanban.js'; +export { DEFAULT_COLUMNS } from './types/kanban.js'; + +export type { + ApiOk, + ApiError, + ApiErrorBody, + ApiResponse, + PaginatedData, + PaginatedResponse, + HealthData, + MeData, + BoardListData, + TaskListParams, + TaskListData, + TaskDetailParams, + TaskDetailData, + TaskLogParams, + TaskLogData, + CreateTaskPayload, + CommentPayload, + AssignPayload, + PromotePayload, + BlockPayload, + UnblockPayload, + ArchivePayload, + DispatchPayload, + ErrorCode, +} from './types/api.js'; + +export type { + TelegramUser, + TelegramChat, + TelegramInitData, + TelegramWebAppThemeParams, + TelegramWebAppInfo, +} from './types/telegram.js'; + +// Zod schemas +export { + BoardSlugSchema, + BoardSchema, + TaskStatusSchema, + TaskIdSchema, + TaskSummarySchema, + TaskCommentSchema, + TaskRunSchema, + TaskEventSchema, + TaskAttachmentSchema, + TaskDetailSchema, +} from './schemas/kanban.js'; + +export { + apiOkSchema, + ApiErrorBodySchema, + ApiErrorSchema, + apiResponseSchema, + paginatedDataSchema, + CreateTaskPayloadSchema, + CommentPayloadSchema, + AssignPayloadSchema, + PromotePayloadSchema, + BlockPayloadSchema, + UnblockPayloadSchema, + ArchivePayloadSchema, + DispatchPayloadSchema, + TaskListQuerySchema, + TaskLogQuerySchema, + BoardParamSchema, + TaskParamSchema, +} from './schemas/api.js'; + +export { + TelegramUserSchema, + TelegramChatSchema, + TelegramInitDataSchema, + TelegramWebAppThemeParamsSchema, +} from './schemas/telegram.js'; diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts new file mode 100644 index 0000000..3df54ad --- /dev/null +++ b/packages/contracts/src/schemas/api.ts @@ -0,0 +1,102 @@ +// ───────────────────────────────────────────────────────────── +// Zod schemas for API request/response validation +// ───────────────────────────────────────────────────────────── + +import { z } from 'zod'; + +// ─── Envelope ──────────────────────────────────────────────── + +export function apiOkSchema(dataSchema: T) { + return z.object({ + ok: z.literal(true), + data: dataSchema, + requestId: z.string(), + }); +} + +export const ApiErrorBodySchema = z.object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), +}); + +export const ApiErrorSchema = z.object({ + ok: z.literal(false), + error: ApiErrorBodySchema, + requestId: z.string(), +}); + +export function apiResponseSchema(dataSchema: T) { + return z.union([apiOkSchema(dataSchema), ApiErrorSchema]); +} + +// ─── Pagination ────────────────────────────────────────────── + +export function paginatedDataSchema(itemSchema: T) { + return z.object({ + items: z.array(itemSchema), + nextCursor: z.string().nullable().optional(), + totalEstimate: z.number().int().optional(), + }); +} + +// ─── Write payloads ────────────────────────────────────────── + +export const CreateTaskPayloadSchema = z.object({ + title: z.string().min(1).max(500), + body: z.string().max(50_000).optional(), + assignee: z.string().max(128).optional(), + priority: z.number().int().min(0).max(10).optional(), + workspaceKind: z.string().max(64).optional(), + workspacePath: z.string().max(1024).optional(), + goalMode: z.boolean().optional(), + maxRuntimeSeconds: z.number().int().positive().optional(), + idempotencyKey: z.string().max(256).optional(), +}); + +export const CommentPayloadSchema = z.object({ + body: z.string().min(1).max(50_000), +}); + +export const AssignPayloadSchema = z.object({ + assignee: z.string().min(1).max(128), +}); + +export const PromotePayloadSchema = z.object({ + reason: z.string().max(1000).optional(), +}); + +export const BlockPayloadSchema = z.object({ + reason: z.string().min(1).max(1000), +}); + +export const UnblockPayloadSchema = z.object({}); + +export const ArchivePayloadSchema = z.object({}); + +export const DispatchPayloadSchema = z.object({}); + +// ─── Query params ──────────────────────────────────────────── + +export const TaskListQuerySchema = z.object({ + status: z.string().optional(), + assignee: z.string().optional(), + q: z.string().max(200).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + cursor: z.string().optional(), +}); + +export const TaskLogQuerySchema = z.object({ + lines: z.coerce.number().int().min(1).max(1000).optional(), +}); + +// ─── Board slug & task ID (for URL params) ─────────────────── + +export const BoardParamSchema = z.object({ + board: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/), +}); + +export const TaskParamSchema = z.object({ + board: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/), + taskId: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/), +}); diff --git a/packages/contracts/src/schemas/index.ts b/packages/contracts/src/schemas/index.ts new file mode 100644 index 0000000..d28742a --- /dev/null +++ b/packages/contracts/src/schemas/index.ts @@ -0,0 +1,3 @@ +export * from './kanban.js'; +export * from './api.js'; +export * from './telegram.js'; diff --git a/packages/contracts/src/schemas/kanban.ts b/packages/contracts/src/schemas/kanban.ts new file mode 100644 index 0000000..8d3b285 --- /dev/null +++ b/packages/contracts/src/schemas/kanban.ts @@ -0,0 +1,89 @@ +// ───────────────────────────────────────────────────────────── +// Zod schemas for Kanban domain models +// ───────────────────────────────────────────────────────────── + +import { z } from 'zod'; + +// ─── Board ─────────────────────────────────────────────────── + +export const BoardSlugSchema = z + .string() + .min(1) + .max(128) + .regex(/^[a-zA-Z0-9_-]+$/, 'Board slug must be alphanumeric with hyphens/underscores'); + +export const BoardSchema = z.object({ + slug: BoardSlugSchema, + name: z.string().optional(), + description: z.string().optional(), + icon: z.string().optional(), + current: z.boolean().optional(), +}); + +// ─── Task ──────────────────────────────────────────────────── + +export const TaskStatusSchema = z.enum([ + 'triage', + 'todo', + 'ready', + 'running', + 'blocked', + 'done', + 'archived', +]); + +export const TaskIdSchema = z + .string() + .min(1) + .max(128) + .regex(/^[a-zA-Z0-9_-]+$/, 'Task ID must be alphanumeric with hyphens/underscores'); + +export const TaskSummarySchema = z.object({ + id: z.string(), + title: z.string(), + status: TaskStatusSchema, + assignee: z.string().nullable().optional(), + priority: z.number().int().nullable().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + blockedReason: z.string().nullable().optional(), + parentIds: z.array(z.string()).optional(), + childIds: z.array(z.string()).optional(), +}); + +export const TaskCommentSchema = z.object({ + id: z.string().optional(), + author: z.string().optional(), + body: z.string().min(1), + createdAt: z.string().optional(), +}); + +export const TaskRunSchema = z.object({ + id: z.string().optional(), + status: z.string().optional(), + startedAt: z.string().optional(), + finishedAt: z.string().optional(), + logAvailable: z.boolean().optional(), +}); + +export const TaskEventSchema = z.object({ + id: z.string().optional(), + type: z.string(), + message: z.string().optional(), + createdAt: z.string().optional(), +}); + +export const TaskAttachmentSchema = z.object({ + id: z.string(), + filename: z.string(), + sizeBytes: z.number().int().optional(), + mimeType: z.string().optional(), +}); + +export const TaskDetailSchema = TaskSummarySchema.extend({ + body: z.string().nullable().optional(), + comments: z.array(TaskCommentSchema).optional(), + runs: z.array(TaskRunSchema).optional(), + events: z.array(TaskEventSchema).optional(), + attachments: z.array(TaskAttachmentSchema).optional(), +}); diff --git a/packages/contracts/src/schemas/telegram.ts b/packages/contracts/src/schemas/telegram.ts new file mode 100644 index 0000000..cdc31c5 --- /dev/null +++ b/packages/contracts/src/schemas/telegram.ts @@ -0,0 +1,46 @@ +// ───────────────────────────────────────────────────────────── +// Zod schemas for Telegram types +// ───────────────────────────────────────────────────────────── + +import { z } from 'zod'; + +export const TelegramUserSchema = z.object({ + id: z.number().int().positive(), + is_bot: z.boolean().optional(), + first_name: z.string().min(1), + last_name: z.string().optional(), + username: z.string().optional(), + language_code: z.string().optional(), + is_premium: z.boolean().optional(), + photo_url: z.string().url().optional(), +}); + +export const TelegramChatSchema = z.object({ + id: z.number().int(), + type: z.string(), + title: z.string().optional(), + username: z.string().optional(), + photo_url: z.string().url().optional(), +}); + +export const TelegramInitDataSchema = z.object({ + hash: z.string().min(1), + auth_date: z.number().int().positive(), + query_id: z.string().optional(), + user: TelegramUserSchema.optional(), + chat: TelegramChatSchema.optional(), + chat_type: z.string().optional(), + chat_instance: z.string().optional(), + start_param: z.string().optional(), + can_send_after: z.number().int().optional(), +}); + +export const TelegramWebAppThemeParamsSchema = z.object({ + bg_color: z.string().optional(), + text_color: z.string().optional(), + hint_color: z.string().optional(), + link_color: z.string().optional(), + button_color: z.string().optional(), + button_text_color: z.string().optional(), + secondary_bg_color: z.string().optional(), +}); diff --git a/packages/contracts/src/types/api.ts b/packages/contracts/src/types/api.ts new file mode 100644 index 0000000..d5556f7 --- /dev/null +++ b/packages/contracts/src/types/api.ts @@ -0,0 +1,150 @@ +// ───────────────────────────────────────────────────────────── +// API request/response envelope types +// From ADR §10 "Common response envelope" and endpoint specs +// ───────────────────────────────────────────────────────────── + +import type { Board, TaskSummary, TaskDetail, AppUser } from './kanban.js'; + +// ─── Envelope ──────────────────────────────────────────────── + +export interface ApiOk { + ok: true; + data: T; + requestId: string; +} + +export interface ApiErrorBody { + code: string; + message: string; + details?: unknown; +} + +export interface ApiError { + ok: false; + error: ApiErrorBody; + requestId: string; +} + +export type ApiResponse = ApiOk | ApiError; + +// ─── Pagination ────────────────────────────────────────────── + +export interface PaginatedData { + items: T[]; + nextCursor?: string | null; + totalEstimate?: number; +} + +export type PaginatedResponse = ApiOk> | ApiError; + +// ─── Health ────────────────────────────────────────────────── + +export interface HealthData { + status: 'ok' | 'degraded'; + version: string; + bridge: { + configured: boolean; + reachable: boolean; + }; +} + +// ─── Me ────────────────────────────────────────────────────── + +export interface MeData { + user: AppUser; + expiresAt: string; +} + +// ─── Board list ────────────────────────────────────────────── + +export type BoardListData = Board[]; + +// ─── Task list ─────────────────────────────────────────────── + +export interface TaskListParams { + board: string; + status?: string; + assignee?: string; + q?: string; + limit?: number; + cursor?: string; +} + +export type TaskListData = PaginatedData; + +// ─── Task detail ───────────────────────────────────────────── + +export interface TaskDetailParams { + board: string; + taskId: string; +} + +export type TaskDetailData = TaskDetail; + +// ─── Task log ──────────────────────────────────────────────── + +export interface TaskLogParams { + board: string; + taskId: string; + lines?: number; +} + +export interface TaskLogData { + content: string; + truncated: boolean; +} + +// ─── Write payloads ────────────────────────────────────────── + +export interface CreateTaskPayload { + title: string; + body?: string; + assignee?: string; + priority?: number; + workspaceKind?: string; + workspacePath?: string; + goalMode?: boolean; + maxRuntimeSeconds?: number; + idempotencyKey?: string; +} + +export interface CommentPayload { + body: string; +} + +export interface AssignPayload { + assignee: string; +} + +export interface PromotePayload { + reason?: string; +} + +export interface BlockPayload { + reason: string; +} + +export interface UnblockPayload { + // empty for now; may add reason later +} + +export interface ArchivePayload { + // empty for now +} + +export interface DispatchPayload { + // empty for now +} + +// ─── Error codes (ADR §10) ────────────────────────────────── + +export type ErrorCode = + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'VALIDATION_ERROR' + | 'RATE_LIMITED' + | 'BRIDGE_UNREACHABLE' + | 'BRIDGE_ERROR' + | 'TIMEOUT' + | 'INTERNAL_ERROR'; diff --git a/packages/contracts/src/types/kanban.ts b/packages/contracts/src/types/kanban.ts new file mode 100644 index 0000000..6e442dc --- /dev/null +++ b/packages/contracts/src/types/kanban.ts @@ -0,0 +1,108 @@ +// ───────────────────────────────────────────────────────────── +// Core Kanban domain types +// Based on ADR §10 "Core models" and §10 "API contract for v1" +// ───────────────────────────────────────────────────────────── + +/** Hermes Kanban board identifier */ +export type BoardSlug = string; + +export interface Board { + slug: BoardSlug; + name?: string; + description?: string; + icon?: string; + current?: boolean; +} + +// ─── Task ──────────────────────────────────────────────────── + +export type TaskStatus = + | 'triage' + | 'todo' + | 'ready' + | 'running' + | 'blocked' + | 'done' + | 'archived'; + +export interface TaskSummary { + id: string; + title: string; + status: TaskStatus; + assignee?: string | null; + priority?: number | null; + createdAt?: string; + updatedAt?: string; + blockedReason?: string | null; + parentIds?: string[]; + childIds?: string[]; +} + +export interface TaskDetail extends TaskSummary { + body?: string | null; + comments?: TaskComment[]; + runs?: TaskRun[]; + events?: TaskEvent[]; + attachments?: TaskAttachment[]; +} + +// ─── Sub-resources ─────────────────────────────────────────── + +export interface TaskComment { + id?: string; + author?: string; + body: string; + createdAt?: string; +} + +export interface TaskRun { + id?: string; + status?: string; + startedAt?: string; + finishedAt?: string; + logAvailable?: boolean; +} + +export interface TaskEvent { + id?: string; + type: string; + message?: string; + createdAt?: string; +} + +export interface TaskAttachment { + id: string; + filename: string; + sizeBytes?: number; + mimeType?: string; +} + +// ─── App roles (ADR §7) ───────────────────────────────────── + +export type AppRole = 'viewer' | 'operator' | 'admin'; + +export interface AppUser { + telegramId: number; + firstName?: string; + lastName?: string; + username?: string; + role: AppRole; + allowedBoards?: BoardSlug[]; +} + +// ─── Column grouping (used by frontend kanban view) ───────── + +export interface ColumnDef { + status: TaskStatus; + title: string; +} + +export const DEFAULT_COLUMNS: ColumnDef[] = [ + { status: 'triage', title: 'Triage' }, + { status: 'todo', title: 'To Do' }, + { status: 'ready', title: 'Ready' }, + { status: 'running', title: 'Running' }, + { status: 'blocked', title: 'Blocked' }, + { status: 'done', title: 'Done' }, + { status: 'archived', title: 'Archived' }, +]; diff --git a/packages/contracts/src/types/telegram.ts b/packages/contracts/src/types/telegram.ts new file mode 100644 index 0000000..252b674 --- /dev/null +++ b/packages/contracts/src/types/telegram.ts @@ -0,0 +1,88 @@ +// ───────────────────────────────────────────────────────────── +// Telegram Mini App types +// From ADR §2 "Telegram Mini Apps" and §7 auth model +// ───────────────────────────────────────────────────────────── + +/** + * Parsed Telegram WebAppInitData. + * Fields are present after parsing the raw initData query string. + * @see https://core.telegram.org/bots/webapps#webappinitdata + */ +export interface TelegramUser { + id: number; + is_bot?: boolean; + first_name: string; + last_name?: string; + username?: string; + language_code?: string; + is_premium?: boolean; + photo_url?: string; +} + +export interface TelegramChat { + id: number; + type: string; + title?: string; + username?: string; + photo_url?: string; +} + +/** + * Parsed representation of Telegram initData. + * The raw string is query-string-like; the frontend sends it as-is. + * The BFF parses and validates the hash server-side. + */ +export interface TelegramInitData { + /** HMAC hash from Telegram — used for validation, never trusted alone */ + hash: string; + /** Unix timestamp when the data was signed */ + auth_date: number; + /** Bot-provided unique session identifier */ + query_id?: string; + /** The user who opened the Mini App */ + user?: TelegramUser; + /** The chat from which the Mini App was opened */ + chat?: TelegramChat; + /** Chat type if opened from a chat attachment */ + chat_type?: string; + /** Chat instance if opened from a chat attachment */ + chat_instance?: string; + /** Start parameter from t.me link */ + start_param?: string; + /** Arbitrary data from bot link */ + can_send_after?: number; +} + +/** + * Telegram WebApp color scheme object as passed to the Mini App. + * Used for theme-aware UI rendering. + */ +export interface TelegramWebAppThemeParams { + bg_color?: string; + text_color?: string; + hint_color?: string; + link_color?: string; + button_color?: string; + button_text_color?: string; + secondary_bg_color?: string; +} + +/** + * Telegram WebApp platform info. + */ +export interface TelegramWebAppInfo { + /** Color scheme: 'light' or 'dark' */ + colorScheme: 'light' | 'dark'; + /** Current theme parameters */ + themeParams: TelegramWebAppThemeParams; + /** Whether the Mini App is expanded */ + isExpanded: boolean; + /** Current viewport height */ + viewportHeight: number; + /** Current viewport stable height (excluding keyboard) */ + viewportStableHeight: number; + /** Platform identifier (e.g., 'android', 'ios', 'tdesktop', 'web') */ + platform: string; + /** App version */ + version: string; +} diff --git a/packages/contracts/tsconfig.build.json b/packages/contracts/tsconfig.build.json new file mode 100644 index 0000000..cdb8e9a --- /dev/null +++ b/packages/contracts/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..e2541c4 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..9ffc86c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,353 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + packages/bridge: + dependencies: + '@kanban/contracts': + specifier: workspace:* + version: link:../contracts + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^20.14.0 + version: 20.19.43 + tsx: + specifier: ^4.16.0 + version: 4.23.0 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + + packages/contracts: + dependencies: + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + typescript: + specifier: ^5.5.0 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + tsx@4.23.0: + resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + fsevents@2.3.3: + optional: true + + tsx@4.23.0: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..41cfbb4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'packages/*' + # Future: - 'apps/*' for BFF, frontend