feat(contracts): M1 shared TypeScript contracts and bridge skeleton

This commit is contained in:
kisskin 2026-07-10 10:16:39 +03:00
parent 4474880562
commit 0042fb3df0
21 changed files with 1653 additions and 0 deletions

1
.gitignore vendored
View file

@ -16,6 +16,7 @@ __pycache__/
node_modules/ node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
packages/*/dist/
coverage/ coverage/
*.tsbuildinfo *.tsbuildinfo

13
package.json Normal file
View file

@ -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"
}
}

View file

@ -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"
}
}

165
packages/bridge/src/auth.ts Normal file
View file

@ -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>): DevUser {
return {
telegramId: 123456789,
firstName: 'DevUser',
role: 'admin',
...overrides,
};
}

View file

@ -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 <sub>` 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<KanbanSubcommand>([
'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<T>(
subcommand: KanbanSubcommand,
args: string[],
board?: string
): Promise<T> {
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<Board[]> {
return this.exec<Board[]>('boards', []);
}
async listTasks(
board: string,
opts?: { status?: string; assignee?: string; limit?: number }
): Promise<TaskSummary[]> {
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<TaskSummary[]>('list', args, board);
}
async getTask(board: string, taskId: string): Promise<TaskDetail> {
return this.exec<TaskDetail>('show', [taskId], board);
}
async createTask(
board: string,
payload: { title: string; body?: string; assignee?: string; priority?: number }
): Promise<TaskDetail> {
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<TaskDetail>('create', args, board);
}
async addComment(board: string, taskId: string, body: string): Promise<void> {
await this.exec('comment', [taskId, '--body', body], board);
}
async assignTask(board: string, taskId: string, assignee: string): Promise<void> {
await this.exec('assign', [taskId, '--assignee', assignee], board);
}
async promoteTask(board: string, taskId: string, reason?: string): Promise<void> {
const args = [taskId];
if (reason) args.push('--reason', reason);
await this.exec('promote', args, board);
}
async blockTask(board: string, taskId: string, reason: string): Promise<void> {
await this.exec('block', [taskId, '--reason', reason], board);
}
async unblockTask(board: string, taskId: string): Promise<void> {
await this.exec('unblock', [taskId], board);
}
async archiveTask(board: string, taskId: string): Promise<void> {
await this.exec('archive', [taskId], board);
}
async dispatchBoard(board: string): Promise<void> {
await this.exec('dispatch', [], board);
}
async getTaskLog(board: string, taskId: string, lines?: number): Promise<string> {
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;
}

View file

@ -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<void> {
// 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);
}

View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

View file

@ -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"]
}

View file

@ -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"
}
}

View file

@ -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';

View file

@ -0,0 +1,102 @@
// ─────────────────────────────────────────────────────────────
// Zod schemas for API request/response validation
// ─────────────────────────────────────────────────────────────
import { z } from 'zod';
// ─── Envelope ────────────────────────────────────────────────
export function apiOkSchema<T extends z.ZodTypeAny>(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<T extends z.ZodTypeAny>(dataSchema: T) {
return z.union([apiOkSchema(dataSchema), ApiErrorSchema]);
}
// ─── Pagination ──────────────────────────────────────────────
export function paginatedDataSchema<T extends z.ZodTypeAny>(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_-]+$/),
});

View file

@ -0,0 +1,3 @@
export * from './kanban.js';
export * from './api.js';
export * from './telegram.js';

View file

@ -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(),
});

View file

@ -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(),
});

View file

@ -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<T> {
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<T> = ApiOk<T> | ApiError;
// ─── Pagination ──────────────────────────────────────────────
export interface PaginatedData<T> {
items: T[];
nextCursor?: string | null;
totalEstimate?: number;
}
export type PaginatedResponse<T> = ApiOk<PaginatedData<T>> | 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<TaskSummary>;
// ─── 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';

View file

@ -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' },
];

View file

@ -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;
}

View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

View file

@ -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"]
}

353
pnpm-lock.yaml generated Normal file
View file

@ -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: {}

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
packages:
- 'packages/*'
# Future: - 'apps/*' for BFF, frontend