Files
proof-of-work/AGENTS.md
T
Keysat c02892e178
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Update Current state: 1.2.0:1 built + sideloaded
2026-06-13 00:33:57 -05:00

12 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 15 (App Router, server components + server actions, SSE streaming) — dynamic request APIs are async (see Conventions)
  • React 19, 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.)
  • Next 15 dynamic APIs are async — await them. Route-handler context params, page/layout params + searchParams, and cookies()/headers() are all Promises. Established idiom (keeps handler bodies untouched): [id] routes take context: { params: Promise<{…}> } then const params = await context.params; server pages take props then const params = await props.params / const searchParams = await props.searchParams. Route tests pass params: Promise.resolve({…}). All routes are dynamic, so the Next 15 "uncached by default" change is a no-op here.
  • 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.2.0:1 — the Next.js 14→15 / React 18→19 upgrade (the remaining P1; closes the Next framework RSC + middleware-bypass CVEs). Built + sideloaded to the StartOS box (immense-voyage.local, 2026-06-13, on master) as proof-of-work_x86_64.s9pk (80M, git f487204). Verified locally before build: tsc + lint clean, 209 tests pass, next build succeeds, standalone bundle traces the Prisma engine. Registry empty, publishing parked (sideload-only via make install).

Pending on-box check: confirm 1.2.0:1 boots clean in StartOS → Logs (this supersedes the still-unconfirmed 1.1.0:9 non-root clean-boot check — same Logs verification: entrypoint logs launching … as nextjs, app writes /data as uid 1001 with no permission errors).

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: Next 15 / React 19 upgrade. Async-params/searchParams migration across 10 [id] route files + 4 server pages (uniform await re-derive idiom — see Conventions). Deps: next 15.5.x, react/react-dom 19.x, eslint-config-next 15.5.x, lucide-react → 1.x, next-themes → 0.4.x (the latter two bumped for React-19 peers). next.config.js/middleware unchanged; no schema/data change. Residual npm audit items are dev/build-only tooling (esbuild/tsx, picomatch, bundled postcss) — not in the runtime image; do NOT audit fix --force (npm wrongly suggests downgrading to next@9).

Next steps (priority order):

  1. 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.
  2. Tiered AI prompt formatting (ROADMAP.md → AI quality).
  3. (Later) Next 15→16 when ready — next lint is deprecated in 15.5 (removed in 16), plus Next 16's own breaking changes; do it as its own tested bump.

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.)