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."
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
| Decision | Value |
|---|---|
| Framework | TanStack Start React |
| Deployment | CF Workers via Nitro adapter (Wrangler) |
| Styling | shadcn/ui + Tailwind CSS |
| Code splitting | TanStack Start built-in — follow framework defaults |
| Capacitor (iter 2) | SPA mode build — no backend changes required |
createServerFncreateServerFn used for SSR data loading only (web context, never called from native Capacitor)https://dashboard.xprivate.education/api/...Capacitor.isNativePlatform() client-side to switch base URL if neededGET /api/v1/whoami before rendering any UI/login (zero UI flash)
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
| Concern | Value |
|---|---|
| API router | Hono 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 |
| Validation | Zod — shared via @packages/service I/O schemas |
// 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
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)
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
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.
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
{ "student:read": true, "report:write": true }
-- 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] })]);
// 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);
| Decision | Value |
|---|---|
| Provider | Neon Postgres 18 — serverless, autoscaling |
| Connection (prod) | CF Hyperdrive — connection pooling, reduced latency |
| Connection (dev) | Direct Neon serverless HTTP driver |
| ORM | Drizzle ORM — 12KB bundle, CF Workers native |
| Migrations | Drizzle Kit — packages/db/migrations/ |
| Primary keys | uuid with DEFAULT gen_uuidv7() — PG18 native, no extension needed |
| Timestamps | All fields: TIMESTAMP WITH TIME ZONE (timestamptz) |
| Duration fields | _ms suffix — stored as millisecond integer precision |
| Naming | snake_case — schema fields, table names, Zod I/O schemas, DTOs |
| Relations | Drizzle relations() defined for all FK tables — ORM-style queries preferred |
rum extension availability on Neon Postgres 18. Fallback if unavailable: GIN index + materialized partial index per entity type (e.g., separate tsvector per student).-- 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 && );
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
| Decision | Value |
|---|---|
| Provider | Cloudflare R2 |
| Access mode | S3-compatible API — presigned PUT URLs. Worker NOT proxy for file bytes. |
| Worker role | URL generation + DB record management only (minimal bandwidth) |
| Use cases | KYC (foto KTP), kontrak PDF, bukti bayar, foto laporan presensi |
| Wrangler binding | DOCUMENTS → xprivate-documents bucket |
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 }
| Decision | Value |
|---|---|
| Backend | SigNoz — OTEL-compatible |
| Instrumentation | @tigorlazuardi/telemetry-js — cloudflare module |
| Protocol | OTLP over HTTP |
| Scope | All @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
| Item | Risk | Fallback |
|---|---|---|
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 |
| Decision | Value |
|---|---|
| Library | Paraglide JS — type-safe, Vite-native, tree-shakeable |
| Iter 1 | Bahasa Indonesia (BI) — default and only locale |
| Iter 2 | English (EN) — add locale file, swap via Paraglide locale switcher |
| Key store | Defined in codebase from iter 1 — no ad-hoc string literals in UI |
| Key format | TBD in sub-task 2.17 (detail session) |