Files
kairos/apps/server/AGENTS.md
T

19 KiB

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.tsModuleMetadata (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)

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)

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)

// 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

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:

// ✅ CORRECT
<DialogTrigger render={<Button />} />
<SidebarMenuButton render={<Link to="/bookmarks" />}>

// ❌ WRONG — asChild does NOT exist
<DialogTrigger asChild><Button /></DialogTrigger>

Dialog Forms

<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)

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:

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

import { toast } from 'sonner'
toast.success('操作成功')
toast.error('操作失败')

Motion Animations

Use motion for page transitions, staggered entrance, and layout animations:

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)

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:generatedb: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.
// 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.tsbetterAuth() 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.tscreateAuthClient() 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:

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:generatedb: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