Last updated: 2026-06-28 Analyst: Codebase audit (apps/internal-api + apps/internal-dashboard, full source review)
Scorecard
| # | Parameter | Score | Notes |
|---|---|---|---|
| 1 | Authentication | 9.5 | Dual-gate: Cloudflare Access JWT (cryptographically verified via JWKS) + STAFF_EMAILS allowlist; dev bypass is fail-closed (must still pass allowlist); audience pinning (ACCESS_AUD) optional but not yet set |
| 2 | Security | 9.5 | Sensitive columns explicitly excluded from all reads (no databaseConfig, no Razorpay/Stripe IDs); ops DB separate from core DB; workers_dev = false + preview_urls = false (no .workers.dev backdoor); read-only on core DB |
| 3 | Business Logic | 8.5 | Net spend formula correctly handles HOLD/CAPTURE/VOID three-way ledger; MRR handles monthly vs yearly billing cycles; credit consolidation collapses hold/capture/void pairs in transaction history |
| 4 | API Design | 8.5 | Consistent Hono routes; keyset pagination on customer list (stable across concurrent inserts); parallel DB queries in getCustomerDetail; DaysToggle reactive (7/30/90d) |
| 5 | Data Model | 8.5 | Clean ops DB schema: accessLog (accountability with IP + UA), customerNotes, three rollup tables; all core DB reads on carefully allowlisted columns |
| 6 | Audit Trail | 8.0 | access_log written on every GET /customers/:id and POST /customers/:id/notes; includes staff email, action, target tenant, IP, user-agent, timestamp |
| 7 | Frontend Quality | 8.0 | Next.js 16 App Router; reusable primitives (Card, StatCard, Badge, Table, Skeleton, EmptyState, ErrorState); useApi hook with cancellation; recharts LineTrend + custom BarList |
| 8 | Feature Completeness | 7.5 | 6 pages complete (Overview, Customers, Revenue, Credits, Growth, Audit); keyset customer pagination; read-only (no mutations yet); audit log viewer |
| 9 | Error Handling | 7.5 | safe() wrapper for optional DB tables; API throws with message on non-OK; frontend shows ErrorState with retry; credit consolidation handles edge cases |
| 10 | Infrastructure | 8.5 | ops.vlozi.app/api/* Worker route + Vercel Next.js; Cloudflare Access gates entire hostname; dual Neon DBs (core read-replica + ops write DB) |
| 11 | Observability | 6.5 | accessLog provides staff accountability; no metrics, alerts, or structured logging on the ops layer itself; no health endpoint on internal-api |
| 12 | Performance | 5.5 | All analytics reads are LIVE (rollup cron disabled); no index on credit_transactions(created_at) WHERE amount < 0; heavy queries will timeout at scale |
| 13 | Documentation | 5.5 | READMEs in both apps; memory doc exists; no formal spec docs under docs/internal-ops/ until now |
| 14 | Testing | 2.0 | Zero test files in either apps/internal-api or apps/internal-dashboard |
Overall: 7.5 / 10
Bugs
B1 — Live-only queries will timeout at scale (rollup cron disabled)
Severity: High
Location: apps/internal-api/src/services/spend.ts, wrangler.toml
The rollup cron (0 2 * * *) is commented out in wrangler.toml (Cloudflare account is at the 5-cron limit). All analytics reads — especially netConsumed() and netSpendByService() — perform live scans of credit_transactions filtered by created_at. At even a few million rows, these queries take 5–10 seconds and will hit the 30-second Worker CPU limit. There is also no index on (created_at) WHERE amount < 0 to speed up debit-only scans.
Recommended index:
CREATE INDEX CONCURRENTLY credit_tx_created_at_debit_idx
ON credit_transactions (created_at) WHERE amount < 0;B2 — Note add triggers second view_customer audit entry via refetch
Severity: Low
Location: apps/internal-dashboard/src/app/customers/[id]/page.tsx
After a staff member adds a note, the page calls refetch() which re-calls GET /api/admin/customers/:id. The route handler writes view_customer to accessLog on every call. A staff member who adds 5 notes generates 6 audit entries (1 initial view + 5 refetch-triggered views). The audit trail overstates how many times the profile was independently accessed.
Fix: Only log view_customer on GET when the request does NOT include a ?refetch=notes marker (or separate the notes endpoint from the full profile).
B3 — Keyset cursor encodes Date object: timezone-dependent serialisation
Severity: Low
Location: apps/internal-api/src/services/admin-analytics.ts — listCustomers()
The pagination cursor is base64(JSON.stringify({ c: createdAt, id })). createdAt is a JavaScript Date object. JSON.stringify(new Date()) produces an ISO-8601 string in UTC — but if the DB driver returns a Date already adjusted to a non-UTC timezone, the comparison >= cursor.c on subsequent pages uses a different reference time. This could produce duplicate or skipped rows in rare environments where the Neon driver or Node.js runtime uses local timezone for Date construction.
B4 — listAccessLog limit param not validated; accepts any integer
Severity: Low
Location: apps/internal-api/src/routes/admin.ts → GET /audit
The route reads limit = Number(c.req.query("limit") ?? "200") and passes it directly to listAccessLog(opsDb, limit). There is no cap validation — a caller can request limit=1000000 and force a full table scan. The listAccessLog service has a documented max of 500 but doesn't enforce it in code.
B5 — safe() silently hides missing tables on Growth page
Severity: Low
Location: apps/internal-api/src/services/admin-analytics.ts → getGrowth()
Waitlist and feedback queries are wrapped in safe() which returns null on error. If the waitlist or feedback tables don't exist (e.g., a staging DB), the Growth page renders empty sections with no explanation. Staff see no waitlist data and may believe there are zero signups, when actually the table is absent. A null result should surface a visible warning in the UI.
B6 — STAFF_EMAILS allowlist not whitespace-trimmed
Severity: Low
Location: apps/internal-api/src/middleware/staff-auth.ts
The allowlist is parsed with STAFF_EMAILS.split(",") but .trim() is not applied to each entry. If the secret is set as "founder@vlozi.app, hello@vlozi.app" (space after comma), " hello@vlozi.app" with a leading space will never match the JWT email claim. The failure mode is silent 403s for valid staff members.
B7 — No access log retention policy
Severity: Low
Location: apps/internal-api/src/db/schema.ts → accessLog
The accessLog table has no TTL, cleanup job, or partitioning strategy. At 50 customer-profile views per day (conservative for a growing team), it accumulates 18,000+ rows/year. Within 3–4 years this degrades audit query performance. No retention decision is enforced at the schema or infrastructure level.
Areas of Improvement
A1 — Re-enable rollup cron + add credit_tx index
The single most important performance improvement. Free a cron slot (or upgrade account tier), uncomment [triggers] in wrangler.toml, and add the index. This drops overview/credits/growth query time from seconds to milliseconds once rolled up.
A2 — Add customer mutations (suspend, adjust credits)
Current state: read-only. Phase 2 scope includes suspending a tenant and issuing credit adjustments. With credit helpers already in the service and the audit trail built, the infrastructure is ready — only the route and UI components are missing.
A3 — Add test coverage
Both apps/internal-api and apps/internal-dashboard have zero tests. At minimum:
admin-analytics.tsservice functions (spend computation, MRR formula, keyset cursor)- Staff auth middleware (JWT rejection, allowlist miss)
spend.tsnet spend formula (hold/capture/void combinations)
A4 — Move STAFF_EMAILS to ops DB table
Managing staff access via an env var requires a Worker redeployment every time someone joins or leaves. A staffAccess table in ops DB with (email, active, addedBy, addedAt) rows would allow runtime management and produce an audit trail of its own.
A5 — Extract credit helpers to @repo/core-billing
apps/internal-api/src/services/credit-helpers.ts duplicates logic from the manager app. A shared @repo/core-billing package (already referenced in TODOs) would eliminate the drift risk.
A6 — Enable ACCESS_AUD in production
The ACCESS_AUD env var is defined and the JWT validation code supports it, but it's not set in production. Audience pinning binds the JWT to this specific Cloudflare Access application — a leaked JWT from another Access app cannot be replayed here. Set ACCESS_AUD to the Application Audience (AUD) tag from the Cloudflare Access dashboard.
A7 — Log all page views for full accountability
Currently only view_customer and add_note are logged. Revenue, Credits, and Growth pages that show aggregate sensitive data (MRR, top consumers) are not logged. For a complete audit trail, every authenticated page request should write an entry — even if the target is null for non-customer-specific pages.
Known Deferred Items (Not Bugs)
| Item | Status |
|---|---|
| One-time PIN login via Email Routing | Needs CF Email Routing for @vlozi.app + OTP enabled in Access |
| Rollup cron | Disabled (5-cron limit); re-enable when slot freed |
| Feedback/Waitlist optional tables | safe() wrapper is the current mitigation |
| Customer mutations (suspend, refund) | Phase 2 scope |
| Per-service deep activity drill-down | Phase 2 scope |
| Credit helpers extracted to @repo/core-billing | Technical debt, tracked |