Files
proof-of-work/AGENTS.md
T
Keysat 3f22ef7600
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
v1.1.0:9 — P2 hardening: input-validation 400s, auth rate-limit, XFF anti-spoof, non-root container
P2 batch from the 2026-06-13 full-eval (EVALUATION.md / ROADMAP.md), reviewed by the reviewer agent. App-code + packaging only; no schema or data change, existing /data untouched.

Input validation: malformed JSON bodies, invalid date, and out-of-range or non-numeric pagination on /api/workouts now return 400 instead of 500. New lib/http.ts readJsonBody maps a bad body to a ZodError across the 11 CRUD routes whose catch maps ZodError to 400; me/import and admin/signups guard request.json() in an explicit try/catch.

Rate limiting: POST /api/auth now shares the UI login server action's per-IP 10-per-15min cap and returns 429 + Retry-After. clientIpFromHeaders reads the rightmost (trusted-proxy-appended) X-Forwarded-For entry instead of the spoofable leftmost.

Container: drops root. The entrypoint prepares /data as root, chowns it to nextjs, then exec su-exec nextjs:nodejs node server.js (su-exec added to the runner image). The container drop needs live sideload verification.
2026-06-13 00:03:47 -05:00

10 KiB

AGENTS.md — Proof of Work

Self-hosted multi-user workout logger (Next.js app) packaged as a StartOS 0.4 s9pk, published to a private Start9 registry.

Stack (versions that matter)

  • Next.js 14 (App Router, server components + server actions, SSE streaming)
  • React 18, TypeScript 5, TailwindCSS 3
  • Prisma 5 ORM over SQLite (WAL mode; tuned at boot)
  • bcrypt (native — NOT bcryptjs), zod 3 for validation
  • Vitest 4 for tests
  • @start9labs/start-sdk for the 0.4 packaging layer
  • Node >= 20 to build

Layout (two projects in one repo)

proof-of-work/            ← the Next.js app (THIS is where you run npm)
  app/                    ← App Router routes; app/api/** = route handlers
  app/main/               ← authed UI; navigation.tsx = sidebar
  components/             ← React components (workouts/, ai/, settings/)
  lib/ai/                 ← AI subsystem (see below)
  lib/ai/providers/       ← claude.ts openai.ts gemini.ts ollama.ts + index.ts (getProvider; openai.ts exports both openai + openai-compatible = 5 registered providers)
  prisma/schema.prisma    ← schema (mirror; real DB migrates via entrypoint ALTERs)
  prisma/*.seed.json      ← curated exercise library + AI templates (reconciled each boot)
  tests/                  ← Vitest specs (ai-*.test.ts, routes-*.test.ts, ...)
start9/0.4/               ← StartOS packaging wrapper
  docker_entrypoint.sh    ← boot: first-boot seed, additive ALTERs, library reconcile
  Makefile / s9pk.mk      ← s9pk build (ARCHES := x86)
  startos/versions/       ← one file per ExVer version + index.ts (the version graph)
~/.proof-of-work/         ← publish.sh + unpublish.sh (NOT in repo; self-hosted registry)

workout-planner/ is scratch (only logs/) — ignore. start9/0.4/*.s9pk are build artifacts.

Commands

Run app commands from proof-of-work/ (running tsc/vitest/next from repo root fails — wrong cwd):

cd proof-of-work
npm run dev            # local dev server
npm run build          # next build (run this to catch route/type errors before shipping)
npm run lint           # next lint
npm test               # vitest run (full suite)
npx vitest run tests/ai-pricing.test.ts      # single file
npx vitest run -t "findPrice"                # single test by name
npx tsc --noEmit       # typecheck only
npx prisma generate    # REQUIRED after editing schema.prisma (else TS can't see new fields)
npm run db:seed        # seed InstanceSettings singleton (NO users/library — see below)
npm run create-admin -- you@example.com pw "Name"   # create first admin locally (--force resets existing)

Build/sideload the s9pk (from start9/0.4/): make x86 then make install. Targets come from s9pk.mk (the wrapper Makefile just sets ARCHES := x86):

  • make x86 — build the x86 s9pk.
  • make install — sideload the newest local .s9pk to the StartOS box at host: in ~/.startos/config.yaml (via start-cli package install).
  • make publish — upload every .s9pk to the S3 bucket (s9pk-s3base:) and index it on registry: from ~/.startos/config.yaml (via s3cmd + start-cli s9pk publish). Distinct from ~/.proof-of-work/publish.sh below.
  • make clean — remove build artifacts.

Both install and publish read host/registry config from ~/.startos/config.yaml, which is not in the repo — verify against the live setup, not from a checkout.

Canonical publish path for this project: ~/.proof-of-work/publish.sh (builds, uploads to FileBrowser, registers) — separate from the generic make publish. Unpublish: ~/.proof-of-work/unpublish.sh.

npm run db:seed (= tsx prisma/seed.ts) seeds only the InstanceSettings singleton — deliberately NO users and NO curated library (the library attaches at admin-creation and via the boot-time ensure). It is live, not dead — invoked at Docker image-build time (start9/0.4/Dockerfile) and the local-dev first-run path. npm run create-admin (= tsx scripts/create-admin.ts) is the local-dev equivalent of the StartOS "Set admin credentials" action: creates the first admin + seeds their library; --force to reset/promote an existing account. Runtime first-boot/upgrade seeding is handled separately by docker_entrypoint.sh.

Conventions

  • Versioning is ExVer: 1.1.0:4 (note the colon). Every release = a new start9/0.4/startos/versions/vMAJOR.MINOR.PATCH.N.ts file, imported into versions/index.ts and promoted to current (previous current moves into other[]).
  • Bump the version BEFORE building the s9pk — Start9 0.4 won't recognize a rebuild as an update otherwise.
  • Schema changes are additive ALTERs in docker_entrypoint.sh, guarded by PRAGMA table_info checks. Keep schema.prisma in sync as the mirror, but the entrypoint is what migrates live /data. Never write a destructive migration.
  • Commit subject = vX.Y.Z:N — short summary, imperative, body explains the why.
  • Git remote is self-hosted (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in ~/.proof-of-work/{publish,unpublish}.sh; FileBrowser creds live in ~/.keysat/filebrowser.env (outside the repo, gitignored). Default branch is master.
  • Authorization tiers: whole-instance routes (settings/{export,import}-db) are admin-only (!user.isAdmin → 403); per-user data routes scope by user.id. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with requiresBaseUrl) are admin-only (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI.
  • Malformed JSON body must return 400, not 500. Routes whose catch maps instanceof z.ZodError → 400 parse via readJsonBody(request) (lib/http.ts — throws a ZodError on bad JSON, so the existing branch handles it with no catch change). safeParse-style routes (me/import, admin/signups) wrap request.json() in an explicit try/catch → 400. (AI/admin routes using .catch(() => ({})) are a third, pre-existing pattern — unify if you touch them.)
  • The container runs the Node server as non-root. docker_entrypoint.sh runs as root only to prep /data (seed, ALTERs, library reconcile), then chown -R nextjs:nodejs "$DATA_DIR" and exec su-exec nextjs:nodejs node /app/server.js (su-exec added in the Dockerfile runner stage). Any new entrypoint step that needs root must run before that final line.
  • Tests live in proof-of-work/tests/; mock server-action deps with vi.hoisted() + vi.mock.
  • Before editing the AI subsystem (proof-of-work/lib/ai/** or the generate/generations routes), read docs/guides/ai-subsystem.md — provider abstraction, SSE/lenient-JSON, pricing/model menus, and the background-runner architecture live there.

Always

  • Run npx prisma generate after any schema.prisma edit, then npx tsc --noEmit.
  • Run npm test AND npm run build before shipping a version.
  • Add the boot-time ALTER TABLE (with an existence guard) for any new column, in docker_entrypoint.sh.
  • Treat API keys / secrets as plaintext in /data BY DESIGN (threat model: the operator owns /data). Reference env-var names (DATABASE_URL, etc.); never hardcode values.
  • Keep migrations idempotent and additive; data already on a user's server must survive upgrades.
  • Verify the published file actually changed (size / 404 / Last-Modified) after publish.sh.

Never

  • Never add Co-Authored-By / "Generated with" trailers to commits — the user authors commits solo. (This was done wrong in earlier commits; do not repeat.)
  • Never reintroduce nonce-based CSP — it broke first paint. Use the static 'unsafe-inline' CSP in next.config.js.
  • Never run app commands from the repo root — always cd proof-of-work first.
  • Never export non-HTTP-method symbols from a route.ts — Next.js rejects the build (helpers go in lib/, e.g. lib/ai/activateConfig.ts).
  • Never commit app.db, *.bak, or any user data — they're gitignored; double-check git status before git add.
  • Never click Uninstall on a StartOS package during a data cutover — it destroys the volume; use Stop.
  • Never assume GitHub — don't add a GitHub remote or push there.

Current state

Latest version is 1.1.0:9 (P2 hardening batch, on master). 1.1.0:8 was the last build+sideload confirmed booting; :9 is being built + sideloaded this session — the container privilege-drop is only verified once that boot succeeds and the app writes /data as uid 1001. Registry empty, publishing parked (sideload-only via make install).

Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).

Done this session (P2 batch from EVALUATION.md, reviewed by the reviewer agent): malformed bodies / invalid date / out-of-range pagination now 400 not 500 (new lib/http.ts readJsonBody across 11 CRUD routes; explicit guard on me/import + admin/signups); POST /api/auth rate-limited (shares the UI login:${ip} 10/15min bucket; 429+Retry-After); rate-limiter XFF anti-spoof (rightmost entry); container drops root via su-exec. Tests 209 pass, build + tsc + lint clean.

In progress: build + sideload of 1.1.0:9 (make x86make install from start9/0.4/), then verify it boots + writes /data as non-root.

Next steps (priority order):

  1. Next.js 14→15 major bump (the remaining P1 — CVEs) as its own tested change — planned next; the login server action already uses async cookies()/headers(), easing the migration.
  2. P3 hardening batch (ROADMAP.md → Security & hardening): login timing oracle, CSP unsafe-eval, /api/health info disclosure, rate-limit map leak, exerciseId ownership on workout PATCH/sets POST, 30-day sessions, text max-length. Also unify the 3rd JSON-parse pattern in programs/[id]/days/[dayId]/start.
  3. Tiered AI prompt formatting (ROADMAP.md → AI quality).

Open/parked: rate-limit per-IP correctness depends on the StartOS proxy forwarding real client IPs (unverified on the box). publish.sh Step-3 registry no-op (parked w/ publishing). Community-registry 4 blockers (ROADMAP.md → Packaging).

Git remote: origin → self-hosted Gitea at ssh://git@immense-voyage.local:59916/grant/proof-of-work.git; master tracks it. (~/.proof-of-work/{publish,unpublish}.sh registry/FileBrowser hosts are separate from this code remote.)