Usability Hardening — Public Accounts, Abuse Protection, Admin Observability (2026-07-07)
Three prioritized gaps from the 2026-07-07 readiness review, in build order.
The remaining review items go to docs/ROADMAP.md.
Boundary reminder: public-web and internal-web never import from each
other. Feature 1 mirrors internal-web's D1 auth pattern into public-web with
its own copies (auth stores are app-local, exactly as StaffStore is), not a
shared import.
Feature 1 — Real public accounts (public-web)
Problem
UpgradeAccountDialog POSTs to /api/auth/register, but that route does not
exist in public-web (only a mock lives in internal-web — a pre-split leftover
that also crosses the app boundary). authStore holds the token in
localStorage with no server verification — the same insecure pattern D1
removed for staff. Net: the Tamu→Akun upgrade is dead UI and, if wired, would
be insecure.
Design (mirror D1, scoped to public-web)
- Store: SQLite
public_usersatPUBLIC_DB(default/data/public.sqlite,
shared /data volume) viabetter-sqlite3(already a public-web dep):
id, email UNIQUE, name, pass_hash (bcrypt), token_version, active, created_at, updated_at. Raw NIK is never persisted — it is used
transiently for the Dukcapil check at registration then discarded (public
surface PII rule). No first-boot seed (public users self-register). - Session: httpOnly
ahu_public_sessioncookie, HS256 viajose
(NEXTAUTH_SECRET, 8h expiry), role fixedpublic_account, claims
sub=email,name,tv=token_version,scope=["tanya:history","tanya:filter"].
MirrorssignPublicAccountToken. Secure flag in production only. - Routes (
apps/public-web/src/app/api/auth/): POST /register— validate (email regex, password ≥ 8, NIK 16 digits +
fullName ≥ 2 for register intent), Dukcapil stub, unique-email
(409 email_taken), create user, sign token, set cookie. Returns{user}.POST /login— bcrypt verify (email+password),LOGIN_MIN_MSfloor
against enumeration timing, set cookie,401on failure.GET /me— verify cookie →{user}or401.POST /logout— clear cookie.- Dukcapil: copy the existing pass-through stub into public-web
(src/lib/auth/dukcapil.ts); real Kemendagri API is phase-2 (MoU-gated). - Client:
authStoredrops localStorage token; hydrates via/api/auth/me
on mount;login(user)(no token param — cookie is httpOnly);logout()
calls/api/auth/logout.UpgradeAccountDialogreads{user}from the
response and callslogin(user).useIsTanyaAnonunchanged (still reads
user?.role).
Out of scope (Feature 1)
- Per-account chat history keying (Akun history still uses the doc-RAG
/sessionsbackend as today) → roadmap. - Password reset / email verification → roadmap.
- Real Dukcapil integration (MoU + creds not available).
Dormancy / compat
No new env required to keep today's behavior: with the routes present, an
un-upgraded visitor is still Tamu. NEXTAUTH_SECRET already exists for staff;
public-web reads the same var (dev fallback string as internal-web has).
Feature 2 — Abuse hardening (public-web)
Problem
The daily budget keys only on the anon_session_id cookie — clearing cookies
or incognito resets it. No IP limit, no burst limit, no bot check. Every
question now burns GPU, so this is a real cost/DoS exposure on an
internet-facing surface.
Design
- Per-IP daily cap (
anon:ip:{ip}:day, TTL to end-of-UTC-day) — a
cookie-independent ceiling, defaultANON_IP_DAILY_MAX=40(≈ several
cookie-budgets, generous enough for shared NAT). Client IP from
X-Forwarded-For(rightmost hop is the edge nginx; take the first/leftmost
public value, configurable viaTRUST_PROXY_HOPS, default 1). - Per-IP burst/velocity (
anon:ip:{ip}:burst, INCR with short TTL) —
defaultANON_IP_BURST_MAX=15perANON_IP_BURST_WINDOW_SEC=60. Stops
rapid scripted spam even under the daily cap. - Wiring:
checkIpLimits(req)runs in/api/anon/consume(the pre-flight
the composer already calls) before the cookie DECR. On exceed →429 {error:"ip_rate_limited", retryAfter}. Fail-open on Redis error (matches
the existing counter semantics). Ordering: burst → IP-day → cookie, cheapest
and most-abusive first. - Turnstile (env-gated dormant): when
TURNSTILE_SECRETis set,
/api/anon/consumerequires acf-turnstile-responsetoken, verified
againsthttps://challenges.cloudflare.com/turnstile/v0/siteverify; invalid
→403 {error:"turnstile_failed"}. Unset → skipped entirely (no external
call, byte-identical to today). This is a bot-check, not an LLM call, so the
on-prem LLM constraint does not apply; it stays dormant until keys land. - Admin-configurable IP caps reuse the existing Redis-override pattern
(getDailyLimit) →getIpDailyMax()readinganon:config:ip_daily_max.
Out of scope
- Turnstile widget wiring in the composer UI (needs the site key) — the server
verify path ships dormant; the client widget is a fast follow when keys land. - WAF / edge-level blocking (nginx layer, separate).
Feature 3 — Admin Threads & Sessions explorer (internal-web)
Problem
ROADMAP.md removed the /admin/observe/threads nav until built. For a live
chatbot, admins have no way to see what users actually ask or how the bot
answers — the single most important operational view. Both backends already
exist: /api/admin/traces (+ /traces/[id]) and /api/threads/data.
Design
- Page
apps/internal-web/src/app/(staff)/admin/observe/threads/page.tsx:
a traces table (trace_id, name, status, duration, user_id, session_id,
created_at, error_count) fromGET /api/admin/traces?limit&offset, with
pagination and a row drill-down to the span tree via/api/admin/traces/[id].
Read-only. Follows the existing observe-page shell (mirror
observe/audit/observe/learningsstructure). - Restore the
{ id: "threads", label: "Percakapan", href: "/admin/observe/threads" }
entry toAdminRail.tsxunder the Observasi group. - Gated by the existing admin middleware (
/admin/:path*). No new backend.
Out of scope (→ roadmap)
RAG corpus management, persona/prompt editors, DB schema browser,
model-gateway/provider standalone pages — each needs a new backend.
Global constraints
- Both web suites (
pnpm --filter @ahu/{public,internal}-web test) +pnpm check
(typecheck + conventions) stay green. TDD for every route/store. - New env vars documented in the committed
*.env.example; no live env value
changed without deploy intent. public-web← addbcryptjs+jose(internal-web already has them);
better-sqlite3+ioredisalready present.- Security invariant: no raw NIK persisted; session cookie httpOnly+SameSite=Lax,
Secure in prod.
Success criteria
- Register + login through the real dialog set an httpOnly cookie;
/mereturns
the Akun user; reload keeps the Akun state without a localStorage token;
logout clears it. Duplicate email → 409. - Clearing the
anon_session_idcookie does NOT grant unlimited questions once
the per-IP daily cap is hit; a burst of rapid sends is throttled. Turnstile
path dormant with no secret set. /admin/observe/threadslists real traces and drills into one; nav entry
present; unauth request to the page → redirect to/login.