XPrivate Education — Architecture

Step 2 · Stack Decisions Locked · Schemas Pending (2.10–2.20)
IN PROGRESS Iter 1 Admin-MVP 2026-05-26 Sub-tasks 2.1–2.9 Done
1 Stack Overview
Monorepo
Bun Workspaces NOT runtime
Frontend
TanStack Start React shadcn/ui
API Router
Hono /api/* via Nitro
OpenAPI
@hono/zod-openapi Auto-generated
Auth
Better Auth Google OAuth
RBAC
Roll Your Own Negation ACL
Database
Neon Postgres 18 CF Hyperdrive
ORM
Drizzle ORM Drizzle Kit
Validation
Zod snake_case DTOs
Object Storage
Cloudflare R2 S3 Presigned URL
Observability
SigNoz OTEL telemetry-js
Unit Tests
Vitest In CI
Integration
Vitest Manual (MVP) Neon Branch
E2E Tests
Playwright
CI/CD
Cloudflare Workers CI
i18n
Paraglide JS BI iter 1 · EN iter 2
Runtime
Cloudflare Workers Wrangler
UUID
uuidv7() PG18 native
2 Package Structure
Apps (Deployment Targets)
📦
@apps/web TanStack Start (React SSR + frontend) + Hono API layer. Deploys to CF Workers via Wrangler.
@apps/cron-* Cloudflare Cron Workers. One package per distinct cron job. Trusted internal context.
Packages (Shared Libraries)
🗄️
@packages/db Single source of truth for Drizzle schemas, migrations, client setup.
⚙️
@packages/service All business logic + Zod I/O schemas. Auth-agnostic — never checks permissions.
🔐
@packages/auth RBAC resolution, permission middleware, WhoAmI builder. Shared by web + future consumers.
3 Pending (Next Session)
Schema definitions (2.10–2.20) — resume here Auth schema · 5D Pricing Matrix · Sesi state machine · Cancellation Policy · Audit middleware · Office/Settlement/DocumentUpload/Notification/Anomaly · Bank soal architecture · i18n strategy · Deployment architecture detail · Final write + sign-off
💡
Resume prompt: "Baca plans/RESUME.md dan plans/ARCHITECTURE.md. Lanjut Step 2 schema definitions dari sub-task 2.10 (auth schema). Ikuti session etiquette di §Session Etiquette."
1 Repository Tree
/ ├── apps/ # Deployment targets (@apps/*) │ ├── web/ # TanStack Start (React SSR + Hono API) │ │ ├── src/ │ │ │ ├── routes/ # Frontend routes (TanStack Start) │ │ │ └── api/ # Hono app (/api/*) │ │ └── wrangler.jsonc │ └── cron-<task-name>/ # CF Cron Workers (1 per cron job) │ └── wrangler.jsonc │ ├── packages/ # Shared libraries (@packages/*) │ ├── db/ # Drizzle schema + migrations + client │ │ ├── schema/ # Table definitions (snake_case) │ │ ├── migrations/ # Drizzle Kit migrations │ │ └── client.ts # Neon + Hyperdrive client │ ├── service/ # Business logic + Zod I/O schemas │ │ ├── src/ │ │ └── schemas/ # Zod schemas (separately exported) │ └── auth/ # RBAC resolution + permission middleware │ ├── permissions.ts # Permission const (codebase-defined) │ ├── resolve.ts # RBAC resolution algorithm │ └── middleware.ts # Hono middleware (requirePermission) │ ├── plans/ # Architecture + scope docs ├── sites/ # HTML documentation sites ├── .claude/ # Claude rules + agents (Step 6–7) ├── CLAUDE.md # Root Claude doc (Step 6) ├── package.json # Bun workspace root └── bun.lock
2 Package Dependency Graph
Inter-package dependencies — @apps depend on @packages
flowchart TD
    Web["@apps/web\nTanStack Start + Hono"] --> Auth["@packages/auth\nRBAC + WhoAmI"]
    Web --> Service["@packages/service\nBusiness Logic + Zod"]
    Web --> DB["@packages/db\nDrizzle Schema"]
    Cron["@apps/cron-*\nCF Cron Workers"] --> Service
    Cron --> DB
    Auth --> DB
    Service --> DB
    DB --> Neon[("Neon Postgres 18\nvia CF Hyperdrive")]
    style Web fill:#1e3a8a,stroke:#38bdf8,color:#e2e8f0
    style Cron fill:#1e3a8a,stroke:#60a5fa,color:#e2e8f0
    style Auth fill:#4c1d95,stroke:#a78bfa,color:#e2e8f0
    style Service fill:#064e3b,stroke:#34d399,color:#e2e8f0
    style DB fill:#134e4a,stroke:#2dd4bf,color:#e2e8f0
    style Neon fill:#14532d,stroke:#4ade80,color:#e2e8f0
        
3 Key Invariants
⚠️
@packages/service is auth-agnostic — receives resolved, pre-authenticated input. NEVER checks auth or permissions internally.
🔐
Auth guard happens in @apps/web API routes — Hono middleware (requirePermission) runs before service calls. @packages/auth provides this middleware.
🗄️
@packages/db = single source of truth — all Drizzle schemas live here. No inline schema definitions in app code.
📦
Bun = package manager + builder only — NOT the runtime. All deployed code runs in Cloudflare Workers (Wrangler). No Bun runtime APIs in production code.
Cron jobs are trusted internal context — @apps/cron-* calls @packages/service directly without auth checks. They are not exposed to external clients.
1 Frontend — TanStack Start (React)
DecisionValue
FrameworkTanStack Start React
DeploymentCF Workers via Nitro adapter (Wrangler)
Stylingshadcn/ui + Tailwind CSS
Code splittingTanStack Start built-in — follow framework defaults
Capacitor (iter 2)SPA mode build — no backend changes required
Capacitor-Ready Design Rules
📱
Mobile (iter 2) readiness — enforced from iter 1
  1. All backend logic accessible via explicit Hono API routes — not only createServerFn
  2. createServerFn used for SSR data loading only (web context, never called from native Capacitor)
  3. Mobile calls same Hono API routes via absolute URL: https://dashboard.xprivate.education/api/...
  4. Use Capacitor.isNativePlatform() client-side to switch base URL if needed
  5. No Worker bandwidth wasted — file uploads go direct to R2 (presigned URL), not through Worker
Frontend Auth Interaction
🔄
  1. User hits protected route → FE calls GET /api/v1/whoami before rendering any UI
  2. 401 response → redirect to /login (zero UI flash)
  3. 200 response → load resolved permissions → render dashboard
  4. 403 from any API call → render 403 page inline
  5. WhoAmI cached in app memory for full lifecycle — refresh on logout or explicit session update
2 API Layer — Dual-Mode Server
Request routing in @apps/web (Nitro / CF Workers)
flowchart LR
    Browser(["Browser / Mobile\nCapacitor"]) -->|HTTP| Nitro["Nitro\nCF Workers"]
    Nitro -->|"/ · /dashboard/*\n/login · etc."| TSR["TanStack Start\nSSR Router"]
    Nitro -->|"/__server/*"| SF["createServerFn\nSSR data only"]
    Nitro -->|"/api/*"| Hono["Hono App\n@hono/zod-openapi"]
    Hono --> WhoAmI["/api/v1/whoami"]
    Hono --> Domain["/api/v1/..."]
    Hono --> Docs["/api/docs\nSwagger UI"]
    Hono --> Spec["/api/openapi.json\nOpenAPI 3.x"]
    TSR --> PA["@packages/auth"]
    SF --> PS["@packages/service"]
    Domain --> PA2["@packages/auth\npermission check"]
    Domain --> PS2["@packages/service"]
    style Nitro fill:#1e3a8a,stroke:#38bdf8,color:#e2e8f0
    style Hono fill:#7c2d12,stroke:#fb923c,color:#e2e8f0
    style TSR fill:#1e3a8a,stroke:#93c5fd,color:#e2e8f0
    style SF fill:#1e293b,stroke:#64748b,color:#94a3b8
        
ConcernValue
API routerHono mounted in Nitro for /api/*
OpenAPI spec@hono/zod-openapi — auto-generated from route + Zod definitions
Swagger UI/api/docs via @hono/swagger-ui
Versioning/api/v1/ from start — future versions additive
ValidationZod — shared via @packages/service I/O schemas
WhoAmI Response Shape
// GET /api/v1/whoami — 200 OK
{
  "user_id": "019502c9-...",          // uuidv7
  "name": "Budi Santoso",
  "avatar_url": "https://...",          // nullable
  "roles": ["finance", "scheduler"],   // for UI display only — not used for permission logic
  "permissions": {                      // resolved permission map — only granted included
    "student:read": true,
    "invoice:export": true,
    "schedule:write": true
  },
  "superuser": false                  // Better Auth admin plugin "blessed user" flag
}

// 401 Unauthorized — no valid session cookie
1 Full Auth Journey
Login flow: unauthenticated → Google OAuth → session → dashboard render
sequenceDiagram
    actor User
    participant FE as Frontend (TanStack Start)
    participant API as Worker API (Hono)
    participant BA as Better Auth
    participant DB as Neon Postgres

    User->>FE: Visit protected route
    FE->>API: GET /api/v1/whoami
    API->>BA: Validate session cookie
    BA->>DB: Lookup session
    DB-->>BA: Not found / expired
    BA-->>API: 401 Unauthorized
    API-->>FE: 401
    FE->>FE: Redirect to /login (zero UI flash)

    User->>FE: Click Login with Google
    FE->>BA: Initiate Google OAuth
    BA-->>User: Redirect to Google consent screen
    User-->>BA: Grant consent
    BA->>DB: Upsert user + create session
    BA-->>FE: Set session cookie + redirect to /dashboard

    FE->>API: GET /api/v1/whoami
    API->>BA: Validate session cookie
    BA->>DB: Fetch user + roles
    DB->>DB: Resolve permissions (union + dedupe + negation)
    DB-->>BA: User + resolved permissions
    BA-->>API: User context
    API-->>FE: 200 { user_id, name, permissions, superuser }
    FE->>FE: Cache WhoAmI in app memory
    FE->>FE: Render dashboard (permission-gated UI)
        
2 Invite / Welcome Email Flow
Admin invites student/tutor — invite doubles as welcome email
sequenceDiagram
    actor Admin
    participant API as Worker API
    participant BA as Better Auth
    participant DB as Neon Postgres
    participant Mail as Email Service
    actor Invitee as Student or Tutor

    Admin->>API: POST /api/v1/users/invite {email, role}
    API->>API: requirePermission('user:invite')
    API->>BA: Create invite token for email
    BA->>DB: INSERT invite {email, role, token, expires_at, status=PENDING}
    BA->>Mail: Send invite + welcome email (activation link)
    Mail-->>Invitee: Invite email with link
    API-->>Admin: 200 {invite_id}

    Invitee->>API: GET /invite?token=xxx
    API->>BA: Validate token (not expired, not used)
    BA->>DB: CREATE user record + assign initial role
    BA->>DB: UPDATE invite status=ACCEPTED
    BA-->>Invitee: Redirect to complete profile / set password
        
ℹ️
No Organization plugin — overhead too large. Invite flow handled via Better Auth core + custom invite endpoint. Admin plugin used for blessed user (superuser) detection only.
3 Better Auth Configuration Notes
Plugins used
  • Admin plugin — for blessed user / superuser detection
  • tanstackStartCookies — cookie handling for TanStack Start
  • Custom invite flow (no Organization plugin)
⚠️
superuser field — added via additionalFields (boolean) on Better Auth user schema. The admin plugin's internal role field is used only for detecting blessed users. WhoAmI exposes superuser: boolean derived from this.
1 Permission Resolution Flow
RBAC resolution algorithm — runs in @packages/auth middleware on every API request
flowchart TD
    A(["Incoming API Request"]) --> B["Extract session cookie"]
    B --> C{"Valid session?"}
    C -->|No| D[/"Return 401 Unauthorized"/]
    C -->|Yes| E["Fetch user from DB"]
    E --> F{"superuser = true?"}
    F -->|Yes| G[/"Pass — full access granted\n(no permission checks)"/]
    F -->|No| H["Fetch user_roles"]
    H --> I["For each role:\nfetch role_permissions"]
    I --> J["Union all permission keys\nfrom all roles"]
    J --> K["Dedupe permission set"]
    K --> L["Identify negation entries\n!permission:name format"]
    L --> M["Remove matching positive permission"]
    M --> N["Remove negation entry itself"]
    N --> O["Resolved permissions map\n{ 'student:read': true, ... }"]
    O --> P{"Route requirePermission(x)?"}
    P -->|"resolved[x] === true"| Q[/"next() — authorized"/]
    P -->|"missing or superuser bypassed"| R[/"Return 403 Forbidden"/]
    style D fill:#7f1d1d,stroke:#f87171,color:#fecaca
    style R fill:#7f1d1d,stroke:#f87171,color:#fecaca
    style G fill:#14532d,stroke:#4ade80,color:#dcfce7
    style Q fill:#14532d,stroke:#4ade80,color:#dcfce7
        
2 Multi-Role + Negation ACL Example
Input — User has 2 roles
Role A: "scheduler"
student:read invoice:read report:write
Role B: "restricted"
report:write !invoice:read
↓ union all
After union (raw)
student:read invoice:read report:write report:write !invoice:read
↓ dedupe
After dedupe
student:read invoice:read report:write !invoice:read
↓ apply negation ACL
After negation — invoice:read removed, !invoice:read removed
student:read invoice:read report:write !invoice:read
↓ final resolved
Resolved permissions (sent to client)
{ "student:read": true, "report:write": true }
3 RBAC DB Schema
-- packages/db/schema/rbac.ts (Drizzle)

const roles = pgTable('roles', {
  id:          uuid().primaryKey().default(sql`gen_uuidv7()`),
  name:        text().notNull().unique(),
  description: text(),
  created_at:  timestamptz().defaultNow(),
  updated_at:  timestamptz().defaultNow(),
});

const role_permissions = pgTable('role_permissions', {
  role_id:        uuid().references(() => roles.id),
  permission_key: text().notNull(),  // e.g. "student:read" or "!invoice:read"
  is_negation:    boolean().default(false).notNull(),
}, (t) => [primaryKey({ columns: [t.role_id, t.permission_key] })]);

const user_roles = pgTable('user_roles', {
  user_id:     text().notNull(),  // Better Auth user ID
  role_id:     uuid().references(() => roles.id),
  assigned_by: text(),              // admin user_id
  assigned_at: timestamptz().defaultNow(),
}, (t) => [primaryKey({ columns: [t.user_id, t.role_id] })]);
TypeScript Permission Guard (Hono Middleware)
// packages/auth/src/middleware.ts
export const PERMISSIONS = {
  STUDENT_READ:    'student:read',
  STUDENT_WRITE:   'student:write',
  INVOICE_READ:    'invoice:read',
  INVOICE_EXPORT:  'invoice:export',
  SCHEDULE_WRITE:  'schedule:write',
  USER_INVITE:     'user:invite',
  // ... all permissions exhaustively listed
} as const;

export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];

export function requirePermission(permission: Permission) {
  return createMiddleware(async (c, next) => {
    const resolved = c.get('resolvedPermissions');
    const superuser = c.get('superuser');
    if (superuser || resolved[permission]) return next();
    return c.json({ error: 'Forbidden' }, 403);
  });
}

// Usage in Hono route:
app.post('/api/v1/users/invite', requirePermission(PERMISSIONS.USER_INVITE), handler);
1 Database Decisions
DecisionValue
ProviderNeon Postgres 18 — serverless, autoscaling
Connection (prod)CF Hyperdrive — connection pooling, reduced latency
Connection (dev)Direct Neon serverless HTTP driver
ORMDrizzle ORM — 12KB bundle, CF Workers native
MigrationsDrizzle Kit — packages/db/migrations/
Primary keysuuid with DEFAULT gen_uuidv7() — PG18 native, no extension needed
TimestampsAll fields: TIMESTAMP WITH TIME ZONE (timestamptz)
Duration fields_ms suffix — stored as millisecond integer precision
Namingsnake_case — schema fields, table names, Zod I/O schemas, DTOs
RelationsDrizzle relations() defined for all FK tables — ORM-style queries preferred
2 PostgreSQL Extensions (First Migration)
Extension Purpose Index Type
pg_trgm
Fuzzy string search — username, email, KTP, transaction/session IDs, phone
GIN
btree_gist
Schedule overlap index — enables GiST indexes on scalar types for tstzrange queries
GiST
unaccent
Text normalization before tsvector — strips accents for consistent tokenization
Pre-processing
rum
Compound tsvector + filter queries — enables efficient (tsvector + student_id) index without heap scan
RUM
⚠️
Verify at Step 4: Confirm rum extension availability on Neon Postgres 18. Fallback if unavailable: GIN index + materialized partial index per entity type (e.g., separate tsvector per student).
3 Index Patterns
-- Fuzzy search (pg_trgm GIN) — for string fields searched by admin
CREATE INDEX idx_users_email_trgm ON users USING GIN (email gin_trgm_ops);
CREATE INDEX idx_users_phone_trgm ON users USING GIN (phone gin_trgm_ops);
-- Also on: ktp_number, transaction_id, transaction_no, session_no

-- Full-text search with compound filter (rum) — presensi/progress reports
-- tsvector uses 'simple'/'english' dict (NOT Indonesian — bilingual-ready)
CREATE INDEX idx_progress_reports_fts ON progress_reports
  USING rum (content_tsvector rum_tsvector_ops, student_id);
-- Allows: WHERE student_id = X AND content_tsvector @@ query  (no heap scan)

-- Schedule overlap (btree_gist + tstzrange) — DB-level double-booking prevention
-- PostgreSQL 18 native: WITHOUT OVERLAPS temporal constraint
ALTER TABLE lesson_sessions ADD CONSTRAINT no_tutor_schedule_overlap
  EXCLUDE USING GIST (
    tutor_id WITH =,
    tstzrange(start_at, end_at) WITH &&
  );
4 Integration Test Strategy — Neon Branching
🧪
MVP: Manual only — integration tests run locally on demand, not in CI. CI pipeline runs: typecheck → lint → Vitest unit tests (no DB) → build.
Future (post-MVP) — Neon branch-based integration test flow
flowchart LR
    Prod[("Production\nNeon Branch")] -->|"instant branch\ncopy-on-write"| Test[("Test\nNeon Branch")]
    Test --> Migrate["Apply migrations\nDrizzle Kit"]
    Migrate --> Run["Run Vitest\nintegration tests"]
    Run --> Delete["Delete branch\n(cleanup)"]
    style Prod fill:#14532d,stroke:#4ade80,color:#dcfce7
    style Test fill:#1e3a8a,stroke:#38bdf8,color:#dbeafe
    style Delete fill:#7f1d1d,stroke:#f87171,color:#fecaca
        
1 Object Storage — Cloudflare R2
DecisionValue
ProviderCloudflare R2
Access modeS3-compatible API — presigned PUT URLs. Worker NOT proxy for file bytes.
Worker roleURL generation + DB record management only (minimal bandwidth)
Use casesKYC (foto KTP), kontrak PDF, bukti bayar, foto laporan presensi
Wrangler bindingDOCUMENTSxprivate-documents bucket
File upload flow — client uploads directly to R2, Worker handles metadata only
sequenceDiagram
    participant C as Client (Browser/Mobile)
    participant W as Worker API
    participant R as Cloudflare R2
    participant DB as Neon Postgres

    C->>W: POST /api/v1/upload-url {filename, content_type, context}
    W->>W: Validate auth + requirePermission('document:upload')
    W->>R: Generate presigned PUT URL (S3 API, expires 15m)
    W->>DB: INSERT document_upload {status=PENDING, ...}
    W-->>C: { upload_url, upload_id, expires_in: 900 }
    Note over C,R: Worker NOT in file transfer path — no bandwidth cost
    C->>R: PUT presigned-url (direct upload, binary)
    R-->>C: 200 OK
    C->>W: POST /api/v1/upload-confirm {upload_id, success: true}
    W->>DB: UPDATE document_upload {status=UPLOADED, size_bytes, ...}
    W-->>C: { document_id }
        
2 Observability — SigNoz + OTEL
DecisionValue
BackendSigNoz — OTEL-compatible
Instrumentation@tigorlazuardi/telemetry-js — cloudflare module
ProtocolOTLP over HTTP
ScopeAll @apps/* — web server + cron workers
# Environment variables (wrangler.jsonc vars + secrets)
OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.signoz.io:4318"
OTEL_SERVICE_NAME="xprivate-web"          # or "xprivate-cron-*"
OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,..."
SIGNOZ_INGESTION_KEY="..."                  # secret
1 Partner-Authority Decisions
ORM
Drizzle ORM
API Routing
Hono + @hono/zod-openapi
Validation
Zod
Monorepo
Bun Workspaces
Unit Tests
Vitest — in CI
Integration Tests
Vitest — manual MVP
E2E Tests
Playwright
CI/CD
CF Workers CI
i18n
Paraglide JS
Frontend
TanStack Start
Styling
shadcn/ui + Tailwind
Runtime
CF Workers + Wrangler
2 CI Pipeline (MVP)
🔍
Type Check
tsc --noEmit
🧹
Lint
ESLint / Biome
🧪
Unit Tests
Vitest (no DB)
🏗️
Build
Wrangler build
🚀
Deploy
CF Workers CI
ℹ️
Integration tests NOT in CI (MVP) — run manually on demand. Post-MVP: Neon branch → migrate → test → delete branch (see Database tab for flow diagram).
3 Open Verification Items (resolve at Step 4)
ItemRiskFallback
rum extension availability on Neon Postgres 18 Medium GIN index + materialized partial tsvector column per entity
Better Auth + TanStack Start + CF Workers timeout (Oct 2025 issue) Low — likely resolved May 2026 File GitHub issue + async boundary workaround
Hono mounted in Nitro (TanStack Start) — integration stability Low — documented pattern Use createAPIFileRoute as Hono passthrough
Paraglide JS + TanStack Start routing integration Low i18next as fallback
4 i18n Strategy — Paraglide JS
DecisionValue
LibraryParaglide JS — type-safe, Vite-native, tree-shakeable
Iter 1Bahasa Indonesia (BI) — default and only locale
Iter 2English (EN) — add locale file, swap via Paraglide locale switcher
Key storeDefined in codebase from iter 1 — no ad-hoc string literals in UI
Key formatTBD in sub-task 2.17 (detail session)