From 31b092f231816355826978026594cd2f9f59c68e Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 2 May 2026 18:14:40 +0800 Subject: [PATCH] feat(spec): hash-encode API paths at the cm-web-next public boundary --- .../2026-05-02-b1-nextjs-scaffold-design.md | 68 ++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) 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 a2eaae8..7fe7932 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 @@ -79,19 +79,71 @@ 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 + +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": + +| 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` | + +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. + +`web/lib/api-paths.ts`: + +```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 all `/api/*` requests to `api-server:3000` (the upstream service name on the compose network). Implementation sketch: +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[]) { - const targetPath = "/" + path.join("/"); - const trailingSlash = request.nextUrl.pathname.endsWith("/") ? "/" : ""; - const target = `${API_BASE_URL}${targetPath}${trailingSlash}`; +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, @@ -123,11 +175,11 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ path: stri Why catch-all instead of one file per endpoint: -- The four endpoints today are mechanically identical proxies; writing four near-duplicate files is a YAGNI violation. +- 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 500) matches the Flask version (`return jsonify({"error": str(e)}), 500`). +- 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 means `GET /api/acc/` proxies to `api-server:3000/acc/` (Flask's exact pattern); `POST /api/update-acc-data` proxies to `api-server:3000/update-acc-data`. Same shape as `cm_web_view.py:proxy_acc()`, `proxy_update_acc()`, etc. +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. ### Empty UI page