feat(spec): hash-encode API paths at the cm-web-next public boundary
This commit is contained in:
parent
d60c5c97a9
commit
31b092f231
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user