feat(spec): hash-encode API paths at the cm-web-next public boundary

This commit is contained in:
yiekheng 2026-05-02 18:14:40 +08:00
parent d60c5c97a9
commit 31b092f231

View File

@ -79,19 +79,71 @@ cm_bot_v2/
| Node | 22 LTS (in container) | Current LTS line | | Node | 22 LTS (in container) | Current LTS line |
| Trailing slashes | `trailingSlash: true` | Parity with Flask `@app.route('/api/acc/')` paths | | 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<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 ### 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/<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 ```typescript
import { NextRequest, NextResponse } from "next/server"; 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"; const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
async function forward(request: NextRequest, path: string[]) { async function forward(request: NextRequest, path: string[]): Promise<NextResponse> {
const targetPath = "/" + path.join("/"); const hash = path[0];
const trailingSlash = request.nextUrl.pathname.endsWith("/") ? "/" : ""; const route = PUBLIC_TO_UPSTREAM[hash];
const target = `${API_BASE_URL}${targetPath}${trailingSlash}`; if (!route) {
return NextResponse.json({ error: "Not Found" }, { status: 404 });
}
const target = `${API_BASE_URL}/${route.upstream}${route.trailingSlash ? "/" : ""}`;
const init: RequestInit = { const init: RequestInit = {
method: request.method, 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: 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). - 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 ### Empty UI page