think
16px
820px

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_users at PUBLIC_DB (default /data/public.sqlite,
    shared /data volume) via better-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_session cookie, HS256 via jose
    (NEXTAUTH_SECRET, 8h expiry), role fixed public_account, claims
    sub=email, name, tv=token_version, scope=["tanya:history","tanya:filter"].
    Mirrors signPublicAccountToken. 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_MS floor
    against enumeration timing, set cookie, 401 on failure.
  • GET /me — verify cookie → {user} or 401.
  • 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: authStore drops localStorage token; hydrates via /api/auth/me
    on mount; login(user) (no token param — cookie is httpOnly); logout()
    calls /api/auth/logout. UpgradeAccountDialog reads {user} from the
    response and calls login(user). useIsTanyaAnon unchanged (still reads
    user?.role).

Out of scope (Feature 1)

  • Per-account chat history keying (Akun history still uses the doc-RAG
    /sessions backend 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, default ANON_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 via TRUST_PROXY_HOPS, default 1).
  • Per-IP burst/velocity (anon:ip:{ip}:burst, INCR with short TTL) —
    default ANON_IP_BURST_MAX=15 per ANON_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_SECRET is set,
    /api/anon/consume requires a cf-turnstile-response token, verified
    against https://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() reading anon: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) from GET /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/learnings structure).
  • Restore the { id: "threads", label: "Percakapan", href: "/admin/observe/threads" }
    entry to AdminRail.tsx under 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 ← add bcryptjs + jose (internal-web already has them);
    better-sqlite3 + ioredis already present.
  • Security invariant: no raw NIK persisted; session cookie httpOnly+SameSite=Lax,
    Secure in prod.

Success criteria

  1. Register + login through the real dialog set an httpOnly cookie; /me returns
    the Akun user; reload keeps the Akun state without a localStorage token;
    logout clears it. Duplicate email → 409.
  2. Clearing the anon_session_id cookie 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.
  3. /admin/observe/threads lists real traces and drills into one; nav entry
    present; unauth request to the page → redirect to /login.