Completion notes — Usability Hardening (2026-07-07)
Spec: specs/2026-07-07-usability-hardening-design.md · Plan: plans/2026-07-07-usability-hardening.md
Built in priority order the user set: public accounts → abuse protection → admin threads explorer. Remaining review items → docs/ROADMAP.md.
Feature 1 — Real public accounts (public-web)
Replaced the dead/insecure Akun flow (dialog POSTed to a non-existent
/api/auth/register; token in localStorage) with a real, secure one mirroring
the staff D1 pattern, scoped to public-web (no cross-app import).
| Files | What |
|---|---|
src/lib/auth/{jwt,session,dukcapil,public-store}.ts |
signPublicAccountToken/verifyPublicSessionToken (jose HS256, ahu_public_session httpOnly cookie); PublicUserStore (better-sqlite3 + bcrypt, no NIK column); Dukcapil pass-through stub |
src/app/api/auth/{register,login,me,logout}/route.ts |
real routes; register = NIK+Dukcapil+unique-email (409), login = email+password (401), me/logout via cookie |
src/store/authStore.ts, src/hooks/useAuthHydrate.ts, UpgradeAccountDialog.tsx, tanya/page.tsx |
authStore drops localStorage token → hydrates from /api/auth/me; dialog register→/register, signin→/login, reads {user} only |
package.json |
added bcryptjs + jose |
NIK is never persisted — used transiently for the Dukcapil check then discarded (public-surface PII rule).
Feature 2 — Abuse hardening (public-web)
Cookie-clearing/incognito no longer resets the budget; scripted bursts are throttled.
| Files | What |
|---|---|
src/lib/anon/ip-limits.ts |
getClientIp (XFF, TRUST_PROXY_HOPS), checkIpLimits (burst→daily, INCR+EXPIRE, fail-open), getIpDailyMax (Redis override → env → default 40) |
src/lib/anon/turnstile.ts |
Cloudflare Turnstile verify, dormant unless TURNSTILE_SECRET set (no external call otherwise); fail-closed when enabled |
src/app/api/anon/consume/route.ts |
IP gate + Turnstile run BEFORE the cookie DECR; 429 ip_rate_limited / 403 turnstile_failed. Existing store already surfaces any 429 as the locked banner |
Feature 3 — Admin Threads & Sessions explorer (internal-web)
The single most important operational view for a live chatbot — what users ask,
how the bot answers — restored as a first-class page (ROADMAP-deferred).
| Files | What |
|---|---|
src/lib/data/admin-traces.ts |
tested mapServerTrace/normalizeTraceList (Agno snake_case → rows) |
src/components/admin/observe/ThreadsExplorer.tsx |
paginated table over /api/admin/traces + span-tree drill-down (/api/admin/traces/[id]) |
src/app/(staff)/admin/observe/threads/page.tsx, AdminRail.tsx |
page + restored "Percakapan" nav entry (admin-gated) |
Backends already existed — this was the missing UI. Shares the mapper with the compact settings widget (DRY).
Verification
pnpm checkgreen (the repo's real gate, run by build-and-ship.sh):
workspace typecheck clean; 126 tests pass (streams 8, public-web 98
incl. +26 new, internal-web 20 incl. +5 new);check-conventions.mjsOK.- TDD throughout — every route/store/lib had a failing test first. Auth routes
are driven end-to-end against a real temp SQLite with real bcrypt + jose;
IP-limit ordering + fail-open and Turnstile dormancy asserted directly. - Not yet verified live: no standing dev server (staging-only topology).
Runtime staging verification (register→login→reload→logout from the internet,
IP-cap survives cookie-clear) belongs to the next deploy, which the user has
not yet requested.next lintis non-functional headless (no eslint config —
pre-existing); typecheck + conventions are the effective lint gate.
Env added (documented in public.env.example; live public.env appended)
PUBLIC_DB (default /data/public.sqlite), NEXTAUTH_SECRET (public-web now
signs sessions — dev fallback until set), ANON_IP_DAILY_MAX=40,
ANON_IP_BURST_MAX=15, ANON_IP_BURST_WINDOW_SEC=60, TRUST_PROXY_HOPS=1,
and dormant TURNSTILE_SECRET / NEXT_PUBLIC_TURNSTILE_SITE_KEY.
Interpretation notes
- Kept the existing NIK-gated registration UX (dialog already collects
NIK+name) — made the backend real rather than changing the flow. - Queue/limit copy and PII rules follow the repo's existing conventions.
getClientIpuses standard Nth-from-right XFF semantics (rightmost = closest
trusted proxy);TRUST_PROXY_HOPSselects the hop.