diff --git a/docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md b/docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md index 7fe7932..73b3159 100644 --- a/docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md +++ b/docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md @@ -14,7 +14,7 @@ Stand up a new `cm-web-next` Docker service running a Next.js 15 app at `web/` in this repo, exposed on host port `${CM_WEB_NEXT_HOST_PORT:-8010}` (default 8010 — non-conflicting with 8000/dev, 8001/rex, 8005/siong). The app: 1. Renders a single placeholder page so the operator can confirm the service is up. -2. Provides four Route Handler endpoints that proxy to `api-server:3000` with **byte-equivalent behavior** to the existing Flask `cm_web_view.py` proxies — `GET /api/acc/`, `GET /api/user/`, `POST /api/update-acc-data`, `POST /api/update-user-data`. (B2 will start using these from React; B4 will retire the Flask versions.) +2. Does **not** expose any public `/api/*` route. Browser-side data access in B2 will go through React Server Components (server-side fetch from `api-server:3000` inside the compose network) and Server Actions (mutations) — no scrapable JSON endpoint. See "No public /api/* route" below. 3. Builds reproducibly through `scripts/publish.sh` like the four existing services. 4. Comes up automatically with `bash scripts/dev.sh up`. @@ -79,107 +79,19 @@ cm_bot_v2/ | Node | 22 LTS (in container) | Current LTS line | | Trailing slashes | `trailingSlash: true` | Parity with Flask `@app.route('/api/acc/')` paths | -### API path hashing — opaque public URLs +### No public `/api/*` route — RSC + Server Actions architecture -The four endpoints exposed by the Flask `cm_web_view.py` (`/api/acc/`, `/api/user/`, `/api/update-acc-data`, `/api/update-user-data`) are renamed at the public boundary using SHA-256 (truncated to 16 hex chars) of the endpoint name. The web is not designed for SEO and scanners shouldn't be tipped off to "an endpoint named acc exists": +The Flask `cm_web_view.py` exposed four JSON proxy endpoints to the browser (`/api/acc/`, `/api/user/`, `/api/update-acc-data`, `/api/update-user-data`). The Next.js rewrite **does not** replicate them as public routes. There's no third-party API consumer (the bot CLI talks to `api-server` directly inside the compose network, not through the web), so a public JSON surface is pure attack surface with no upside. -| Logical name | Public path (web-next) | api-server upstream (unchanged) | -|---|---|---| -| `acc` | `/api/414322309db5c06d/` | `/acc/` | -| `user` | `/api/04f8996da763b7a9/` | `/user/` | -| `update-acc-data` | `/api/982830e2982d95de/` | `/update-acc-data` | -| `update-user-data` | `/api/f1a25b37d8db494c` | `/update-user-data` | +Instead: -Properties: -- **Deterministic.** No salt — same hash on rex, siong, dev. Operators reading code see a hash; running `python3 -c "import hashlib; print(hashlib.sha256(b'acc').hexdigest()[:16])"` confirms the mapping. -- **Boundary only.** Only `cm-web-next` translates hashes. `api-server`'s Flask routes are untouched (`/acc/`, `/user/`, etc.). Since `api-server` has no host port (per C5), scanners can't reach those names directly. -- **Trailing-slash preserved.** GET endpoints keep the trailing slash (matching Flask), POST endpoints don't (matching Flask). -- **Mapping lives in `web/lib/api-paths.ts`.** Both the Route Handler (server) and any future client React code (B2) read from the same exported constants. Hardcoded values — no runtime hashing on the request path. +- **Reads (B2)** happen inside React Server Components. `app/page.tsx` and friends are server-rendered; their server-side `fetch("http://api-server:3000/acc/")` calls run on the Next.js server inside the docker network. The browser only ever receives the rendered HTML / RSC payload — no JSON endpoint to call. +- **Writes (B2)** go through Next.js Server Actions: `"use server"` async functions called from React components. Next.js auto-handles the wire format (POST to `/` with magic encoded payload, processed server-side and routed by the framework). The browser never sees a `/api/*` path. +- **api-server stays internal** as designed in C5 (no host port published). `web-next` reaches it as `api-server:3000` via the compose network, same as `web-view` does today. -`web/lib/api-paths.ts`: +Net result: zero public JSON endpoints, zero scrapable paths beyond `/` and Server Actions' opaque internal routes. The hash-encoded URL scheme that earlier drafts proposed is gone — there's nothing to obfuscate when nothing is public. -```typescript -// SHA-256(endpoint).hex[:16]. Deterministic; no salt. Public-boundary only: -// the upstream api-server still uses the readable names. See B1 spec. -export const API_PATHS = { - acc: "414322309db5c06d", // upstream: /acc/ - user: "04f8996da763b7a9", // upstream: /user/ - updateAcc: "982830e2982d95de", // upstream: /update-acc-data - updateUser: "f1a25b37d8db494c", // upstream: /update-user-data -} as const; - -// Reverse map for the Route Handler. Keys are the public path segment, -// values are { upstream: string; trailingSlash: boolean }. -export const PUBLIC_TO_UPSTREAM: Record = { - [API_PATHS.acc]: { upstream: "acc", trailingSlash: true }, - [API_PATHS.user]: { upstream: "user", trailingSlash: true }, - [API_PATHS.updateAcc]: { upstream: "update-acc-data", trailingSlash: false }, - [API_PATHS.updateUser]: { upstream: "update-user-data", trailingSlash: false }, -}; -``` - -If a request comes in for a hash that isn't in the mapping, the Route Handler returns 404 (not a 500 — it's literally not a route we expose). - -### Route Handler proxy - -A single catch-all Route Handler at `web/app/api/[...path]/route.ts` forwards mapped `/api//` requests to `api-server:3000` (the upstream service name on the compose network). The handler: - -1. Reads the first segment of `path` as the hash. -2. Looks it up in `PUBLIC_TO_UPSTREAM` from `web/lib/api-paths.ts`. -3. If found, builds the upstream URL with the original `trailingSlash` flag and forwards. -4. If not found, returns 404. - -Implementation sketch: - -```typescript -import { NextRequest, NextResponse } from "next/server"; -import { PUBLIC_TO_UPSTREAM } from "@/lib/api-paths"; - -const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000"; - -async function forward(request: NextRequest, path: string[]): Promise { - const hash = path[0]; - const route = PUBLIC_TO_UPSTREAM[hash]; - if (!route) { - return NextResponse.json({ error: "Not Found" }, { status: 404 }); - } - const target = `${API_BASE_URL}/${route.upstream}${route.trailingSlash ? "/" : ""}`; - - const init: RequestInit = { - method: request.method, - headers: { "content-type": request.headers.get("content-type") ?? "application/json" }, - }; - if (request.method !== "GET" && request.method !== "HEAD") { - init.body = await request.text(); - } - - try { - const upstream = await fetch(target, init); - const body = await upstream.text(); - return new NextResponse(body, { - status: upstream.status, - headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" }, - }); - } catch (err) { - return NextResponse.json({ error: String(err) }, { status: 500 }); - } -} - -export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { - return forward(req, (await ctx.params).path); -} -export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { - return forward(req, (await ctx.params).path); -} -``` - -Why catch-all instead of one file per endpoint: - -- The four endpoints are mechanically identical proxies through a hash-table lookup; writing four near-duplicate files is a YAGNI violation. -- B2 may need additional methods or per-route logic; that's the time to split (and split only the routes that need it). -- The error shape (`{"error": str}` with HTTP 5xx, `{"error": "Not Found"}` with 404) matches the Flask version (`return jsonify({"error": str(e)}), 500`) and adds 404 for unmapped paths. - -The trailing-slash preservation comes from the per-route `trailingSlash` flag in the mapping, not from the request URL — keeps client and server in lockstep regardless of how the URL was typed. +For B1 specifically there is no Route Handler, no `web/app/api/` directory, no `web/lib/api-paths.ts`, and no smoke-test API call. The placeholder page documents the architecture so the next reader understands the choice. ### Empty UI page @@ -349,7 +261,6 @@ Standard Next.js ignores. `web/.gitignore` includes `.next/`, `node_modules/`, b | `web/app/layout.tsx` | Create — title + Tailwind globals | | `web/app/page.tsx` | Create — placeholder card | | `web/app/globals.css` | Create — `@import "tailwindcss";` | -| `web/app/api/[...path]/route.ts` | Create — catch-all proxy | | `web/.gitignore` | Create — Next.js standard | | `web/.dockerignore` | Create — exclude node_modules / .next | | `docker/web-next/Dockerfile` | Create — multi-stage Node 22 alpine | @@ -380,7 +291,7 @@ No file removals. Nothing in `app/` is touched. Low. - **Side-by-side means double the surface for now.** Two web services running, two host ports, two images. The cost is real but bounded — once B2 is feature-complete and B4 cuts over, the old `cm-web` retires and we're back to one. The alternative (in-place rewrite) was higher risk because broken B2 commits would break prod. -- **Catch-all Route Handler regex semantics.** Next.js's `[...path]` matches any depth; the explicit Flask routes are flat (e.g., `/api/acc/`). If any future api-server endpoint accidentally collides with a Next.js convention path (`/api/_next/...`, `/api/__nextjs_original-stack-frames`), behavior could be surprising. None of these conflicts exist today (api-server's routes are `/acc/`, `/user/`, `/update-acc-data`, `/update-user-data`). +- **No public `/api/*` route — be aware before adding one.** B1 explicitly does not expose JSON endpoints to the browser. If a future need surfaces (a third-party consumer, a mobile native client, a webhook callback), revisit the architecture deliberately rather than adding a Route Handler ad-hoc. The threat model assumed in this design is "internal CRUD only, browser is the only consumer." - **Trailing slashes.** `trailingSlash: true` in Next.js means `/api/acc` redirects to `/api/acc/` with a 308. The Flask version always required the slash. Behavior is parity-equivalent for clients that include the slash; clients that don't get a redirect they didn't get before. The UI we control will always include it. - **Build context size.** Docker build context includes the whole repo unless we set up `.dockerignore`. The repo's existing `.dockerignore` excludes `__pycache__`, `*.py[cod]`, `*.log`, `.git`, `logs/`, `node_modules/`. The `node_modules/` entry already prevents copying the host's `web/node_modules/` if the operator ran `npm install` on the host — good. We don't need a new repo-root `.dockerignore` change.