Working context for AI agents on lara-app — a multi-tenant WhatsApp automation platform. Read this before writing code. Non-negotiables: run tasks via the Makefile targets (they wrap Bun + Turbo) · use Bun for package installs (never npm/yarn) · build
sharedfirst · runmake type-check && make lintbefore claiming a task done · prefer existing patterns/utilities over inventing new ones. The Critical Gotchas section is hard-won from production — every item is a bug that already bit us once. Treat it as load-bearing.
TypeScript monorepo: Bun runtime + Turbo build + Hono backend + React/Vite/TanStack Router frontend. Multi-tenant WhatsApp automation platform with JWT auth, subscription billing (Midtrans), and tier-based quotas.
# Development
make dev # Start all (client:5173, server:3000)
make dev-client # Client only
make dev-server # Server only
# Build
make build # Build all with Turbo caching
make build-shared # Build shared first (required)
# Quality
make lint # Lint, format, and autofix
make type-check # Type-check all packages
# Tests
make test # All tests
make test-auth # Auth tests
# Database (wraps scripts/db-manager.ts, reads DATABASE_URL from server/.env)
make db-fresh # Drop + create + seed (DEV ONLY)
make db-migrate # Run migrations from db/migrations/
make db-status # Show tables with row counts
make db-add-user EMAIL=<email> USERNAME=<username> PHONE=<phone> [PASSWORD=<pw>] [TENANT_ID=<id>]lara-app/
├── shared/ # Types (build first!) - import as "shared" (client) or "shared/dist" (server)
├── server/ # Hono API - entry: src/index.ts
├── client/ # React + Vite - entry: src/main.tsx, routes: src/routes/
├── db/ # schema.sql, seed.sql, migrations/
├── k8s/ # Kubernetes manifests (configmap, secret, deployments)
└── scripts/ # db-manager.ts, etc.
- Shared Types: Edit
shared/src/types/, rebuild, then import - Server Routes: Hono method chaining in
server/src/routes/ - Client Routes: File-based in
client/src/routes/(TanStack Router) - Auth: JWT (15min access, 7d refresh),
authMiddleware,requireSystemRole - Multi-Tenant: V3 architecture — each user has
waServiceId+waPhone, resolved viawa_servicestable - Roles: System (
user,admin,root) + Tenant (owner,admin,manager,user,viewer) - Tiers:
free | pro | enterprisewith quota enforcement via@middleware/quota - Encryption: API keys encrypted at rest (AES-256-GCM) via
@lib/encryption. Always usedecryptSecret()when readingws.api_keyorusers.wa_api_keyfrom DB - Audit Logging: Fire-and-forget via
auditLog()from@lib/audit
// Client: @/* → src/*, @components/*, @lib/*, @hooks/*, etc.
// Server: @/* → src/*, @lib/*, @routes/*, @middleware/*
// Use @/types/* NOT @types/* (conflicts with TS declarations)
import type { AuthUser } from '@shared'- 2 spaces, 120 chars, single quotes, no semicolons
- Arrow parens: always, trailing comma: ES5, JSX: double quotes
Client: VITE_API_BASE_URL, VITE_MIDTRANS_CLIENT_KEY, VITE_MIDTRANS_IS_PRODUCTION
Server: DATABASE_URL, JWT_SECRET, REDIS_URL, ENCRYPTION_KEY, MIDTRANS_SERVER_KEY, MIDTRANS_CLIENT_KEY, GROQ_API_KEY, META_WHATSAPP_*, NATS_URL, S3_*, SENTRY_*
// WRONG - passes encrypted "enc:..." string to external API
headers: {'X-API-Key': row.api_key}
// CORRECT - decrypt before use
import { decryptSecret } from '@lib/encryption'
headers: {'X-API-Key': decryptSecret(row.api_key)}
// Note: decryptSecret() is migration-friendly — plaintext values pass through unchanged// WRONG - breaks jsonb_array_length()
${JSON.stringify(data)}
// CORRECT
${sql.json(data)}
// For complex types (e.g. SubscriptionPlan[]):
${sql.json(value as unknown as Parameters<typeof sql.json>[0])}With PgBouncer (prepare: false), PostgreSQL returns TEXT[] and JSONB as strings instead of parsed objects.
// WRONG - will fail or corrupt data when PgBouncer returns strings
tags: row.tags || []
recipients: row.recipients
// CORRECT - use shared helpers from @lib/db
import { parseTextArrayField, parseJsonbField } from '@lib/db'
// For TEXT[] columns (tags, scope_jids, custom_columns, etc.)
tags: parseTextArrayField(row.tags as unknown as string[] | string)
// For JSONB columns (recipients, webhook_headers, etc.)
recipients: parseJsonbField(row.recipients, [])
contentApiHeaders: parseJsonbField(row.contentApiHeaders, {})Affected column types:
TEXT[]: Returns as{tag1,tag2}string literalJSONB: Returns as'{"key":"value"}'JSON string
Files with TEXT[] columns: users.tags, scheduled_campaigns.tags, broadcast_*_templates.*, auto_response_rules.scope_jids/exclude_jids
For local development, use direct PostgreSQL (port 5432) instead of PgBouncer (port 5433) to avoid:
- PgBouncer authentication complexity (userlist.txt, auth_query)
- TEXT[]/JSONB string parsing issues
- Prepared statement restrictions
# SSH tunnel configuration (~/.ssh/config)
Host k3s-tunnel
LocalForward 5432 10.x.x.x:5432 # Direct PostgreSQL (db-rw)
LocalForward 5433 10.x.x.y:5432 # PgBouncer (db-pgbouncer-rw)
# Use port 5432 in server/.env for local dev
DATABASE_URL=postgresql://app:password@localhost:5432/<db_name>Note: PgBouncer is still valuable for production (connection pooling, HA), but direct PostgreSQL is simpler for local development.
// WRONG - Tailwind uses static analysis
hover:${variable}
// CORRECT
hover:brightness-110- Proxy URLs need JWT tokens (auto-generated via
/downloadendpoint) - Falls back to WhatsApp CDN on proxy failure
Auth uses passwordless OTP (WhatsApp or email). In dev mode with OTP_WHATSAPP_PROVIDER=mock, the OTP code is logged to console.
app@example.com— Root user (system admin, wa_phone: 62XXXXXXXXXXX)
- Always use
bun, not npm/yarn - Build
sharedbefore other packages - Use
Layout+ProtectedRoutefor new pages - Glassmorphism:
bg-white/70 backdrop-blur-xl ring-1 ring-white/50 - Always
decryptSecret()when reading API keys from DB - Use
auditLog()for security-sensitive operations