docs(spec): hide /api entirely — drop Route Handler section, document RSC+Server-Actions choice

This commit is contained in:
yiekheng 2026-05-02 20:36:11 +08:00
parent ff99b1248a
commit 5cac356007

View File

@ -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 `/<page>` 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<string, { upstream: string; trailingSlash: boolean }> = {
[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/<hash>/` 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<NextResponse> {
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.