docs(spec): hide /api entirely — drop Route Handler section, document RSC+Server-Actions choice
This commit is contained in:
parent
ff99b1248a
commit
5cac356007
@ -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:
|
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.
|
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.
|
3. Builds reproducibly through `scripts/publish.sh` like the four existing services.
|
||||||
4. Comes up automatically with `bash scripts/dev.sh up`.
|
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 |
|
| 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
|
### 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) |
|
Instead:
|
||||||
|---|---|---|
|
|
||||||
| `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:
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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`:
|
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
|
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.
|
||||||
// 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.
|
|
||||||
|
|
||||||
### Empty UI page
|
### 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/layout.tsx` | Create — title + Tailwind globals |
|
||||||
| `web/app/page.tsx` | Create — placeholder card |
|
| `web/app/page.tsx` | Create — placeholder card |
|
||||||
| `web/app/globals.css` | Create — `@import "tailwindcss";` |
|
| `web/app/globals.css` | Create — `@import "tailwindcss";` |
|
||||||
| `web/app/api/[...path]/route.ts` | Create — catch-all proxy |
|
|
||||||
| `web/.gitignore` | Create — Next.js standard |
|
| `web/.gitignore` | Create — Next.js standard |
|
||||||
| `web/.dockerignore` | Create — exclude node_modules / .next |
|
| `web/.dockerignore` | Create — exclude node_modules / .next |
|
||||||
| `docker/web-next/Dockerfile` | Create — multi-stage Node 22 alpine |
|
| `docker/web-next/Dockerfile` | Create — multi-stage Node 22 alpine |
|
||||||
@ -380,7 +291,7 @@ No file removals. Nothing in `app/` is touched.
|
|||||||
Low.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user