think
16px
820px

Plan D — Deferred Backlog Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Close the four unblocked Plan C deferrals: durable ahu-net attach for the RAG stack (D3), local TEI embeddings for dash knowledge (D4), sql-guard enforcement on the live public agent (D2), and real server-side staff auth replacing nginx Basic Auth (D1).

Architecture: Spec at docs/superpowers/specs/2026-07-02-plan-d-backlog-design.md. Deploy order D3 → D4 → D2 → D1 (infra first; auth last because it changes edge behavior). Three corrections discovered during plan research supersede spec details: (1) the Python sql-guard already exists and is tested in apps/public-agent (app/sql_guard/, dash/tools/public_sql.py) — the real gap is deployment env (DASH_VARIANT unset → live container registers the unguarded dash agent as data-agent); D2 is therefore env wiring + verification, not a port. (2) Dash pgvector re-ingest uses python -m dash.scripts.load_knowledge --recreate in-container — the knowledge-reingest BullMQ queue targets the Milvus/RAG stack, not dash. (3) Staff roles are the app's real UserRole values admin | it_staff | staff, not the spec's ('admin','staf').

Tech Stack: Next.js 15 App Router (internal-web: jose, better-sqlite3, zustand, vitest; new dep bcryptjs), Python 3.12 Agno agents (pgvector, sqlglot), TEI (tei-qwen3-embed, host port 8100 on Server 2), Docker compose stacks on Server 2 (obert@192.168.83.20, stacks at /home/obert/ahu-ai-staging/infra), edge nginx on this box.

Conventions used below:
- Repo root = /home/efran/remote-development/poc-ahu-ai/ahu-ai-chatbot. All local commands run from repo root.
- SSH2 = ssh obert@192.168.83.20.
- Ship = ./infra/deploy/build-and-ship.sh (builds all 4 images, ships + loads on Server 2). Per-stack restart = ssh obert@192.168.83.20 "cd /home/obert/ahu-ai-staging/infra && docker compose -p <proj> -f compose.<stack>.yaml up -d".
- Python tests: run with the repo venv .venv/bin/python -m pytest from the app dir. If an import needs packages absent locally, run the same pytest inside the deployed container instead: docker exec ahu-ai-agent-public python -m pytest tests/... -v.
- Markdown render-back rule applies to this file after commit (curl -F "file=@docs/superpowers/plans/2026-07-02-monorepo-split-plan-d-backlog.md" https://x056.think.val.id/upload).


Phase D3 — Durable ahu-net attach for ai-ahu-rag

Task 1: Make the RAG stack's ahu-net attach survive recreate

The ai-ahu-rag container on Server 2 was attached to ahu-net with a one-off docker network connect; a --force-recreate drops it and breaks the public orchestrator's http://ai-ahu-rag:8110 resolution. Fix the RAG stack's own compose (sibling repo on Server 2 at /home/obert/projects/ai-ahu-document-rag). No files in this repo change.

Files:
- Modify (remote): obert@192.168.83.20:~/projects/ai-ahu-document-rag/compose.yaml

  • [x] Step 1: Back up and fetch the remote compose
ssh obert@192.168.83.20 "cp ~/projects/ai-ahu-document-rag/compose.yaml ~/projects/ai-ahu-document-rag/compose.yaml.bak-plan-d"
scp obert@192.168.83.20:~/projects/ai-ahu-document-rag/compose.yaml /tmp/rag-compose.yaml
  • [x] Step 2: Edit /tmp/rag-compose.yaml

Two edits (use the Read + Edit tools on /tmp/rag-compose.yaml; exact indentation must match the file):

  1. In the ai-ahu-rag service's networks: list (currently - ai-ahu-rag-net and - milvus), add:
      - ahu-net
  1. In the top-level networks: block (which already declares ai-ahu-rag-net and milvus as external), add:
  ahu-net:
    external: true

Only the ai-ahu-rag service gets the new network — do not attach Milvus or other services.

  • [x] Step 3: Push back and validate config
scp /tmp/rag-compose.yaml obert@192.168.83.20:~/projects/ai-ahu-document-rag/compose.yaml
ssh obert@192.168.83.20 "cd ~/projects/ai-ahu-document-rag && docker compose config --quiet && echo CONFIG-OK"

Expected: CONFIG-OK. If it errors, diff against the .bak-plan-d backup and fix indentation.

  • [x] Step 4: Recreate and verify the attach is durable
ssh obert@192.168.83.20 "cd ~/projects/ai-ahu-document-rag && docker compose up -d --force-recreate ai-ahu-rag"
ssh obert@192.168.83.20 "docker inspect ai-ahu-rag --format '{{range \$k, \$v := .NetworkSettings.Networks}}{{\$k}} {{end}}'"

Expected: network list includes ahu-net (alongside the rag/milvus networks) after a force-recreate — that is the durability proof.

  • [x] Step 5: Verify orchestrator resolution + RAG health
ssh obert@192.168.83.20 "docker exec ahu-ai-chatbot-public node -e \"fetch('http://ai-ahu-rag:8110/health').then(r=>r.text()).then(t=>{console.log('RAG-OK',t)}).catch(e=>{console.error('RAG-FAIL',e.message);process.exit(1)})\""

Expected: RAG-OK ... (any 200 body). If Milvus connectivity broke, check the service still lists its original networks (Step 2 must ADD, not replace).

  • [x] Step 6: End-to-end public doc query
curl -sS -N -X POST https://x056.ahu-demo.chatbot-neo.val.id/api/chat/public \
  -H 'Content-Type: application/json' \
  -d '{"message":"Apa syarat pendirian PT?","sessionId":"plan-d3-smoke"}' | head -40

Expected: SSE stream with Indonesian answer content sourced from the doc corpus (not an error event). If the route payload shape rejects this (422), read apps/public-web/src/app/api/chat/public/route.ts for the exact body shape and retry.

  • [x] Step 7: Record completion (no repo commit — external change only)

Note in the session that D3 is done; the compose backup compose.yaml.bak-plan-d on Server 2 is the rollback.


Phase D4 — Local embeddings via tei-qwen3-embed

Both agents hardcode OpenAIEmbedder(id="text-embedding-3-small") (violates the on-prem constraint; returns 0-dim vectors without an OpenAI key). Reuse the existing tei-qwen3-embed container (TEI, Qwen/Qwen3-Embedding-4B, host port 8100, OpenAI-compatible /v1/embeddings). LAN IP 192.168.83.20:8100 is deliberate: TEI sits on ai-ahu-rag-net, agents on ahu-net/ahu-gateway-net, so container-name routing is unavailable; the host-published port is durable.

Task 2: Embedder factory in public-agent (TDD)

Files:
- Create: apps/public-agent/dash/embedder.py
- Create: apps/public-agent/tests/test_embedder.py
- Modify: apps/public-agent/dash/agents.py (imports at ~line 12; embedder args at lines 194 and 208)

  • [x] Step 1: Write the failing test

Create apps/public-agent/tests/test_embedder.py:

"""build_embedder(): env-gated local TEI vs legacy dev fallback."""


def test_fallback_without_env(monkeypatch):
    for var in ("EMBEDDER_BASE_URL", "EMBEDDER_MODEL", "EMBEDDER_DIM", "EMBEDDER_API_KEY"):
        monkeypatch.delenv(var, raising=False)
    from dash.embedder import build_embedder

    e = build_embedder()
    assert e.id == "text-embedding-3-small"


def test_local_tei_when_env_set(monkeypatch):
    monkeypatch.setenv("EMBEDDER_BASE_URL", "http://192.168.83.20:8100/v1")
    monkeypatch.setenv("EMBEDDER_MODEL", "Qwen/Qwen3-Embedding-4B")
    monkeypatch.setenv("EMBEDDER_DIM", "2560")
    monkeypatch.setenv("EMBEDDER_API_KEY", "EMPTY")
    from dash.embedder import build_embedder

    e = build_embedder()
    assert e.id == "Qwen/Qwen3-Embedding-4B"
    assert e.base_url == "http://192.168.83.20:8100/v1"
    assert e.dimensions == 2560
    assert e.api_key == "EMPTY"


def test_defaults_when_only_base_url_set(monkeypatch):
    for var in ("EMBEDDER_MODEL", "EMBEDDER_DIM", "EMBEDDER_API_KEY"):
        monkeypatch.delenv(var, raising=False)
    monkeypatch.setenv("EMBEDDER_BASE_URL", "http://192.168.83.20:8100/v1")
    from dash.embedder import build_embedder

    e = build_embedder()
    assert e.id == "Qwen/Qwen3-Embedding-4B"
    assert e.dimensions == 2560
  • [x] Step 2: Run test to verify it fails
cd apps/public-agent && ../../.venv/bin/python -m pytest tests/test_embedder.py -v

Expected: FAIL / ERROR with ModuleNotFoundError: No module named 'dash.embedder'. (If agno itself is missing locally, run in-container per the header convention — but the repo venv ran evals, so it should be present.)

  • [x] Step 3: Write the implementation

Create apps/public-agent/dash/embedder.py:

"""Embedder factory.

EMBEDDER_BASE_URL set  -> local TEI (OpenAI-compatible /v1/embeddings) — the
                          on-prem production path (tei-qwen3-embed).
EMBEDDER_BASE_URL unset -> legacy OpenAI dev fallback (unchanged behavior).
"""

from os import getenv

from agno.knowledge.embedder.openai import OpenAIEmbedder


def build_embedder() -> OpenAIEmbedder:
    base_url = getenv("EMBEDDER_BASE_URL")
    if not base_url:
        return OpenAIEmbedder(id="text-embedding-3-small")
    return OpenAIEmbedder(
        id=getenv("EMBEDDER_MODEL", "Qwen/Qwen3-Embedding-4B"),
        base_url=base_url,
        api_key=getenv("EMBEDDER_API_KEY", "EMPTY"),
        dimensions=int(getenv("EMBEDDER_DIM", "2560")),
    )
  • [x] Step 4: Run test to verify it passes
cd apps/public-agent && ../../.venv/bin/python -m pytest tests/test_embedder.py -v

Expected: 3 passed. If base_url/api_key/dimensions attribute names differ on this agno version, check python -c "from agno.knowledge.embedder.openai import OpenAIEmbedder; import inspect; print(inspect.signature(OpenAIEmbedder.__init__))" and adjust the test asserts (constructor kwargs above match agno's OpenAIEmbedder dataclass fields).

  • [x] Step 5: Wire into apps/public-agent/dash/agents.py

Three edits:

  1. After the existing import block (near line 12, from agno.knowledge.embedder.openai import OpenAIEmbedder), the import of OpenAIEmbedder becomes unused once both call sites switch — replace it:
from dash.embedder import build_embedder

(Delete the from agno.knowledge.embedder.openai import OpenAIEmbedder line only if nothing else in the file references OpenAIEmbedder — verify with grep -n OpenAIEmbedder apps/public-agent/dash/agents.py.)

  1. Line ~194 (dash_knowledge PgVector): replace
        embedder=OpenAIEmbedder(id="text-embedding-3-small"),

with

        embedder=build_embedder(),
  1. Line ~208 (dash_learnings PgVector): same one-line replacement.
  • [x] Step 6: Sanity-import + full public-agent test sweep
cd apps/public-agent && ../../.venv/bin/python -c "import dash.embedder; print('import-ok')"
cd apps/public-agent && ../../.venv/bin/python -m pytest tests/test_embedder.py -v

Expected: import-ok, 3 passed. (Do not import dash.agents locally — it opens DB config at import time.)

  • [x] Step 7: Commit
git add apps/public-agent/dash/embedder.py apps/public-agent/tests/test_embedder.py apps/public-agent/dash/agents.py
git commit -m "feat(d4): env-gated local TEI embedder for public agent"

Task 3: Embedder factory in internal-agent (mirror of Task 2)

The two agents are deliberately separate codebases (public/internal isolation), so the code is duplicated, not shared.

Files:
- Create: apps/internal-agent/dash/embedder.py
- Create: apps/internal-agent/tests/test_embedder.py
- Modify: apps/internal-agent/dash/agents.py (same lines: import ~12, embedder args at 194 and 208)

  • [x] Step 1: Create the same two new files with identical content

apps/internal-agent/dash/embedder.py — byte-identical to the Task 2 Step 3 code block.
apps/internal-agent/tests/test_embedder.py — byte-identical to the Task 2 Step 1 code block.

cp apps/public-agent/dash/embedder.py apps/internal-agent/dash/embedder.py
cp apps/public-agent/tests/test_embedder.py apps/internal-agent/tests/test_embedder.py

(If apps/internal-agent/tests/ lacks an __init__.py while public's has one, mirror that too: ls apps/internal-agent/tests/.)

  • [x] Step 2: Apply the same three agents.py edits

In apps/internal-agent/dash/agents.py: add from dash.embedder import build_embedder, replace both embedder=OpenAIEmbedder(id="text-embedding-3-small"), lines (194, 208) with embedder=build_embedder(),, drop the now-unused OpenAIEmbedder import if grep -n OpenAIEmbedder apps/internal-agent/dash/agents.py shows no other use.

  • [x] Step 3: Run tests
cd apps/internal-agent && ../../.venv/bin/python -m pytest tests/test_embedder.py -v

Expected: 3 passed.

  • [x] Step 4: Commit
git add apps/internal-agent/dash/embedder.py apps/internal-agent/tests/test_embedder.py apps/internal-agent/dash/agents.py
git commit -m "feat(d4): env-gated local TEI embedder for internal agent"

Task 4: EMBEDDER_* env plumbing

Both agent containers read env/shared.env + their own env file; embedder config is shared → put it in shared.env.

Files:
- Modify: infra/env/shared.env.example

  • [x] Step 1: Append to infra/env/shared.env.example
cat >> infra/env/shared.env.example <<'EOF'

# Local embeddings (D4) — tei-qwen3-embed on Server 2, OpenAI-compatible.
# LAN IP on purpose: TEI is on ai-ahu-rag-net, agents on ahu-net — no
# container-name route exists; the host-published port is durable.
EMBEDDER_BASE_URL=http://192.168.83.20:8100/v1
EMBEDDER_MODEL=Qwen/Qwen3-Embedding-4B
EMBEDDER_DIM=2560
EMBEDDER_API_KEY=EMPTY
EOF
  • [x] Step 2: Apply the same block to the real local env file (infra/env/shared.env, git-ignored — deploy-staging.sh scp's it to Server 2):
grep -q EMBEDDER_BASE_URL infra/env/shared.env || cat >> infra/env/shared.env <<'EOF'

EMBEDDER_BASE_URL=http://192.168.83.20:8100/v1
EMBEDDER_MODEL=Qwen/Qwen3-Embedding-4B
EMBEDDER_DIM=2560
EMBEDDER_API_KEY=EMPTY
EOF
grep EMBEDDER infra/env/shared.env

Expected: the four vars echoed back.

  • [x] Step 3: Commit
git add infra/env/shared.env.example
git commit -m "feat(d4): EMBEDDER_* env for local TEI embeddings"

Task 5: Deploy D4 + re-ingest pgvector at 2560 dims

Vector dim changes 1536 → 2560; no in-place migration — drop and re-ingest. dash_knowledge reloads from the mounted knowledge dirs via dash.scripts.load_knowledge --recreate (which calls vector_db.drop()/create() then re-inserts tables/ queries/ business/); dash_learnings is dynamic — drop/create empty and let it repopulate.

  • [x] Step 1: Sanity-check TEI before shipping
curl -sS http://192.168.83.20:8100/v1/embeddings -H 'Content-Type: application/json' \
  -d '{"model":"Qwen/Qwen3-Embedding-4B","input":"pendirian PT"}' | head -c 300; echo

Expected: JSON starting {"object":"list","data":[{"object":"embedding",... (2560 floats). If unreachable, stop — D4 blocks here.

  • [x] Step 2: Build + ship all images, redeploy stacks
./infra/deploy/build-and-ship.sh
./infra/deploy/deploy-staging.sh

Expected: script ends Done. Staging URLs: with public health OK. This also rsyncs infra/ and scp's the updated shared.env.

  • [x] Step 3: Confirm the agents actually see the env
ssh obert@192.168.83.20 "docker exec ahu-ai-agent-internal printenv EMBEDDER_BASE_URL EMBEDDER_DIM && docker exec ahu-ai-agent-public printenv EMBEDDER_BASE_URL EMBEDDER_DIM"

Expected: http://192.168.83.20:8100/v1 / 2560 twice.

  • [x] Step 4: Recreate knowledge + learnings tables and re-ingest

Both agents share the same Postgres (ahu-dash-db) tables, so run once, from the internal agent (its knowledge mount is the superset):

ssh obert@192.168.83.20 "docker exec ahu-ai-agent-internal python -m dash.scripts.load_knowledge --recreate"
ssh obert@192.168.83.20 "docker exec ahu-ai-agent-internal python -c \"from dash.agents import dash_learnings; dash_learnings.vector_db.drop(); dash_learnings.vector_db.create(); print('learnings-recreated')\""

Expected: Recreating knowledge base..., per-dir file counts, Done!; then learnings-recreated. Errors mentioning expected 2560 dimensions mean an old table survived — re-run the --recreate.

  • [x] Step 5: Verify hybrid search returns non-zero scores via TEI
ssh obert@192.168.83.20 "docker exec ahu-ai-agent-internal python -c \"
from dash.agents import dash_knowledge
res = dash_knowledge.search('syarat pendirian PT')
print('results:', len(res))
print('first:', str(res[0])[:200] if res else 'NONE')
\""

Expected: results: ≥ 1 with real content. Then confirm no OpenAI leakage:

ssh obert@192.168.83.20 "docker logs --since 10m ahu-ai-agent-internal 2>&1 | grep -i 'openai_api_key\|api.openai.com' || echo CLEAN"
ssh obert@192.168.83.20 "docker logs --since 10m ahu-ai-agent-public 2>&1 | grep -i 'openai_api_key\|api.openai.com' || echo CLEAN"

Expected: CLEAN twice (spec success criterion 6; on-prem constraint).

  • [x] Step 6: Live staff data query exercising knowledge

Open https://x056.ahu-demo.chatbot-neo-staff.val.id/app/data (Basic Auth still on at this point) and ask a question that hits knowledge (e.g. "Berapa jumlah PT yang terdaftar tahun ini?"). Expected: agent answers with knowledge-informed SQL, no embedding errors in docker logs ahu-ai-agent-internal.


Phase D2 — sql-guard enforcement on the live public agent

Research finding (supersedes spec §D2 scope): the guarded public variant already exists and is tested in this repo — apps/public-agent/app/sql_guard/{validator.py,result_filter.py} (sqlglot rules + k-anonymity result filter), dash/tools/public_sql.py (guarded run_sql_query returning Indonesian refusals, policy-driven via POLICY_CTX), dash/agents.py:652 (public_dash agent, id public_dash), and app/main.py:97-115 (registers public_dash only when DASH_VARIANT=public; default "staff" registers the unguarded dash agent, whose auto-id is data-agent). The live ahu-ai-agent-public has DASH_VARIANT unset → public traffic currently reaches the unguarded agent. The fix is two env values + verification. The TS validator (apps/public-web/src/lib/orchestrator/sql-guard/, 13 vitest cases) stays as the reference implementation — no DataTool wiring (DataTool never sees SQL or rows).

Task 6: Flip the public stack to the guarded variant (repo config)

Files:
- Modify: infra/compose.public.yaml (public-agent environment: block, ~line 37)
- Modify: infra/env/public.env.example (~line 16)

  • [x] Step 1: Add DASH_VARIANT: public to the public-agent service

In infra/compose.public.yaml, the public-agent environment block currently reads:

    environment:
      SURFACE: public
      KNOWLEDGE_DIR: /knowledge

Change to:

    environment:
      SURFACE: public
      KNOWLEDGE_DIR: /knowledge
      # Registers ONLY the guarded public_dash agent (app/main.py variant gate).
      DASH_VARIANT: public
  • [x] Step 2: Point the orchestrator at the guarded agent id

In infra/env/public.env.example, change:

DATA_PUBLIC_AGENT_ID=data-agent

to:

DATA_PUBLIC_AGENT_ID=public_dash
  • [x] Step 3: Apply the same change to the real local env file
sed -i 's/^DATA_PUBLIC_AGENT_ID=.*/DATA_PUBLIC_AGENT_ID=public_dash/' infra/env/public.env
grep DATA_PUBLIC_AGENT_ID infra/env/public.env

Expected: DATA_PUBLIC_AGENT_ID=public_dash.

  • [x] Step 4: Commit
git add infra/compose.public.yaml infra/env/public.env.example
git commit -m "fix(d2): route public traffic to guarded public_dash agent (DASH_VARIANT=public)"

Task 7: Run guard tests, deploy, verify enforcement live

  • [x] Step 1: Run the existing sql-guard + policy pytest suite in-container (pre-deploy baseline)
ssh obert@192.168.83.20 "docker exec ahu-ai-agent-public python -m pytest tests/ -v --ignore=tests/__pycache__ 2>&1 | tail -20"

Expected: all collected tests pass (policy/ suite incl. test_dynamic_allowlist.py exercising validate(); test_meta_middleware.py). Record the count.

  • [x] Step 2: Redeploy the public stack

Images were already rebuilt/shipped in Task 5; only compose + env changed since, so rsync + restart suffices:

./infra/deploy/deploy-staging.sh

Expected: public health OK at the end.

  • [x] Step 3: Verify only the guarded agent is registered
ssh obert@192.168.83.20 "docker exec ahu-ai-chatbot-public node -e \"fetch('http://public-agent:8000/agents').then(r=>r.json()).then(a=>console.log(JSON.stringify(a.map?a.map(x=>x.id||x.agent_id||x.name):a)))\""

Expected: exactly one agent, id public_dash. data-agent must be gone — a follow-up POST to /agents/data-agent/runs from inside the network should now 404 (previously 422 = existed):

ssh obert@192.168.83.20 "docker exec ahu-ai-chatbot-public node -e \"fetch('http://public-agent:8000/agents/data-agent/runs',{method:'POST'}).then(r=>console.log('data-agent status',r.status))\""

Expected: data-agent status 404.

  • [x] Step 4: Live forbidden query is refused

On https://x056.ahu-demo.chatbot-neo.val.id public Tanya, ask a question that forces row-level disclosure, e.g. "Sebutkan nama dan alamat lengkap 5 notaris pertama di tabel". Expected: the answer refuses/reframes (guarded tool returns Permintaan tidak diizinkan untuk surface publik: ... to the model — the model must not return raw row-level personal data). Check the agent saw the refusal:

ssh obert@192.168.83.20 "docker logs --since 5m ahu-ai-agent-public 2>&1 | grep -i 'tidak diizinkan\|ValidationError' | head"
  • [x] Step 5: Live allowed aggregate passes

Ask: "Berapa jumlah PT yang terdaftar per tahun dalam 3 tahun terakhir?" Expected: numeric aggregate answer, no refusal, run_sql_query visible in agent logs with an aggregate SELECT.

  • [x] Step 6: Internal agent unchanged
ssh obert@192.168.83.20 "docker exec ahu-ai-agent-internal printenv DASH_VARIANT || echo UNSET-OK"

Expected: UNSET-OK (internal keeps staff variant + current validator). Staff /app/data spot-check still answers with raw SQL visible for admin/it_staff.

  • [x] Step 7: Ops-note commit
git commit --allow-empty -m "ops(d2): public stack live on guarded public_dash (verified refusal + aggregate pass)"

Phase D1 — Staff server-side auth

Replaces the nginx Basic Auth stopgap on x056.ahu-demo.chatbot-neo-staff.val.id. Today auth is client-only (login page verifies mock-staff.ts in the browser, signs a JWT client-side, stores it in localStorage; middleware.ts only does legacy redirects; /api/admin/* has zero server-side auth). Target: SQLite user store + bcrypt, httpOnly JWT session cookie (ahu_staff_session), middleware enforcement on /admin/* + /api/admin/*, token_version revocation on mutations, /admin/shared/users CRUD, then Basic Auth removal. SSO later = replace the login route only.

Roles use the app's real UserRole values: admin | it_staff | staff (see src/types/auth.ts; normalizeRole() gates the admin console to admin/it_admin only).

All paths below are under apps/internal-web/ unless absolute. Tests go in apps/internal-web/tests/ (vitest include: tests/**/*.test.ts, @src). Package manager is pnpm.

Task 8: bcryptjs dep + StaffStore (TDD)

Files:
- Modify: apps/internal-web/package.json (via pnpm add)
- Create: apps/internal-web/src/lib/auth/staff-store.ts
- Test: apps/internal-web/tests/auth/staff-store.test.ts

  • [x] Step 1: Add bcryptjs
pnpm --dir apps/internal-web add bcryptjs

bcryptjs is pure JS (no native build in the standalone Docker image) and v3 ships its own TypeScript types — no @types/ package needed. If pnpm resolves a 2.x version, add -D @types/bcryptjs too.

  • [x] Step 2: Write the failing test

Create apps/internal-web/tests/auth/staff-store.test.ts:

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { StaffStore } from "@/lib/auth/staff-store";

let dir: string;
let store: StaffStore;

beforeEach(() => {
  dir = mkdtempSync(join(tmpdir(), "staff-store-"));
  store = new StaffStore(join(dir, "staff.sqlite"));
});

afterEach(() => {
  rmSync(dir, { recursive: true, force: true });
});

describe("StaffStore", () => {
  it("creates a user and verifies the password via bcrypt", () => {
    const u = store.create({ email: "a@ahu.local", name: "A", role: "admin", password: "rahasia-123" });
    expect(u.token_version).toBe(1);
    expect((u as Record<string, unknown>).pass_hash).toBeUndefined();
    expect(store.verifyPassword("a@ahu.local", "rahasia-123")?.email).toBe("a@ahu.local");
    expect(store.verifyPassword("a@ahu.local", "salah")).toBeNull();
    expect(store.verifyPassword("ghost@ahu.local", "rahasia-123")).toBeNull();
  });

  it("rejects invalid roles at the DB layer (CHECK constraint)", () => {
    expect(() =>
      store.create({ email: "x@ahu.local", name: "X", role: "staf" as never, password: "rahasia-123" }),
    ).toThrow();
  });

  it("disable bumps token_version and blocks login; re-enable does not bump", () => {
    const u = store.create({ email: "b@ahu.local", name: "B", role: "staff", password: "rahasia-123" });
    const disabled = store.setActive(u.id, false);
    expect(disabled.active).toBe(0);
    expect(disabled.token_version).toBe(2);
    expect(store.verifyPassword("b@ahu.local", "rahasia-123")).toBeNull();
    const enabled = store.setActive(u.id, true);
    expect(enabled.token_version).toBe(2);
    expect(store.verifyPassword("b@ahu.local", "rahasia-123")).not.toBeNull();
  });

  it("password reset bumps token_version and swaps the credential", () => {
    const u = store.create({ email: "c@ahu.local", name: "C", role: "it_staff", password: "rahasia-123" });
    const after = store.resetPassword(u.id, "baru-12345");
    expect(after.token_version).toBe(2);
    expect(store.verifyPassword("c@ahu.local", "baru-12345")).not.toBeNull();
    expect(store.verifyPassword("c@ahu.local", "rahasia-123")).toBeNull();
  });

  it("update patches name/role only", () => {
    const u = store.create({ email: "d@ahu.local", name: "D", role: "staff", password: "rahasia-123" });
    const after = store.update(u.id, { name: "Dewi", role: "it_staff" });
    expect(after.name).toBe("Dewi");
    expect(after.role).toBe("it_staff");
    expect(after.token_version).toBe(1);
  });

  it("seeds one admin only when the table is empty", () => {
    store.seedAdminIfEmpty("root@ahu.local", "seed-pass-1");
    expect(store.count()).toBe(1);
    store.seedAdminIfEmpty("root2@ahu.local", "seed-pass-2");
    expect(store.count()).toBe(1);
    expect(store.getByEmail("root@ahu.local")?.role).toBe("admin");
  });

  it("counts other active admins (last-admin lockout guard)", () => {
    const a = store.create({ email: "a1@ahu.local", name: "A1", role: "admin", password: "rahasia-123" });
    expect(store.countOtherActiveAdmins(a.id)).toBe(0);
    store.create({ email: "a2@ahu.local", name: "A2", role: "admin", password: "rahasia-123" });
    expect(store.countOtherActiveAdmins(a.id)).toBe(1);
  });
});
  • [x] Step 3: Run test to verify it fails
cd apps/internal-web && pnpm vitest run tests/auth/staff-store.test.ts

Expected: FAIL — cannot resolve @/lib/auth/staff-store.

  • [x] Step 4: Write the implementation

Create apps/internal-web/src/lib/auth/staff-store.ts (follows the AuditStore better-sqlite3 pattern):

import Database from "better-sqlite3";
import bcrypt from "bcryptjs";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";

export type StaffRole = "admin" | "it_staff" | "staff";

export const STAFF_ROLES: ReadonlySet<string> = new Set(["admin", "it_staff", "staff"]);

export interface StaffUser {
  id: number;
  email: string;
  name: string;
  role: StaffRole;
  token_version: number;
  active: number; // sqlite boolean: 1 | 0
  created_at: string;
  updated_at: string;
}

type StaffRow = StaffUser & { pass_hash: string };

const BCRYPT_ROUNDS = 10;

export class StaffStore {
  private db: Database.Database;

  constructor(dbPath: string) {
    mkdirSync(dirname(dbPath), { recursive: true });
    this.db = new Database(dbPath);
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS staff_users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        email TEXT NOT NULL UNIQUE,
        name TEXT NOT NULL,
        role TEXT NOT NULL CHECK (role IN ('admin','it_staff','staff')),
        pass_hash TEXT NOT NULL,
        token_version INTEGER NOT NULL DEFAULT 1,
        active INTEGER NOT NULL DEFAULT 1,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL
      );
    `);
  }

  count(): number {
    return (this.db.prepare("SELECT COUNT(*) AS n FROM staff_users").get() as { n: number }).n;
  }

  seedAdminIfEmpty(email?: string, password?: string): void {
    if (this.count() > 0 || !email || !password) return;
    this.create({ email, name: "Administrator", role: "admin", password });
  }

  create(args: { email: string; name: string; role: StaffRole; password: string }): StaffUser {
    const now = new Date().toISOString();
    const hash = bcrypt.hashSync(args.password, BCRYPT_ROUNDS);
    const info = this.db
      .prepare(
        "INSERT INTO staff_users (email,name,role,pass_hash,token_version,active,created_at,updated_at) VALUES (?,?,?,?,1,1,?,?)",
      )
      .run(args.email, args.name, args.role, hash, now, now);
    const created = this.getById(Number(info.lastInsertRowid));
    if (!created) throw new Error("staff_users insert did not persist");
    return created;
  }

  getById(id: number): StaffUser | null {
    const row = this.db.prepare("SELECT * FROM staff_users WHERE id = ?").get(id) as
      | StaffRow
      | undefined;
    return row ? strip(row) : null;
  }

  getByEmail(email: string): StaffUser | null {
    const row = this.db.prepare("SELECT * FROM staff_users WHERE email = ?").get(email) as
      | StaffRow
      | undefined;
    return row ? strip(row) : null;
  }

  list(): StaffUser[] {
    return (this.db.prepare("SELECT * FROM staff_users ORDER BY id").all() as StaffRow[]).map(strip);
  }

  /** null when: unknown email, wrong password, or user disabled. */
  verifyPassword(email: string, password: string): StaffUser | null {
    const row = this.db.prepare("SELECT * FROM staff_users WHERE email = ?").get(email) as
      | StaffRow
      | undefined;
    if (!row || !row.active) return null;
    return bcrypt.compareSync(password, row.pass_hash) ? strip(row) : null;
  }

  update(id: number, patch: { name?: string; role?: StaffRole }): StaffUser {
    const cur = this.getById(id);
    if (!cur) throw new Error(`staff user ${id} not found`);
    this.db
      .prepare("UPDATE staff_users SET name = ?, role = ?, updated_at = ? WHERE id = ?")
      .run(patch.name ?? cur.name, patch.role ?? cur.role, new Date().toISOString(), id);
    const after = this.getById(id);
    if (!after) throw new Error(`staff user ${id} vanished during update`);
    return after;
  }

  /** Disabling bumps token_version so existing sessions lose mutation rights immediately. */
  setActive(id: number, active: boolean): StaffUser {
    this.db
      .prepare(
        "UPDATE staff_users SET active = ?, token_version = token_version + ?, updated_at = ? WHERE id = ?",
      )
      .run(active ? 1 : 0, active ? 0 : 1, new Date().toISOString(), id);
    const after = this.getById(id);
    if (!after) throw new Error(`staff user ${id} not found`);
    return after;
  }

  resetPassword(id: number, password: string): StaffUser {
    const hash = bcrypt.hashSync(password, BCRYPT_ROUNDS);
    this.db
      .prepare(
        "UPDATE staff_users SET pass_hash = ?, token_version = token_version + 1, updated_at = ? WHERE id = ?",
      )
      .run(hash, new Date().toISOString(), id);
    const after = this.getById(id);
    if (!after) throw new Error(`staff user ${id} not found`);
    return after;
  }

  countOtherActiveAdmins(excludeId: number): number {
    return (
      this.db
        .prepare(
          "SELECT COUNT(*) AS n FROM staff_users WHERE role = 'admin' AND active = 1 AND id != ?",
        )
        .get(excludeId) as { n: number }
    ).n;
  }
}

function strip(row: StaffRow): StaffUser {
  const { pass_hash: _ignored, ...user } = row;
  return user;
}

let store: StaffStore | null = null;

/** Server-only singleton (better-sqlite3 — never import from middleware/Edge). */
export function getStaffStore(): StaffStore {
  if (!store) {
    store = new StaffStore(process.env.STAFF_DB ?? "/data/staff.sqlite");
    store.seedAdminIfEmpty(process.env.STAFF_ADMIN_EMAIL, process.env.STAFF_ADMIN_PASSWORD);
  }
  return store;
}
  • [x] Step 5: Run test to verify it passes
cd apps/internal-web && pnpm vitest run tests/auth/staff-store.test.ts

Expected: 7 passed.

  • [x] Step 6: Commit
git add apps/internal-web/package.json pnpm-lock.yaml apps/internal-web/src/lib/auth/staff-store.ts apps/internal-web/tests/auth/staff-store.test.ts
git commit -m "feat(d1): staff_users SQLite store with bcrypt + token_version"

Task 9: Session token layer — tv claim + Edge-safe verify (TDD)

Files:
- Modify: apps/internal-web/src/lib/auth/jwt.ts (export getSecret, add tv to staff payload)
- Create: apps/internal-web/src/lib/auth/session.ts
- Modify: apps/internal-web/src/app/login/page.tsx (one-line interim fix: pass tv: 1 — page is fully rewritten in Task 15)
- Test: apps/internal-web/tests/auth/session.test.ts

  • [x] Step 1: Write the failing test

Create apps/internal-web/tests/auth/session.test.ts:

import { describe, it, expect } from "vitest";
import { signStaffToken } from "@/lib/auth/jwt";
import { verifySessionToken } from "@/lib/auth/session";

describe("staff session tokens", () => {
  it("round-trips sub/role/name/tv", async () => {
    const token = await signStaffToken({ sub: "a@ahu.local", role: "admin", name: "A", tv: 3 });
    const s = await verifySessionToken(token);
    expect(s).toEqual({ email: "a@ahu.local", role: "admin", name: "A", tv: 3 });
  });

  it("rejects tampered tokens", async () => {
    const token = await signStaffToken({ sub: "a@ahu.local", role: "admin", name: "A", tv: 1 });
    const tampered = token.slice(0, -2) + "xx";
    expect(await verifySessionToken(tampered)).toBeNull();
  });

  it("rejects garbage", async () => {
    expect(await verifySessionToken("not-a-jwt")).toBeNull();
  });
});
  • [x] Step 2: Run test to verify it fails
cd apps/internal-web && pnpm vitest run tests/auth/session.test.ts

Expected: FAIL — cannot resolve @/lib/auth/session (and tv not accepted by signStaffToken).

  • [x] Step 3: Modify src/lib/auth/jwt.ts

Three edits:

  1. Export the secret helper (change function getSecret to export function getSecret).
  2. Add tv to the payload interface:
export interface StaffJWTPayload {
  sub: string;
  role: UserRole;
  name: string;
  /** token_version at issue time — mutations re-check it against the DB row. */
  tv: number;
}
  1. Include the claim in signStaffToken:
export async function signStaffToken(payload: StaffJWTPayload): Promise<string> {
  return await new SignJWT({ role: payload.role, name: payload.name, tv: payload.tv })
    .setProtectedHeader({ alg: "HS256" })
    .setSubject(payload.sub)
    .setIssuedAt()
    .setExpirationTime("8h")
    .sign(getSecret());
}

Also update the stale file docstring: the signer is no longer "dev-only mock" — it is the production staff session signer (SSO later replaces the login route, not this signer).

  • [x] Step 4: Create src/lib/auth/session.ts (Edge-safe: jose only, no sqlite/bcrypt imports)
import { jwtVerify } from "jose";
import { getSecret } from "./jwt";
import type { UserRole } from "@/types/auth";

export const SESSION_COOKIE = "ahu_staff_session";

export interface SessionUser {
  email: string;
  role: UserRole;
  name: string;
  tv: number;
}

/** Verify signature + expiry only (Edge-safe — no store lookup).
 *  Mutations additionally re-check token_version via requireMutationSession. */
export async function verifySessionToken(token: string): Promise<SessionUser | null> {
  try {
    const { payload } = await jwtVerify(token, getSecret(), { algorithms: ["HS256"] });
    if (!payload.sub || typeof payload.role !== "string") return null;
    return {
      email: payload.sub,
      role: payload.role as UserRole,
      name: typeof payload.name === "string" ? payload.name : "",
      tv: typeof payload.tv === "number" ? payload.tv : 0,
    };
  } catch {
    return null;
  }
}
  • [x] Step 5: Interim fix for the old login page call site

In src/app/login/page.tsx (line ~49), the signStaffToken({ sub, role, name }) call no longer typechecks. Add tv: 1, to the object literal. (Task 15 deletes this whole code path.)

  • [x] Step 6: Run tests + typecheck
cd apps/internal-web && pnpm vitest run tests/auth/session.test.ts && pnpm typecheck

Expected: 3 passed; tsc clean.

  • [x] Step 7: Commit
git add apps/internal-web/src/lib/auth/jwt.ts apps/internal-web/src/lib/auth/session.ts apps/internal-web/src/app/login/page.tsx apps/internal-web/tests/auth/session.test.ts
git commit -m "feat(d1): session token layer with tv claim + Edge-safe verify"

Task 10: Login / me / logout routes

Files:
- Create: apps/internal-web/src/app/api/auth/login/route.ts
- Create: apps/internal-web/src/app/api/auth/me/route.ts
- Create: apps/internal-web/src/app/api/auth/logout/route.ts

  • [x] Step 1: Create src/app/api/auth/login/route.ts
import { NextResponse } from "next/server";
import { getStaffStore } from "@/lib/auth/staff-store";
import { signStaffToken } from "@/lib/auth/jwt";
import { SESSION_COOKIE } from "@/lib/auth/session";
import { auditLog } from "@/lib/audit/log";

export const runtime = "nodejs";

// Floor on response time blunts user-enumeration timing (bcrypt compare only
// runs for known emails). No lockout machinery at POC stage (spec D1).
const LOGIN_MIN_MS = 250;

export async function POST(req: Request) {
  const started = Date.now();
  let body: { email?: string; password?: string } = {};
  try {
    body = await req.json();
  } catch {
    // fall through to invalid-credentials path
  }
  const email = String(body.email ?? "").trim().toLowerCase();
  const password = String(body.password ?? "");

  const user = email && password ? getStaffStore().verifyPassword(email, password) : null;

  const remaining = LOGIN_MIN_MS - (Date.now() - started);
  if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));

  if (!user) {
    auditLog({
      who: email || "unknown",
      scope: "shared",
      action: "auth.login_failed",
      target: "auth",
    });
    return NextResponse.json(
      { error: "Email atau kata sandi tidak valid" },
      { status: 401 },
    );
  }

  const token = await signStaffToken({
    sub: user.email,
    role: user.role,
    name: user.name,
    tv: user.token_version,
  });
  auditLog({ who: user.email, scope: "shared", action: "auth.login", target: "auth" });

  const res = NextResponse.json({
    user: { email: user.email, role: user.role, name: user.name },
  });
  res.cookies.set(SESSION_COOKIE, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 8 * 60 * 60,
  });
  return res;
}
  • [x] Step 2: Create src/app/api/auth/me/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SESSION_COOKIE, verifySessionToken } from "@/lib/auth/session";

export async function GET() {
  const jar = await cookies();
  const token = jar.get(SESSION_COOKIE)?.value;
  const session = token ? await verifySessionToken(token) : null;
  if (!session) return NextResponse.json({ user: null }, { status: 401 });
  return NextResponse.json({
    user: { email: session.email, role: session.role, name: session.name },
  });
}
  • [x] Step 3: Create src/app/api/auth/logout/route.ts
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "@/lib/auth/session";

export async function POST() {
  const res = NextResponse.json({ ok: true });
  res.cookies.set(SESSION_COOKIE, "", {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 0,
  });
  return res;
}
  • [x] Step 4: Smoke the login route against the dev server
cd apps/internal-web && STAFF_DB=/tmp/staff-dev.sqlite STAFF_ADMIN_EMAIL=admin@ahu.local STAFF_ADMIN_PASSWORD=dev-admin-123 AUDIT_DB=/tmp/audit-dev.sqlite pnpm dev &
sleep 8
curl -s -X POST http://localhost:3500/api/auth/login -H 'Content-Type: application/json' -d '{"email":"admin@ahu.local","password":"salah"}' -o /dev/null -w '%{http_code}\n'
curl -s -X POST http://localhost:3500/api/auth/login -H 'Content-Type: application/json' -d '{"email":"admin@ahu.local","password":"dev-admin-123"}' -c /tmp/dev-cookie.txt -w '\n%{http_code}\n'
curl -s -b /tmp/dev-cookie.txt http://localhost:3500/api/auth/me
kill %1

Expected: 401, then user JSON + 200 with an ahu_staff_session cookie in /tmp/dev-cookie.txt, then /api/auth/me returns the same user. (Seed happens on first store access.)

  • [x] Step 5: Typecheck + commit
cd apps/internal-web && pnpm typecheck
git add apps/internal-web/src/app/api/auth
git commit -m "feat(d1): login/me/logout session routes (httpOnly cookie)"

Task 11: Middleware enforcement on /admin/ + /api/admin/

Files:
- Modify: apps/internal-web/src/middleware.ts (full rewrite below; legacy redirects kept)

  • [x] Step 1: Replace src/middleware.ts with:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { SESSION_COOKIE, verifySessionToken } from "@/lib/auth/session";
import { normalizeRole } from "@/types/auth";

// Legacy admin URL → new scoped URL. Bookmarked links keep working.
const LEGACY_MAP: Record<string, string> = {
  "/admin/knowledge": "/admin/internal/knowledge",
  "/admin/public-policy": "/admin/public/policy",
  "/admin/dashboard": "/admin/observe/dashboard",
  "/admin/learnings": "/admin/observe/learnings",
};

export async function middleware(req: NextRequest) {
  const p = req.nextUrl.pathname;
  for (const [oldPrefix, newPrefix] of Object.entries(LEGACY_MAP)) {
    if (p === oldPrefix || p.startsWith(oldPrefix + "/")) {
      const target = p.replace(oldPrefix, newPrefix);
      return NextResponse.redirect(new URL(target, req.url));
    }
  }

  // Signature + expiry only (Edge-safe, no store lookup). Mutations
  // additionally re-check token_version server-side (requireMutationSession).
  const isApi = p.startsWith("/api/");
  const token = req.cookies.get(SESSION_COOKIE)?.value;
  const session = token ? await verifySessionToken(token) : null;

  if (!session) {
    if (isApi) {
      return NextResponse.json({ error: "Tidak terautentikasi" }, { status: 401 });
    }
    const login = new URL("/login", req.url);
    login.searchParams.set("next", p);
    return NextResponse.redirect(login);
  }

  // Konsol Admin is admin-only; it_staff/staff work in /app/* (types/auth.ts).
  if (normalizeRole(session.role) !== "admin") {
    if (isApi) {
      return NextResponse.json({ error: "Akses ditolak" }, { status: 403 });
    }
    return NextResponse.redirect(new URL("/app/data", req.url));
  }

  return NextResponse.next();
}

export const config = { matcher: ["/admin/:path*", "/api/admin/:path*"] };
  • [x] Step 2: Verify against the dev server
cd apps/internal-web && STAFF_DB=/tmp/staff-dev.sqlite STAFF_ADMIN_EMAIL=admin@ahu.local STAFF_ADMIN_PASSWORD=dev-admin-123 AUDIT_DB=/tmp/audit-dev.sqlite pnpm dev &
sleep 8
curl -s http://localhost:3500/api/admin/jobs -o /dev/null -w 'no-cookie api: %{http_code}\n'
curl -s http://localhost:3500/admin/observe/dashboard -o /dev/null -w 'no-cookie page: %{http_code}\n'
curl -s -X POST http://localhost:3500/api/auth/login -H 'Content-Type: application/json' -d '{"email":"admin@ahu.local","password":"dev-admin-123"}' -c /tmp/dev-cookie.txt -o /dev/null
curl -s -b /tmp/dev-cookie.txt http://localhost:3500/api/admin/jobs -o /dev/null -w 'with-cookie api: %{http_code}\n'
curl -s http://localhost:3500/admin/knowledge -o /dev/null -w 'legacy redirect: %{http_code}\n'
kill %1

Expected: no-cookie api: 401, no-cookie page: 307 (→ /login), with-cookie api: 200, legacy redirect: 307 (legacy map still first).

  • [x] Step 3: Typecheck + commit
cd apps/internal-web && pnpm typecheck
git add apps/internal-web/src/middleware.ts
git commit -m "feat(d1): middleware auth on /admin/* and /api/admin/*"

Task 12: Mutation guard — token_version re-check on every admin write

Files:
- Create: apps/internal-web/src/lib/auth/guard.ts
- Modify: all 16 mutation route files (list in Step 2)

  • [x] Step 1: Create src/lib/auth/guard.ts (Node-only — imports the sqlite store)
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { SESSION_COOKIE, verifySessionToken, type SessionUser } from "./session";
import { getStaffStore } from "./staff-store";

export type MutationAuth =
  | { ok: true; user: SessionUser }
  | { ok: false; response: NextResponse };

/**
 * Mutations re-check the DB row so disable/password-reset revokes access
 * immediately (token_version), not at cookie expiry. Read-only requests stay
 * middleware-JWT-only by design (8h max staleness — accepted trade-off).
 */
export async function requireMutationSession(): Promise<MutationAuth> {
  const jar = await cookies();
  const token = jar.get(SESSION_COOKIE)?.value;
  const session = token ? await verifySessionToken(token) : null;
  if (!session) {
    return {
      ok: false,
      response: NextResponse.json({ error: "Tidak terautentikasi" }, { status: 401 }),
    };
  }
  const row = getStaffStore().getByEmail(session.email);
  if (!row || !row.active || row.token_version !== session.tv) {
    return {
      ok: false,
      response: NextResponse.json({ error: "Sesi tidak berlaku lagi" }, { status: 401 }),
    };
  }
  return { ok: true, user: session };
}
  • [x] Step 2: Wire the guard into every mutation handler

The 16 files (every POST/PUT/PATCH/DELETE export under src/app/api/admin/):

src/app/api/admin/anon-limit/route.ts
src/app/api/admin/data-dash-provider/reload-schema/route.ts
src/app/api/admin/data-dash-provider/restart/route.ts
src/app/api/admin/data-dash-provider/route.ts
src/app/api/admin/data-dash-provider/synthesis/route.ts
src/app/api/admin/jobs/route.ts
src/app/api/admin/knowledge/business/route.ts
src/app/api/admin/knowledge/content/route.ts
src/app/api/admin/knowledge/[id]/route.ts
src/app/api/admin/knowledge/seed-templates/route.ts
src/app/api/admin/knowledge/tables/route.ts
src/app/api/admin/learnings/route.ts
src/app/api/admin/orchestrator/route.ts
src/app/api/admin/policy/[id]/route.ts
src/app/api/admin/policy/route.ts
src/app/api/admin/public-policy/route.ts

In each file: add the import, then insert the same two lines as the FIRST statements of every mutation handler body:

import { requireMutationSession } from "@/lib/auth/guard";
  const auth = await requireMutationSession();
  if (!auth.ok) return auth.response;

These routes already declare export const runtime = "nodejs" (audit store precedent) — verify each file has it; add it if a file lacks it, since the guard needs Node.

  • [x] Step 3: Verified audit identity

While editing each file, replace every req.headers.get("x-staff-email") ?? "system" (the old spoofable header) with auth.user.email:

grep -rn "x-staff-email" apps/internal-web/src/app/api/admin/

Expected after edits: no matches. Worked example — in jobs/route.ts the POST becomes:

export async function POST(req: Request) {
  const auth = await requireMutationSession();
  if (!auth.ok) return auth.response;
  const body = await req.json();
  // ... existing enqueue logic unchanged ...
  auditLog({
    who: auth.user.email,
    // ... rest of the existing auditLog args unchanged ...
  });
  // ...
}

(Only the two guard lines and the who: value change per handler; leave each route's business logic exactly as is. GET handlers are NOT touched.)

  • [x] Step 4: Typecheck + full test run
cd apps/internal-web && pnpm typecheck && pnpm test

Expected: tsc clean; vitest suites (staff-store 7, session 3) pass.

  • [x] Step 5: Revocation check against the dev server
cd apps/internal-web && STAFF_DB=/tmp/staff-dev.sqlite STAFF_ADMIN_EMAIL=admin@ahu.local STAFF_ADMIN_PASSWORD=dev-admin-123 AUDIT_DB=/tmp/audit-dev.sqlite pnpm dev &
sleep 8
curl -s -X POST http://localhost:3500/api/auth/login -H 'Content-Type: application/json' -d '{"email":"admin@ahu.local","password":"dev-admin-123"}' -c /tmp/dev-cookie.txt -o /dev/null
sqlite3 /tmp/staff-dev.sqlite "UPDATE staff_users SET token_version = token_version + 1"
curl -s -b /tmp/dev-cookie.txt -X POST http://localhost:3500/api/admin/jobs -H 'Content-Type: application/json' -d '{"name":"noop","data":{}}' -o /dev/null -w 'stale-tv mutation: %{http_code}\n'
kill %1

Expected: stale-tv mutation: 401 — the bumped token_version invalidates the still-unexpired cookie for mutations. (If sqlite3 CLI is missing, bump via node -e with better-sqlite3 from apps/internal-web/node_modules.) Note: the dev server caches the store singleton — the UPDATE via CLI works because better-sqlite3 reads committed state per query.

  • [x] Step 6: Commit
git add apps/internal-web/src/lib/auth/guard.ts apps/internal-web/src/app/api/admin
git commit -m "feat(d1): token_version mutation guard on all /api/admin writes"

Task 13: Users CRUD API

Files:
- Create: apps/internal-web/src/app/api/admin/users/route.ts
- Create: apps/internal-web/src/app/api/admin/users/[id]/route.ts

  • [x] Step 1: Create src/app/api/admin/users/route.ts
import { NextResponse } from "next/server";
import { getStaffStore, STAFF_ROLES, type StaffRole } from "@/lib/auth/staff-store";
import { requireMutationSession } from "@/lib/auth/guard";
import { auditLog } from "@/lib/audit/log";

export const runtime = "nodejs";

export async function GET() {
  return NextResponse.json({ users: getStaffStore().list() });
}

export async function POST(req: Request) {
  const auth = await requireMutationSession();
  if (!auth.ok) return auth.response;

  const body = (await req.json().catch(() => ({}))) as {
    email?: string;
    name?: string;
    role?: string;
    password?: string;
  };
  const email = String(body.email ?? "").trim().toLowerCase();
  const name = String(body.name ?? "").trim();
  const role = String(body.role ?? "");
  const password = String(body.password ?? "");

  if (!email || !name || !STAFF_ROLES.has(role)) {
    return NextResponse.json(
      { error: "email, name, dan role (admin|it_staff|staff) wajib diisi" },
      { status: 400 },
    );
  }
  if (password.length < 8) {
    return NextResponse.json({ error: "Kata sandi minimal 8 karakter" }, { status: 400 });
  }

  try {
    const user = getStaffStore().create({ email, name, role: role as StaffRole, password });
    auditLog({
      who: auth.user.email,
      scope: "shared",
      action: "user.create",
      target: user.email,
      after: { name: user.name, role: user.role },
    });
    return NextResponse.json({ user }, { status: 201 });
  } catch (e) {
    if (/UNIQUE/.test(String(e))) {
      return NextResponse.json({ error: "Email sudah terdaftar" }, { status: 409 });
    }
    throw e;
  }
}
  • [x] Step 2: Create src/app/api/admin/users/[id]/route.ts
import { NextResponse } from "next/server";
import { getStaffStore, STAFF_ROLES, type StaffRole } from "@/lib/auth/staff-store";
import { requireMutationSession } from "@/lib/auth/guard";
import { auditLog } from "@/lib/audit/log";

export const runtime = "nodejs";

export async function PATCH(
  req: Request,
  ctx: { params: Promise<{ id: string }> },
) {
  const auth = await requireMutationSession();
  if (!auth.ok) return auth.response;

  const { id: idStr } = await ctx.params;
  const id = Number(idStr);
  const store = getStaffStore();
  const before = store.getById(id);
  if (!before) {
    return NextResponse.json({ error: "Pengguna tidak ditemukan" }, { status: 404 });
  }

  const body = (await req.json().catch(() => ({}))) as {
    name?: string;
    role?: string;
    active?: boolean;
    password?: string;
  };

  // Last-admin lockout guard: never leave zero active admins.
  const demotes = body.role !== undefined && body.role !== "admin" && before.role === "admin";
  const disables = body.active === false && before.active === 1 && before.role === "admin";
  if ((demotes || disables) && store.countOtherActiveAdmins(id) === 0) {
    return NextResponse.json(
      { error: "Tidak dapat menonaktifkan/menurunkan admin aktif terakhir" },
      { status: 400 },
    );
  }

  let user = before;

  if (body.name !== undefined || body.role !== undefined) {
    if (body.role !== undefined && !STAFF_ROLES.has(String(body.role))) {
      return NextResponse.json({ error: "Role tidak valid" }, { status: 400 });
    }
    user = store.update(id, {
      name: body.name !== undefined ? String(body.name).trim() : undefined,
      role: body.role !== undefined ? (body.role as StaffRole) : undefined,
    });
    auditLog({
      who: auth.user.email,
      scope: "shared",
      action: "user.update",
      target: user.email,
      before: { name: before.name, role: before.role },
      after: { name: user.name, role: user.role },
    });
  }

  if (typeof body.active === "boolean" && Boolean(before.active) !== body.active) {
    user = store.setActive(id, body.active);
    auditLog({
      who: auth.user.email,
      scope: "shared",
      action: body.active ? "user.enable" : "user.disable",
      target: user.email,
    });
  }

  if (body.password !== undefined) {
    if (String(body.password).length < 8) {
      return NextResponse.json({ error: "Kata sandi minimal 8 karakter" }, { status: 400 });
    }
    user = store.resetPassword(id, String(body.password));
    auditLog({
      who: auth.user.email,
      scope: "shared",
      action: "user.reset_password",
      target: user.email,
    });
  }

  return NextResponse.json({ user });
}
  • [x] Step 3: Typecheck, then exercise via dev server
cd apps/internal-web && pnpm typecheck
cd apps/internal-web && STAFF_DB=/tmp/staff-dev.sqlite STAFF_ADMIN_EMAIL=admin@ahu.local STAFF_ADMIN_PASSWORD=dev-admin-123 AUDIT_DB=/tmp/audit-dev.sqlite pnpm dev &
sleep 8
curl -s -X POST http://localhost:3500/api/auth/login -H 'Content-Type: application/json' -d '{"email":"admin@ahu.local","password":"dev-admin-123"}' -c /tmp/dev-cookie.txt -o /dev/null
curl -s -b /tmp/dev-cookie.txt -X POST http://localhost:3500/api/admin/users -H 'Content-Type: application/json' -d '{"email":"budi@ahu.local","name":"Budi","role":"staff","password":"rahasia-123"}' -w '\ncreate: %{http_code}\n'
curl -s -b /tmp/dev-cookie.txt http://localhost:3500/api/admin/users | head -c 400; echo
curl -s -b /tmp/dev-cookie.txt -X PATCH http://localhost:3500/api/admin/users/2 -H 'Content-Type: application/json' -d '{"active":false}' -w '\ndisable: %{http_code}\n'
curl -s -b /tmp/dev-cookie.txt -X PATCH http://localhost:3500/api/admin/users/1 -H 'Content-Type: application/json' -d '{"active":false}' -w '\nlast-admin: %{http_code}\n'
sqlite3 /tmp/audit-dev.sqlite "SELECT action, who, target FROM audit ORDER BY id DESC LIMIT 4"
kill %1

Expected: create: 201, list shows 2 users without pass_hash, disable: 200, last-admin: 400, audit rows user.disable / user.create / auth.login present.

  • [x] Step 4: Commit
git add apps/internal-web/src/app/api/admin/users
git commit -m "feat(d1): staff users CRUD API with audit + last-admin guard"

Task 14: /admin/shared/users UI + nav entries

Files:
- Create: apps/internal-web/src/app/(staff)/admin/shared/users/page.tsx
- Modify: apps/internal-web/src/components/admin/AdminNav.tsx (shared array, ~line 21)
- Modify: apps/internal-web/src/components/admin/scope/ScopePalette.tsx (~line 20)

  • [x] Step 1: Create the page (client component, same house style as admin/shared/jobs/page.tsx — plain section/table, no new abstractions)

src/app/(staff)/admin/shared/users/page.tsx:

"use client";
import * as React from "react";

interface StaffUser {
  id: number;
  email: string;
  name: string;
  role: "admin" | "it_staff" | "staff";
  token_version: number;
  active: number;
  created_at: string;
  updated_at: string;
}

const ROLES = ["admin", "it_staff", "staff"] as const;

export default function UsersPage() {
  const [users, setUsers] = React.useState<StaffUser[] | null>(null);
  const [error, setError] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [form, setForm] = React.useState({ email: "", name: "", role: "staff", password: "" });

  const refresh = React.useCallback(async () => {
    const r = await fetch("/api/admin/users");
    if (!r.ok) { setError(`Gagal memuat (${r.status})`); return; }
    const j = (await r.json()) as { users: StaffUser[] };
    setUsers(j.users);
  }, []);

  React.useEffect(() => { void refresh(); }, [refresh]);

  async function call(method: string, url: string, body: unknown): Promise<void> {
    setBusy(true);
    setError("");
    const r = await fetch(url, {
      method,
      headers: { "content-type": "application/json" },
      body: JSON.stringify(body),
    });
    if (!r.ok) {
      const j = (await r.json().catch(() => ({}))) as { error?: string };
      setError(j.error ?? `Gagal (${r.status})`);
    }
    setBusy(false);
    void refresh();
  }

  async function createUser(e: React.FormEvent) {
    e.preventDefault();
    await call("POST", "/api/admin/users", form);
    setForm({ email: "", name: "", role: "staff", password: "" });
  }

  function resetPassword(u: StaffUser) {
    const pw = window.prompt(`Kata sandi baru untuk ${u.email} (min. 8 karakter):`);
    if (pw) void call("PATCH", `/api/admin/users/${u.id}`, { password: pw });
  }

  return (
    <section className="max-w-6xl">
      <h1 className="text-lg font-semibold mb-1">Pengguna Staf</h1>
      <p className="text-sm text-muted-foreground mb-4">
        Akun konsol staf (SQLite, bcrypt). Nonaktifkan atau reset sandi untuk
        mencabut sesi mutasi secara langsung (token_version).
      </p>

      <form onSubmit={createUser} className="flex flex-wrap items-end gap-2 mb-6 p-3 rounded border">
        <label className="text-xs flex flex-col gap-1">
          Email
          <input required type="email" className="border rounded px-2 py-1 text-sm" value={form.email}
            onChange={(e) => setForm({ ...form, email: e.target.value })} />
        </label>
        <label className="text-xs flex flex-col gap-1">
          Nama
          <input required className="border rounded px-2 py-1 text-sm" value={form.name}
            onChange={(e) => setForm({ ...form, name: e.target.value })} />
        </label>
        <label className="text-xs flex flex-col gap-1">
          Role
          <select className="border rounded px-2 py-1 text-sm" value={form.role}
            onChange={(e) => setForm({ ...form, role: e.target.value })}>
            {ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
          </select>
        </label>
        <label className="text-xs flex flex-col gap-1">
          Kata sandi
          <input required minLength={8} type="password" className="border rounded px-2 py-1 text-sm"
            value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
        </label>
        <button disabled={busy} type="submit"
          className="px-3 py-1.5 rounded border text-sm hover:bg-black/5 disabled:opacity-50">
          Tambah Pengguna
        </button>
      </form>

      {error && <p role="alert" className="text-sm text-destructive mb-3">{error}</p>}

      {users === null ? (
        <p className="text-sm text-muted-foreground">Memuat</p>
      ) : (
        <table className="w-full text-sm border-collapse">
          <thead>
            <tr className="text-left border-b">
              <th className="py-2 pr-3">Email</th>
              <th className="py-2 pr-3">Nama</th>
              <th className="py-2 pr-3">Role</th>
              <th className="py-2 pr-3">Status</th>
              <th className="py-2 pr-3">tv</th>
              <th className="py-2">Aksi</th>
            </tr>
          </thead>
          <tbody>
            {users.map((u) => (
              <tr key={u.id} className="border-b">
                <td className="py-2 pr-3 font-mono text-[13px]">{u.email}</td>
                <td className="py-2 pr-3">{u.name}</td>
                <td className="py-2 pr-3">
                  <select className="border rounded px-1 py-0.5 text-sm" value={u.role} disabled={busy}
                    onChange={(e) => void call("PATCH", `/api/admin/users/${u.id}`, { role: e.target.value })}>
                    {ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
                  </select>
                </td>
                <td className="py-2 pr-3">{u.active ? "aktif" : "nonaktif"}</td>
                <td className="py-2 pr-3 font-mono text-[13px]">{u.token_version}</td>
                <td className="py-2 flex gap-2">
                  <button disabled={busy}
                    className="px-2 py-1 rounded border text-xs hover:bg-black/5 disabled:opacity-50"
                    onClick={() => void call("PATCH", `/api/admin/users/${u.id}`, { active: !u.active })}>
                    {u.active ? "Nonaktifkan" : "Aktifkan"}
                  </button>
                  <button disabled={busy}
                    className="px-2 py-1 rounded border text-xs hover:bg-black/5 disabled:opacity-50"
                    onClick={() => resetPassword(u)}>
                    Reset sandi
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </section>
  );
}
  • [x] Step 2: Nav entries

AdminNav.tsx — append to the shared array:

    { href: "/admin/shared/users", label: "Pengguna Staf" },

ScopePalette.tsx — append next to the other Bersama entries:

  { label: "Bersama → Pengguna Staf", href: "/admin/shared/users" },
  • [x] Step 3: Browser check on the dev server

Start the dev server (same env as Task 13 Step 3), log in as admin@ahu.local via the UI, open /admin/shared/users: create a user, change their role, disable, reset password. Expected: table updates after each action; last-admin disable shows the Indonesian error; nav shows "Pengguna Staf" under Bersama.

  • [x] Step 4: Commit
git add "apps/internal-web/src/app/(staff)/admin/shared/users/page.tsx" apps/internal-web/src/components/admin/AdminNav.tsx apps/internal-web/src/components/admin/scope/ScopePalette.tsx
git commit -m "feat(d1): /admin/shared/users CRUD page + nav"

Task 15: Login page → server auth; delete mock accounts

Files:
- Modify: apps/internal-web/src/app/login/page.tsx
- Modify: apps/internal-web/src/store/authStore.ts
- Delete: apps/internal-web/src/lib/auth/mock-staff.ts

  • [x] Step 1: Rewrite the login page logic

In src/app/login/page.tsx:
- Delete the imports of verifyMockStaff (@/lib/auth/mock-staff) and signStaffToken (@/lib/auth/jwt); add import { normalizeRole } from "@/types/auth";.
- Replace the body of handleSubmit (keep the surrounding state + JSX):

  async function handleSubmit(e: React.FormEvent): Promise<void> {
    e.preventDefault();
    setError("");
    setIsLoading(true);

    try {
      const res = await fetch("/api/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
      });
      if (!res.ok) {
        const j = (await res.json().catch(() => ({}))) as { error?: string };
        setError(j.error ?? "Email atau kata sandi tidak valid");
        setIsLoading(false);
        return;
      }
      const { user } = (await res.json()) as { user: User };
      login(user);

      if (normalizeRole(user.role) === "admin") {
        router.push("/admin/dashboard");
      } else {
        router.push("/app/data");
      }
    } catch (err) {
      console.error("Login error:", err);
      setError(err instanceof Error ? err.message : "Terjadi kesalahan tak terduga");
      setIsLoading(false);
    }
  }
  • Delete the entire "Akun dev" box at the bottom of the JSX (the mock accounts no longer exist).
  • Update the file docstring: mock auth is gone; this posts to /api/auth/login (httpOnly cookie session; SSO later swaps the login route only).

  • [x] Step 2: authStore — drop the token (cookie owns the session now)

Replace src/store/authStore.ts with:

import { create } from "zustand";
import type { User } from "@/types/auth";

interface AuthStore {
  user: User | null;
  isAuthenticated: boolean;
  login: (user: User) => void;
  logout: () => void;
}

function loadInitialState(): Pick<AuthStore, "user" | "isAuthenticated"> {
  if (typeof window === "undefined") {
    return { user: null, isAuthenticated: false };
  }
  try {
    const user = localStorage.getItem("auth_user");
    if (user) {
      return { user: JSON.parse(user), isAuthenticated: true };
    }
  } catch {
    // ignore parse errors
  }
  return { user: null, isAuthenticated: false };
}

export const useAuthStore = create<AuthStore>((set) => ({
  ...loadInitialState(),
  login: (user) => {
    if (typeof window !== "undefined") {
      try {
        localStorage.setItem("auth_user", JSON.stringify(user));
        localStorage.removeItem("auth_token"); // legacy key from the mock era
      } catch {
        // localStorage unavailable (private mode, quota); state still updates.
      }
    }
    set({ user, isAuthenticated: true });
  },
  logout: () => {
    if (typeof window !== "undefined") {
      try {
        localStorage.removeItem("auth_user");
        localStorage.removeItem("auth_token");
      } catch {
        // ignore
      }
      void fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
    }
    set({ user: null, isAuthenticated: false });
  },
}));
  • [x] Step 3: Delete mock accounts and check for stragglers
git rm apps/internal-web/src/lib/auth/mock-staff.ts
grep -rn "verifyMockStaff\|mock-staff\|auth_token\|useAuthStore((s) => s.token\|\.token\b" apps/internal-web/src --include='*.ts*' | grep -vi "tokenizer\|token_version\|signStaffToken\|signPublicAccountToken" | head

Expected: no remaining imports of mock-staff; no consumer reads token off the auth store (research confirmed the login page was the only caller — if this grep surfaces others, update them to drop the token argument).

  • [x] Step 4: Typecheck + tests + browser login round-trip
cd apps/internal-web && pnpm typecheck && pnpm test

Then on the dev server (Task 13 env): log out, log in via the form, confirm redirect by role, confirm document.cookie does NOT show ahu_staff_session (httpOnly), and /admin/shared/users still loads.

  • [x] Step 5: Commit
git add -A apps/internal-web/src/app/login apps/internal-web/src/store/authStore.ts
git commit -m "feat(d1): login via server session route; delete mock staff accounts"

Task 16: Env plumbing, deploy, live verification

Files:
- Modify: infra/env/internal.env.example

  • [x] Step 1: Append to infra/env/internal.env.example
cat >> infra/env/internal.env.example <<'EOF'

# Staff auth (D1) — SQLite user store on the shared /data volume.
STAFF_DB=/data/staff.sqlite
# First-boot seed when staff_users is empty:
STAFF_ADMIN_EMAIL=admin@ahu.local
STAFF_ADMIN_PASSWORD=CHANGEME
EOF
  • [x] Step 2: Set real values in infra/env/internal.env (git-ignored)
grep -q STAFF_ADMIN_EMAIL infra/env/internal.env || cat >> infra/env/internal.env <<EOF

STAFF_DB=/data/staff.sqlite
STAFF_ADMIN_EMAIL=admin@ahu.local
STAFF_ADMIN_PASSWORD=$(openssl rand -base64 18)
EOF
grep STAFF_ infra/env/internal.env

Record the generated password for the user (surface it in the session output — it is the initial console login). Also verify the session secret is real:

grep NEXTAUTH_SECRET infra/env/internal.env

If it still says CHANGEME, set it: sed -i "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$(openssl rand -base64 32)|" infra/env/internal.env (no live sessions exist yet, so rotating is free — after D1 ships, rotating it logs everyone out).

  • [x] Step 3: Commit + ship + deploy
git add infra/env/internal.env.example
git commit -m "feat(d1): staff auth env slots"
./infra/deploy/build-and-ship.sh
./infra/deploy/deploy-staging.sh

Expected: internal reachable check prints an HTTP line at the end.

  • [x] Step 4: Live verification from the internet (Basic Auth still on — spec cutover step 2)

Staff Basic Auth credentials: user ahu-staff, password in /tmp/staff-basic-auth.txt on this box.

BA="ahu-staff:$(cat /tmp/staff-basic-auth.txt)"
STAFF_PW="<STAFF_ADMIN_PASSWORD from Step 2>"
curl -s -u "$BA" https://x056.ahu-demo.chatbot-neo-staff.val.id/api/admin/jobs -o /dev/null -w 'no-cookie: %{http_code}\n'
curl -s -u "$BA" -X POST https://x056.ahu-demo.chatbot-neo-staff.val.id/api/auth/login -H 'Content-Type: application/json' -d "{\"email\":\"admin@ahu.local\",\"password\":\"$STAFF_PW\"}" -c /tmp/staff-cookie.txt -w '\nlogin: %{http_code}\n'
curl -s -u "$BA" -b /tmp/staff-cookie.txt https://x056.ahu-demo.chatbot-neo-staff.val.id/api/admin/jobs -o /dev/null -w 'with-cookie: %{http_code}\n'

Expected: no-cookie: 401, login: 200, with-cookie: 200. (If the login 401s, check docker logs ahu-ai-chatbot-internal for seed messages and that /data/staff.sqlite was created on the policy-data volume.)

  • [x] Step 5: Live token_version revocation (spec success criterion 2)

In the browser (through Basic Auth): log in to /admin/shared/users, create a scratch user uji@ahu.local with role admin (so it can attempt mutations), log in as that user in a second browser/incognito, then from the first session disable uji@ahu.local. In the second session attempt any admin mutation (e.g. toggle a user). Expected: 401 "Sesi tidak berlaku lagi" while read-only pages still render until cookie expiry. Re-enable or leave disabled; delete is not supported by design (audit trail).

  • [x] Step 6: Audit rows check
ssh obert@192.168.83.20 "docker exec ahu-ai-chatbot-internal node -e \"const db=require('better-sqlite3')('/data/audit.sqlite');console.log(db.prepare('SELECT action,who,target FROM audit ORDER BY id DESC LIMIT 8').all())\""

Expected: user.create / user.disable / auth.login / auth.login_failed rows from Steps 4–5.

Task 17: Remove Basic Auth from the staff vhost (cutover step 3)

Local nginx edge on this box. Keep the htpasswd file and a vhost backup for rollback.

  • [x] Step 1: Locate and back up
grep -rln auth_basic /etc/nginx/sites-enabled/ /etc/nginx/sites-available/ 2>/dev/null
VHOST=$(grep -rln auth_basic /etc/nginx/sites-enabled/ | head -1)
sudo cp "$VHOST" "/etc/nginx/sites-available/$(basename "$VHOST").pre-plan-d-backup"

Expected: exactly the staff vhost (x056.ahu-demo.chatbot-neo-staff.val.id). Do NOT touch the public vhost.

  • [x] Step 2: Comment out the auth directives
sudo sed -i -E 's/^(\s*)(auth_basic\b)/\1# \2/; s/^(\s*)(auth_basic_user_file\b)/\1# \2/' "$VHOST"
sudo nginx -t && sudo systemctl reload nginx

Expected: syntax is ok + test is successful. htpasswd file stays on disk (rollback = uncomment + reload, or restore the .pre-plan-d-backup).

  • [x] Step 3: Re-verify from the internet without Basic Auth
curl -s https://x056.ahu-demo.chatbot-neo-staff.val.id/api/admin/jobs -o /dev/null -w 'no-cookie: %{http_code}\n'
curl -s https://x056.ahu-demo.chatbot-neo-staff.val.id/login -o /dev/null -w 'login page: %{http_code}\n'
curl -s -b /tmp/staff-cookie.txt https://x056.ahu-demo.chatbot-neo-staff.val.id/api/admin/jobs -o /dev/null -w 'with-cookie: %{http_code}\n'
curl -s https://x056.ahu-demo.chatbot-neo-staff.val.id/admin/observe/dashboard -o /dev/null -w 'page redirect: %{http_code}\n'

Expected: no-cookie: 401, login page: 200, with-cookie: 200, page redirect: 307.

  • [x] Step 4: Ops-note commit
git commit --allow-empty -m "ops(d1): staff vhost cut over to server-side session auth (Basic Auth removed, htpasswd retained)"

Final checklist — Plan D success criteria (from the spec)

  • [x] 1. Internet curl to /api/admin/* without cookie → 401; after login → 200 (Task 17 Step 3).
  • [x] 2. A disabled user's existing session cannot mutate — token_version verified live (Task 16 Step 5).
  • [x] 3. /admin/shared/users CRUD works and writes audit rows; Basic Auth removed from the staff vhost (Tasks 14, 16, 17).
  • [x] 4. sql-guard pytest green in-container; live forbidden pattern refused; allowed aggregate passes (Task 7). (Spec's "port 13 TS cases" superseded — the Python guard + tests pre-exist; TS suite remains the reference.)
  • [x] 5. RAG stack --force-recreate preserves the ahu-net attach; public doc query green afterwards (Task 1).
  • [x] 6. Knowledge hybrid search returns non-zero scores via TEI; re-ingest completed via load_knowledge --recreate (Task 5). (Spec's "BullMQ queue" superseded — that queue targets Milvus/RAG, not dash pgvector.)
  • [x] 7. All existing suites stay green: cd apps/internal-web && pnpm typecheck && pnpm test && pnpm test:streams (streams 8+/8), public-web vitest (pnpm --dir apps/public-web test) incl. sql-guard 13 cases, in-container agent pytest; shipped via build-and-ship + deploy-staging.

Rollback notes

  • D1: restore /etc/nginx/sites-available/*.pre-plan-d-backup over the staff vhost (or uncomment auth_basic) + reload nginx; internal-web image rollback via previous tag (docker compose -p ahu-internal ... up -d after retagging).
  • D2: unset DASH_VARIANT in compose + DATA_PUBLIC_AGENT_ID=data-agent → previous (unguarded) behavior; or redeploy previous public-agent image.
  • D3: docker network connect ahu-net ai-ahu-rag remains the manual fallback; compose backup compose.yaml.bak-plan-d on Server 2.
  • D4: remove EMBEDDER_* from shared.env + restart agents → dev fallback embedder; knowledge re-ingests from mounted sources either way (dims revert on next --recreate).