Files
kairos/apps/server/AGENTS.md
T

434 lines
19 KiB
Markdown

# AGENTS.md - Server App Guidelines
TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui.
## Tech Stack
> **⚠️ This project uses Bun — NOT Node.js / npm. Always use `bun run <script>` (not `bun <script>`). Never use `npm`, `npx`, or `node`.**
- **Framework**: TanStack Start (React 19 SSR, file-based routing)
- **Styling**: Tailwind CSS v4 + shadcn/ui (base-nova style, `@base-ui/react`)
- **Database**: PostgreSQL + Drizzle ORM 0.45.x (`drizzle-orm/postgres-js`)
- **State**: TanStack Query v5 (with MutationCache auto-invalidation)
- **RPC**: ORPC (contract-first, type-safe)
- **Auth**: Better Auth (email+password, single-owner, self-hosted)
- **CLI**: citty (server-side admin commands)
- **DnD**: @dnd-kit/react + @dnd-kit/helpers (`move()` for sortable)
- **Virtualization**: @tanstack/react-virtual (`useVirtualizer`)
- **Hotkeys**: @tanstack/react-hotkeys (`useHotkey` for type-safe keyboard shortcuts)
- **Animation**: motion (page transitions, staggered entrance, layout animation)
- **Command Palette**: cmdk (via shadcn Command component, triggered by ⌘K)
- **API Key**: @better-auth/api-key (API key auth for external integrations like N8N)
- **Build**: Vite + Nitro
## Architecture Overview
### Route Architecture
```
/ → Dashboard overview (greeting, quick bookmarks, module summary)
/bookmarks → Bookmarks page (view mode + edit mode toggle)
/finance → Finance page (transaction list with filters, account/category management)
/settings → Settings page (API key management)
/setup → One-time owner setup (first visit only, redirects to /login after)
/login → Login page (redirects to /setup if no owner exists)
```
- **Unified shell**: All authenticated pages share a global sidebar (`AppSidebar`) and command palette (`⌘K`). There is NO separate admin panel — view and management are integrated in each module page.
- **Single-owner model**: Kairos is a self-hosted Life OS. Only ONE user (the owner) exists. There is NO registration page — `/setup` is a one-time wizard shown on first visit.
- **Module pages** (`/bookmarks`, etc.): Each module page has a view mode (clean display) and an edit mode (CRUD, DnD). Toggle via a button in the page header.
- All authenticated routes under `_protected` layout (auth guard + SidebarProvider + CommandPalette → redirect to `/login`).
### Module System
Modules are directory-based under `src/modules/`. Each module provides:
- `index.ts``ModuleMetadata` (id, name, icon, route)
- `schema.ts` — Drizzle tables
- `contract.ts` — ORPC contracts (input/output Zod schemas)
- `router.ts` — ORPC handlers (business logic)
- `components/` — React UI components
Contracts and routers are registered centrally:
- `src/server/api/contracts/index.ts` — imports all module contracts
- `src/server/api/routers/index.ts` — imports all module routers
## Directory Structure
```
src/
├── client/
│ └── orpc.ts # ORPC client (isomorphic: SSR direct call / CSR fetch)
├── components/
│ ├── AppSidebar.tsx # Unified sidebar (reads module registry, collapsible, includes Settings link in footer)
│ ├── CommandPalette.tsx # ⌘K command palette (search bookmarks, engines, navigation)
│ ├── Error.tsx # Error boundary fallback
│ ├── NotFound.tsx # 404 fallback
│ └── ui/ # shadcn/ui components (可自由修改,添加新组件用 bunx shadcn@latest add)
├── hooks/
│ └── use-mobile.ts
├── lib/
│ └── utils.ts # cn() utility
├── modules/
│ ├── registry.ts # ModuleMetadata interface + modules[]
│ ├── bookmarks/ # Bookmarks module (schema, contract, router, components/)
│ └── finance/ # Finance module
│ ├── index.ts # Module metadata
│ ├── schema.ts # Drizzle tables (financeAccount, transactionCategory, transaction)
│ ├── contract.ts # ORPC contracts (account, category, transaction CRUD + summary)
│ ├── router.ts # ORPC handlers
│ └── components/ # React UI components
│ ├── AccountFormDialog.tsx
│ ├── AccountManager.tsx
│ ├── CategoryFormDialog.tsx
│ ├── CategoryManager.tsx
│ ├── TransactionFormDialog.tsx
│ └── TransactionList.tsx
├── cli/
│ ├── index.ts # citty CLI entrypoint (bun run cli ...)
│ └── commands/auth.ts # auth reset-password command
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout (HTML shell, Toaster)
│ ├── _protected.tsx # Auth guard + unified shell (SidebarProvider, AppSidebar, CommandPalette)
│ ├── _protected/
│ │ ├── index.tsx # Dashboard overview (greeting, quick bookmarks, module cards)
│ │ ├── bookmarks.tsx # Bookmarks page (view/edit toggle, Motion animations)
│ │ ├── finance.tsx # Finance page (transaction list, filters, account/category management)
│ │ └── settings.tsx # Settings page (API key management)
│ ├── login.tsx, setup.tsx
│ └── api/ # $.ts (OpenAPI), auth.$.ts, health.ts, rpc.$.ts
├── server/
│ ├── api/
│ │ ├── contracts/index.ts # Central contract registry
│ │ ├── routers/index.ts # Central router registry
│ │ ├── middlewares/ # dbMiddleware, authMiddleware
│ │ ├── interceptors.ts # Validation error transform
│ │ ├── context.ts # BaseContext type
│ │ ├── server.ts # `os = implement(contract).$context<BaseContext>()`
│ │ └── types.ts # RouterClient type export
│ ├── auth/ # Better Auth (schema, instance, client, getSession, checkInitialized)
│ └── db/ # Drizzle (fields, relations, singleton instance)
├── env.ts # @t3-oss/env-core validation
├── router.tsx # TanStack Router + QueryClient + MutationCache
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
└── styles.css # Tailwind + shadcn CSS variables
```
## ORPC Pattern
### 1. Define Contract (in module: `src/modules/feature/contract.ts`)
```typescript
import { oc } from '@orpc/contract'
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-zod'
import { z } from 'zod'
import * as schema from '@/modules/feature/schema'
import { generatedFieldKeys } from '@/server/db/fields'
const selectSchema = createSelectSchema(schema.myTable)
const insertSchema = createInsertSchema(schema.myTable).omit(generatedFieldKeys).omit({ userId: true })
const updateSchema = createUpdateSchema(schema.myTable).omit(generatedFieldKeys).omit({ userId: true })
export const myResource = {
list: oc.input(z.void()).output(z.array(selectSchema)),
create: oc.input(insertSchema).output(selectSchema),
update: oc.input(z.object({ id: z.uuid(), data: updateSchema })).output(selectSchema),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
}
```
### 2. Implement Router (in module: `src/modules/feature/router.ts`)
```typescript
import { ORPCError } from '@orpc/server'
import * as schema from '@/modules/feature/schema'
import { authMiddleware, dbMiddleware } from '@/server/api/middlewares'
import { os } from '@/server/api/server'
export const myResource = {
list: os.feature.myResource.list
.use(dbMiddleware).use(authMiddleware)
.handler(async ({ context }) => {
return await context.db.query.myTable.findMany({
where: (t, { eq }) => eq(t.userId, context.user.id),
orderBy: (t, { desc }) => desc(t.createdAt),
})
}),
create: os.feature.myResource.create
.use(dbMiddleware).use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db.insert(schema.myTable)
.values({ ...input, userId: context.user.id }).returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create' })
return created
}),
}
```
### 3. Register (`src/server/api/contracts/index.ts` + `routers/index.ts`)
```typescript
// contracts/index.ts
import * as feature from '@/modules/feature/contract'
export const contract = { feature }
// routers/index.ts
import * as feature from '@/modules/feature/router'
export const router = os.router({ feature })
```
### 4. Use in Components
```typescript
const { data } = useSuspenseQuery(orpc.feature.myResource.list.queryOptions())
const mutation = useMutation(orpc.feature.myResource.create.mutationOptions())
```
### MutationCache Auto-Invalidation
`router.tsx` configures a global `MutationCache` that auto-invalidates queries in the same module when any mutation succeeds. No need for manual `queryClient.invalidateQueries()` in most cases.
## UI Component Patterns
### base-ui `render` Prop (CRITICAL)
shadcn/ui uses `@base-ui/react`. The `render` prop replaces Radix's `asChild`:
```typescript
// ✅ CORRECT
<DialogTrigger render={<Button />} />
<SidebarMenuButton render={<Link to="/bookmarks" />}>
// ❌ WRONG — asChild does NOT exist
<DialogTrigger asChild><Button /></DialogTrigger>
```
### Dialog Forms
```typescript
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
{/* fields */}
<DialogFooter><Button type="submit"></Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
```
### DnD Sortable (with @dnd-kit/helpers)
```typescript
import { move } from '@dnd-kit/helpers'
import { DragDropProvider } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
// In sortable item:
const { ref, handleRef, isDragging } = useSortable({ id, index, group })
// In container — use move() for reordering:
const handleDragEnd = (event) => {
if (event.canceled) return
const reordered = move(items, event) // @dnd-kit/helpers handles index mapping
setItems(reordered)
reorderMutation.mutate(reordered.map((item, i) => ({ id: item.id, orderId: i })))
}
<DragDropProvider onDragEnd={handleDragEnd}>
{items.map((item, index) => <SortableItem key={item.id} index={index} />)}
</DragDropProvider>
```
### Virtual Scrolling in Dialogs
Use `useState` callback ref (NOT `useRef`) for scroll elements inside Dialogs — `useRef` doesn't trigger re-render when Dialog mounts:
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollElement, // useState, NOT useRef
estimateSize: () => ROW_HEIGHT,
overscan: 5,
})
<div ref={setScrollElement} className="max-h-80 overflow-y-auto">
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<div key={row.key} style={{ position: 'absolute', transform: `translateY(${row.start}px)` }}>
{/* row content */}
</div>
))}
</div>
</div>
```
### Toast Notifications
```typescript
import { toast } from 'sonner'
toast.success('操作成功')
toast.error('操作失败')
```
### Motion Animations
Use `motion` for page transitions, staggered entrance, and layout animations:
```typescript
import { AnimatePresence } from 'motion/react'
import * as motion from 'motion/react-client'
const containerVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { staggerChildren: 0.06, delayChildren: 0.1 } },
}
const itemVariants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] as const } },
}
<motion.div variants={containerVariants} initial="hidden" animate="visible">
{items.map((item) => (
<motion.div key={item.id} variants={itemVariants}>...</motion.div>
))}
</motion.div>
// AnimatePresence for mode switching (e.g., view ↔ edit)
<AnimatePresence mode="wait">
{editing ? (
<motion.div key="edit" initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.98 }}>
...
</motion.div>
) : (
<motion.div key="view" ...>...</motion.div>
)}
</AnimatePresence>
```
### Keyboard Shortcuts (TanStack Hotkeys)
```typescript
import { useHotkey } from '@tanstack/react-hotkeys'
useHotkey('Mod+K', () => openCommandPalette()) // ⌘K on Mac, Ctrl+K on Windows
useHotkey('Mod+S', () => save())
```
### Command Palette (⌘K)
Global command palette in `_protected` layout. Uses shadcn `CommandDialog` + `@tanstack/react-hotkeys`:
- Search bookmarks by name/URL/category
- Search engine shortcuts: `/g query`, `/gh query`, `/yt query`
- Page navigation: 总览, 书签导航
- Quick actions: 管理书签
## Database (Drizzle ORM 0.45.x)
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
- **Validation**: `drizzle-zod` (separate package, NOT `drizzle-orm/zod`)
- **Relations**: `relations()` from `drizzle-orm` in `src/server/db/relations.ts` — callback syntax
- **Table naming**: No `Table` suffix — `user`, `category`, `bookmark`
- **DB instance**: Module-level singleton `export const db = drizzle(client, { schema })` (NOT factory pattern)
- **Shared fields**: Use `...generatedFields` spread for id/createdAt/updatedAt
- **Auth schema**: Generated by Better Auth CLI (`bun run db:auth`), **never hand-edit**
- **Schema re-export**: `db/schema/index.ts` selectively exports tables only (not relations) from auth schema
- **Migration workflow**: Always `db:generate``db:migrate`. **Never** use `db:push`.
- **Path alias exception**: Files in the Drizzle schema chain (`db/schema/index.ts`, module `schema.ts`) MUST use relative imports — `drizzle-kit` does not resolve `@/*` aliases.
```typescript
// Schema — use generatedFields spread
export const myTable = pgTable('my_table', {
...generatedFields, // id (uuid v7), createdAt, updatedAt
name: text('name').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
})
// Relations — callback syntax
export const myTableRelations = relations(myTable, ({ one }) => ({
user: one(user, {
fields: [myTable.userId],
references: [user.id],
}),
}))
```
## Auth (Better Auth — Single-Owner Model)
Kairos is a self-hosted single-user app. There is NO public registration. The first visit triggers a one-time setup wizard (`/setup`), and all subsequent signup attempts are blocked at the database level.
- **Instance**: `src/server/auth/index.ts``betterAuth()` with `drizzleAdapter(db, { provider: 'pg', schema })`
- **Signup blocking**: `databaseHooks.user.create.before` checks if a user already exists → throws `APIError('FORBIDDEN')` if so
- **Client**: `src/server/auth/client.ts``createAuthClient()` for React
- **Server functions**: `src/server/auth/functions.ts`:
- `getSession()` — get current session via `createServerFn`
- `checkInitialized()` — check if owner account exists (used by `/setup` and `/login` routes)
- **Auth tables**: Use `text` IDs (Better Auth manages its own IDs), NOT project's UUID v7
- **Route guard**: `beforeLoad` in `_protected.tsx` calls `getSession()` → redirect to `/login`
- **ORPC middleware**: `authMiddleware` calls `auth.api.getSession({ headers })` → injects `context.user`
- **Password reset**: Via server CLI only (`bun run cli auth reset-password`) — no web-based password recovery
### API Key Authentication
- **Plugin**: `@better-auth/api-key` with `enableSessionForAPIKeys: true`
- **Auth middleware**: Unchanged — API keys automatically simulate sessions via Better Auth plugin
- **Header**: `x-api-key: kairos_xxxxxxxx` (or `Authorization: Bearer kairos_xxxxxxxx`)
- **Management**: Via `/settings` page UI (create, list, delete)
- **Use case**: N8N workflow automation → parse emails → call ORPC API to create transactions
- **Default prefix**: `kairos_`
## Finance Module
### Data Model
- **financeAccount**: Bank accounts, wallets, credit cards. Fields: name, type (checking/savings/credit/cash/investment/loan), currencyCode (ISO 4217, default CNY), initialBalance (integer, smallest currency unit), icon, isArchived, orderId
- **transactionCategory**: Flat expense/income categories. Fields: name, icon, type (expense/income), orderId
- **transaction**: Financial records. Fields: accountId, categoryId (nullable), type (expense/income), amount (integer, positive, smallest currency unit), description, note, date, source (manual/n8n/import), externalId (unique, for N8N dedup)
### Amount Convention
- Stored as **integer in smallest currency unit** (e.g., ¥120.30 = 12030)
- Always **positive** — type field determines income vs expense
- Display: `(amount / 100).toFixed(2)` with currency symbol
### N8N Integration
External services call ORPC API with API key:
```bash
curl -X POST https://kairos.example/api/rpc/finance.transaction.create \
-H "x-api-key: kairos_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"accountId":"...","type":"expense","amount":3500,"description":"午餐","date":"2026-04-01T12:30:00Z","source":"n8n","externalId":"email-abc123"}'
```
- `externalId` enables idempotent deduplication — duplicate imports return existing transaction
- `source: "n8n"` marks automated imports
### ORPC Endpoints
- `finance.account.list/create/update/remove/reorder`
- `finance.category.list/create/update/remove/reorder`
- `finance.transaction.list/create/update/remove/summary`
## Critical Rules
**DO:**
- Run `bun run fix` before committing
- Use `@/*` path aliases (not relative imports)
- Use `render` prop (NOT `asChild`) for base-ui component delegation
- Use `ORPCError` with proper codes
- Use `drizzle-zod` for schema validation (NOT `drizzle-orm/zod`)
- Use callback syntax for `orderBy` and `where` in relational queries
- Use `move()` from `@dnd-kit/helpers` for DnD reordering
- Use `useState` callback ref for virtualizer scroll elements inside Dialogs
- Use `motion` for page transitions and staggered entrance animations
- Use `useHotkey` from `@tanstack/react-hotkeys` for keyboard shortcuts
**DON'T:**
- Add new `src/components/ui/*.tsx` without CLI (use `bunx shadcn@latest add` to scaffold, then freely customize)
- Edit `src/routeTree.gen.ts` (auto-generated)
- Use `asChild` prop (base-ui uses `render`, NOT Radix)
- Import from `drizzle-orm/zod` (use `drizzle-zod`)
- Use `drizzle-orm/bun-sql` driver
- Hand-edit `src/server/auth/schema.ts` (generated by Better Auth CLI, use `bun run db:auth`)
- Add `Table` suffix to Drizzle table exports
- Use `useRef` for scroll elements inside Dialog/conditional rendering
- Use `db:push` — always use `db:generate``db:migrate`
- Use `@/*` aliases in Drizzle schema files (drizzle-kit can't resolve them)
- Add registration/signup functionality (single-owner model, enforced by `databaseHooks`)
- Create separate admin pages — integrate view/edit modes in each module page
- Use `authClient.apiKey` methods for API key management (not ORPC)
- Store amount as float/decimal — amounts MUST be integer (smallest currency unit)
- Duplicate `externalId` on transactions — must be unique (for N8N dedup)
- Try to retrieve API key after creation — keys are shown only once