feat(plan): wire hash-encoded API paths into B1 plan

This commit is contained in:
yiekheng 2026-05-02 18:15:35 +08:00
parent 31b092f231
commit f0fbd01a79

View File

@ -27,7 +27,8 @@
| `web/app/layout.tsx` | Create (via frontend-design) | Root layout. | | `web/app/layout.tsx` | Create (via frontend-design) | Root layout. |
| `web/app/page.tsx` | Create (via frontend-design) | Scaffold-confirmation placeholder. | | `web/app/page.tsx` | Create (via frontend-design) | Scaffold-confirmation placeholder. |
| `web/app/globals.css` | Create | `@import "tailwindcss";` | | `web/app/globals.css` | Create | `@import "tailwindcss";` |
| `web/app/api/[...path]/route.ts` | Create | Catch-all GET/POST proxy to `api-server:3000`. | | `web/app/api/[...path]/route.ts` | Create | Catch-all GET/POST proxy that maps the public hash to the upstream path and forwards to `api-server:3000`. |
| `web/lib/api-paths.ts` | Create | Hash → upstream-name mapping (single source of truth for both the Route Handler and future client code). |
| `docker/web-next/Dockerfile` | Create | Multi-stage Node 22 alpine, standalone output. | | `docker/web-next/Dockerfile` | Create | Multi-stage Node 22 alpine, standalone output. |
| `docker-compose.yml` | Modify | Add `web-next` service. | | `docker-compose.yml` | Modify | Add `web-next` service. |
| `docker-compose.override.yml` | Modify | Add `web-next` build directive. | | `docker-compose.override.yml` | Modify | Add `web-next` build directive. |
@ -245,7 +246,8 @@ Generate two files for a Next.js 15 App Router project that uses Tailwind v4
- Product name "CM Bot V2" - Product name "CM Bot V2"
- Literal text "cm-web-next scaffold" (operators grep for this) - Literal text "cm-web-next scaffold" (operators grep for this)
- One-line note that the real dashboard lands in B2 - One-line note that the real dashboard lands in B2
- An obvious link to /api/acc/ for smoke-testing the proxy - An obvious link to /api/414322309db5c06d/ for smoke-testing the proxy
(this is the SHA-256[:16] of "acc"; see web/lib/api-paths.ts)
Constraints: Constraints:
- Tailwind v4 utility classes only — no external font, image, or icon deps. - Tailwind v4 utility classes only — no external font, image, or icon deps.
@ -294,12 +296,60 @@ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
--- ---
## Task 3: Catch-all Route Handler proxy to `api-server:3000` ## Task 3: API path mapping + catch-all Route Handler proxy
**Files:** **Files:**
- Create: `web/lib/api-paths.ts`
- Create: `web/app/api/[...path]/route.ts` - Create: `web/app/api/[...path]/route.ts`
- [ ] **Step 1: Create the route handler directory** - [ ] **Step 1: Create `web/lib/api-paths.ts`**
```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/lib
```
Create `web/lib/api-paths.ts` with the precomputed hashes:
```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 },
};
```
Verify the hashes (so future readers can double-check):
```bash
.venv/bin/python3 -c "
import hashlib
for endpoint in ['acc', 'user', 'update-acc-data', 'update-user-data']:
print(f'{endpoint:24s} -> {hashlib.sha256(endpoint.encode()).hexdigest()[:16]}')
"
```
Expected output (matches the constants above):
```
acc -> 414322309db5c06d
user -> 04f8996da763b7a9
update-acc-data -> 982830e2982d95de
update-user-data -> f1a25b37d8db494c
```
- [ ] **Step 2: Create the route handler directory**
```bash ```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app/api/\[...path\] mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app/api/\[...path\]
@ -307,19 +357,26 @@ mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app/api/\[...path\]
(The square brackets are Next.js's catch-all dynamic segment syntax. Shell-escape with backslashes.) (The square brackets are Next.js's catch-all dynamic segment syntax. Shell-escape with backslashes.)
- [ ] **Step 2: Write the route handler** - [ ] **Step 3: Write the route handler**
Create `web/app/api/[...path]/route.ts`: Create `web/app/api/[...path]/route.ts`:
```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[]): Promise<NextResponse> { async function forward(
const targetPath = "/" + path.join("/"); request: NextRequest,
const trailingSlash = request.nextUrl.pathname.endsWith("/") ? "/" : ""; path: string[],
const target = `${API_BASE_URL}${targetPath}${trailingSlash}`; ): 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 = { const init: RequestInit = {
method: request.method, method: request.method,
@ -362,7 +419,7 @@ export async function POST(
} }
``` ```
The error shape `{"error": <string>}` with HTTP 500 matches `cm_web_view.py`'s Flask proxy on exception. Mapped requests (`GET /api/414322309db5c06d/` etc.) resolve through `PUBLIC_TO_UPSTREAM` and forward to `api-server:3000/acc/` etc. Unmapped hashes return 404 — they're literally not routes we expose. Network/upstream errors return `{"error": <stringified-error>}` with HTTP 500, matching `cm_web_view.py`'s shape.
- [ ] **Step 3: Build to verify the route compiles** - [ ] **Step 3: Build to verify the route compiles**
@ -373,13 +430,22 @@ npm run build 2>&1 | tail -25
Expected: route summary now includes `/api/[...path]` (or similar). No TS errors. Expected: route summary now includes `/api/[...path]` (or similar). No TS errors.
- [ ] **Step 4: Commit** - [ ] **Step 4: Build and verify the route compiles**
```bash
cd /home/yiekheng/projects/cm_bot_v2/web && \
npm run build 2>&1 | tail -25
```
Expected: route summary now includes `/api/[...path]`. No TS errors. The `@/lib/api-paths` import resolves via the `paths` mapping in `tsconfig.json`.
- [ ] **Step 5: Commit**
```bash ```bash
cd /home/yiekheng/projects/cm_bot_v2 && \ cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/api/\[...path\]/route.ts && \ git add web/lib/api-paths.ts web/app/api/\[...path\]/route.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add catch-all Route Handler proxy to api-server:3000" commit -m "feat(web): hash-encoded API paths + catch-all Route Handler proxy"
``` ```
--- ---
@ -779,10 +845,14 @@ Expected: hits in the response. Open `http://localhost:8010/` in a browser — p
- [ ] **Step 3: API proxy parity (GET)** - [ ] **Step 3: API proxy parity (GET)**
```bash ```bash
diff <(curl -s http://localhost:8000/api/acc/) <(curl -s http://localhost:8010/api/acc/) && echo "GET parity OK" # Old Flask: /api/acc/. New Next.js: /api/<sha256("acc")[:16]>/.
diff \
<(curl -s http://localhost:8000/api/acc/) \
<(curl -s http://localhost:8010/api/414322309db5c06d/) \
&& echo "GET parity OK"
``` ```
Expected: `GET parity OK`. Both go through to `api-server:3000/acc/` and return the same JSON. Expected: `GET parity OK`. Both go through to `api-server:3000/acc/` and return the same JSON. The hash mapping is the only thing in front of the upstream — same body, same status.
- [ ] **Step 4: API proxy parity (POST)** - [ ] **Step 4: API proxy parity (POST)**
@ -793,12 +863,20 @@ diff \
http://localhost:8000/api/update-acc-data) \ http://localhost:8000/api/update-acc-data) \
<(curl -s -X POST -H 'Content-Type: application/json' \ <(curl -s -X POST -H 'Content-Type: application/json' \
-d '{"username":"13c1000","password":"x","status":"","link":""}' \ -d '{"username":"13c1000","password":"x","status":"","link":""}' \
http://localhost:8010/api/update-acc-data) \ http://localhost:8010/api/982830e2982d95de) \
&& echo "POST parity OK" && echo "POST parity OK"
``` ```
Expected: `POST parity OK`. Both POSTs round-trip through to `api-server:3000/update-acc-data`. Expected: `POST parity OK`. Both POSTs round-trip through to `api-server:3000/update-acc-data`.
- [ ] **Step 4b: Unmapped hash returns 404**
```bash
curl -s -o /dev/null -w "code=%{http_code}\n" http://localhost:8010/api/deadbeefdeadbeef/
```
Expected: `code=404`. Confirms the proxy is allowlist-based, not pass-through.
- [ ] **Step 5: Old `cm-web` still serves** - [ ] **Step 5: Old `cm-web` still serves**
```bash ```bash
@ -842,7 +920,10 @@ Expected: clean shutdown (no orphan complaints — `--remove-orphans` from the C
| Next.js 15 + App Router + TypeScript + Tailwind v4 | Task 1 (configs) + Task 2 (app shell) | | Next.js 15 + App Router + TypeScript + Tailwind v4 | Task 1 (configs) + Task 2 (app shell) |
| `output: "standalone"`, `trailingSlash: true` in `next.config.ts` | Task 1 step 3 | | `output: "standalone"`, `trailingSlash: true` in `next.config.ts` | Task 1 step 3 |
| `frontend-design`-generated `layout.tsx` and `page.tsx` | Task 2 step 2 | | `frontend-design`-generated `layout.tsx` and `page.tsx` | Task 2 step 2 |
| Catch-all proxy at `web/app/api/[...path]/route.ts` | Task 3 | | Hash-encoded API paths at the public boundary | Task 3 |
| `web/lib/api-paths.ts` shared mapping for server + client | Task 3 step 1 |
| Catch-all proxy at `web/app/api/[...path]/route.ts` (allowlist-based) | Task 3 step 3 |
| Unmapped hash returns 404 (not pass-through) | Task 9 step 4b |
| Multi-stage Dockerfile (Node 22 alpine, standalone output) | Task 4 | | Multi-stage Dockerfile (Node 22 alpine, standalone output) | Task 4 |
| `web-next` service in base compose, `${CM_WEB_NEXT_HOST_PORT:-8010}:3000` | Task 5 step 1 | | `web-next` service in base compose, `${CM_WEB_NEXT_HOST_PORT:-8010}:3000` | Task 5 step 1 |
| Build directive in override | Task 5 step 2 | | Build directive in override | Task 5 step 2 |