Skip to content

Commit dc114d7

Browse files
cemililikclaude
andcommitted
docs(architecture): say what the SDK carries, and stop naming a route that cannot exist
Both findings were real, and checking the first one surfaced a third thing in the same diagram. "Next->>API: fetches state X-LearnStack-Host + …" was meant as the verb and reads as the noun — a call that fetches state, rather than a call that states the host. It says "call carrying" now. The edge-resolution section was a nine-line prose block where CLAUDE.md asks for "heading + bullets over essay paragraphs". Four bullets, one per rule, and no rule lost: the edge resolves for rendering, the API resolves for itself, the SDK states a HOST over the hop because a host is a lookup key with a closed codomain where a tenant id is a selection, and X-Tenant-Id is an assertion whose mismatch is a 404. And the diagram and the middleware sample both called `GET /v1/tenants/resolve-host` — an endpoint no other document in the corpus defines, on a route shape ADR-0024 does not allow, since everything public is `/api/v{N}/`. An earlier round corrected the prose around it and left these two. They now name the shape and the owner — an `/api/v1` host lookup that Phase 02d ships, which is the phase that first needs one, because it is the phase that puts two tenants in a browser. The `fetch('/v1/...')` in the forbidden-calls sentence goes the same way: an example of what not to write should still not teach a route shape that cannot exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0a534b5 commit dc114d7

1 file changed

Lines changed: 17 additions & 13 deletions

File tree

‎docs/architecture/14-frontend-architecture.md‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,25 @@ Splitting into separate apps is governed by [ADR 0009 — Frontend Single App Fi
7474

7575
## Tenant + Organization Resolution at the Edge
7676

77-
Next.js middleware resolves the tenant (and optionally the organization) before any
78-
route handler runs — for **rendering**. It is not the backend's source of truth and
79-
never was one: the API resolves the host itself, independently and authoritatively,
80-
and the edge's answer is a render-time convenience
81-
([ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md);
82-
[Standards 07 § Tenant Context](../standards/07-frontend-architecture.md) owns the
83-
rules). What the server-side SDK sends the API is the **visitor's host** over the
84-
trusted hop — `X-LearnStack-Host` with `X-LearnStack-Hop-Secret` — and the tenant
85-
header travels only as an assertion the API compares against its own answer.
77+
Next.js middleware resolves the tenant, and optionally the organization, before any
78+
route handler runs. Four rules, all of them from
79+
[ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md);
80+
[Standards 07 § Tenant Context](../standards/07-frontend-architecture.md) owns them.
81+
82+
- **The edge resolves for rendering, not for authority.** Branding, locale, which
83+
navigation to draw. It is not the backend's source of truth and never was one.
84+
- **The API resolves the host itself**, independently, on every request.
85+
- **The SDK states the visitor's *host*** over the trusted hop —
86+
`X-LearnStack-Host` with `X-LearnStack-Hop-Secret`. A host is a lookup key with a
87+
closed codomain; a tenant id is a selection.
88+
- **`X-Tenant-Id` is an assertion.** The API compares it against what it resolved.
89+
A mismatch is a 404; the header never selects a tenant.
8690

8791
```ts
8892
// src/middleware.ts
8993
export async function middleware(req: NextRequest) {
9094
const host = normaliseHost(req.headers.get('host') ?? '');
91-
const resolved = await resolveHost(host); // calls /v1/tenants/resolve-host
95+
const resolved = await resolveHost(host); // an /api/v1 lookup; Phase 02d ships it
9296
if (!resolved) return new NextResponse(null, { status: 404 });
9397

9498
const locale = resolveLocaleFromPathOrTenant(req.nextUrl, resolved.tenant);
@@ -165,13 +169,13 @@ sequenceDiagram
165169
Browser->>Edge: GET https://english.example.com/tr/courses
166170
Edge->>Edge: cache lookup host -> {tenantId, organizationId?}
167171
alt cache miss
168-
Edge->>API: GET /v1/tenants/resolve-host?host=english.example.com
172+
Edge->>API: GET /api/v1 host lookup (Phase 02d)
169173
API->>API: SELECT FROM platform_host_to_tenant WHERE host = $1
170174
API-->>Edge: { tenantId, organizationId?, branding }
171175
Edge->>Edge: cache (60s SWR)
172176
end
173177
Edge->>Next: forward with x-tenant-id (assertion), x-organization-id?, x-locale
174-
Next->>API: fetches state X-LearnStack-Host + X-LearnStack-Hop-Secret
178+
Next->>API: call carrying X-LearnStack-Host + X-LearnStack-Hop-Secret
175179
API->>API: resolve the host independently; compare any assertion
176180
API-->>Next: tenant- + org-scoped responses
177181
Next-->>Browser: rendered HTML
@@ -234,7 +238,7 @@ Two paths:
234238
- **Server-side** — React Server Components and route handlers call the .NET API directly using the typed SDK in `packages/sdk`. The SDK is generated from the backend's OpenAPI document; [Standards 07 § SDK](../standards/07-frontend-architecture.md) owns when and how.
235239
- **Client-side** — interactive components fetch through a thin BFF endpoint under `/api/...` that forwards the request with the user's JWT and the tenant header. The BFF exists to keep API base URLs and CORS off the public web origin, not to wrap business logic.
236240

237-
The SDK is the only sanctioned way to call the API. Hand-rolled `fetch('/v1/...')` calls are blocked by lint — `no-restricted-globals`, because `fetch` is a global and an import rule could never have caught a single call.
241+
The SDK is the only sanctioned way to call the API. Hand-rolled `fetch('/api/v1/...')` calls are blocked by lint — `no-restricted-globals`, because `fetch` is a global and an import rule could never have caught a single call.
238242

239243
## Authentication on the Frontend
240244

0 commit comments

Comments
 (0)