Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
88f52c4
docs(roadmap): close P02d-4 after PR merge
cemililik Oct 8, 2026
b04c44e
docs: accept P02d-5 trusted rendering decisions
cemililik Oct 8, 2026
26e7e16
feat(web): add native trusted HTTPS ingress
cemililik Oct 8, 2026
09ee387
fix(web): bind provenance across Next URL processing
cemililik Oct 8, 2026
b502b30
fix(web): fence native upgrade delegation
cemililik Oct 8, 2026
86d6372
feat(api): enforce trusted visitor budgets
cemililik Oct 8, 2026
9ab81b0
fix(api): charge each rate-limited request once
cemililik Oct 8, 2026
bbbffe8
fix(api): dispose both anonymous limiter budgets
cemililik Oct 8, 2026
d3e1ea6
feat(web): add trusted public caller and locale entry
cemililik Oct 8, 2026
b4c2d44
fix(web): clean production fixture after listener refusal
cemililik Oct 8, 2026
c751e62
test(web): prove the locale width boundary
cemililik Oct 8, 2026
30e1c81
feat(web): prove public rendering and frontend boundaries
cemililik Oct 8, 2026
0e0b519
test(web): close public source and runner proof gaps
cemililik Oct 8, 2026
fcb0752
test(web): close static export and owned cleanup gaps
cemililik Oct 8, 2026
e6f2e70
docs: close P02d-5 implementation and review records
cemililik Oct 8, 2026
4423f93
Merge remote-tracking branch 'origin/main' into development
cemililik Oct 8, 2026
4168ea1
fix(web): compile native ingress before lint resolution
cemililik Oct 8, 2026
e580444
docs(roadmap): record P02d-5 PR and lint fix review
cemililik Oct 8, 2026
5db8dae
fix(web): preserve inline type import runtime edges
cemililik Oct 8, 2026
0701640
fix(web): strip untrusted Next URL carriers
cemililik Oct 8, 2026
d8d540a
docs(architecture): propose bounded renderer admission
cemililik Oct 9, 2026
34eb839
docs(roadmap): record admission proposal review closeout
cemililik Oct 9, 2026
1a7c6fd
docs(decisions): clarify renderer admission tradeoffs and proofs
cemililik Oct 9, 2026
6c443a4
docs(roadmap): record admission clarification review closeout
cemililik Oct 9, 2026
06479e1
docs(decisions): accept bounded renderer admission remediation
cemililik Oct 9, 2026
208d64f
fix(api): coordinate anonymous admission without refusal debit
cemililik Oct 9, 2026
a107df5
test(api): prove bounded sweeper batches release admission
cemililik Oct 9, 2026
3238444
docs(roadmap): close both admission remediation review rounds
cemililik Oct 9, 2026
458a1dd
fix(web): enforce native admission and pinned URL projection
cemililik Oct 9, 2026
e4b97b7
docs(roadmap): close both native admission review rounds
cemililik Oct 9, 2026
e2f92b7
test(web): harden public fixture ownership and containment
cemililik Oct 9, 2026
131a933
fix(web): join fixture shutdown and exact stream verdicts
cemililik Oct 9, 2026
3445f41
docs(roadmap): close both fixture remediation review rounds
cemililik Oct 9, 2026
0af3641
test(web): close public source and tooling proof gaps
cemililik Oct 9, 2026
2136cb0
fix(web): preserve authority origins through static aliases
cemililik Oct 9, 2026
ced7911
fix(web): enforce hop setter fences for constructor aliases
cemililik Oct 9, 2026
b595026
docs(roadmap): close both source and tooling review rounds
cemililik Oct 9, 2026
2b5b6ac
docs: align public rendering guidance and closeout evidence
cemililik Oct 9, 2026
869145e
docs(roadmap): reconcile duplicate remediation status
cemililik Oct 9, 2026
59a5f1c
docs(roadmap): close both corpus and evidence review rounds
cemililik Oct 9, 2026
f09a4ed
test(tooling): isolate temporary Git repository environments
cemililik Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 70 additions & 48 deletions .claude/skills/add-frontend-route/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ description: >

## Purpose

Land a new route in `frontend/apps/web` that respects route-group conventions, tenant + org
resolution at the edge, Server-Component-first rendering, and the typed SDK
contract per
Land a new route in `frontend/apps/web` that respects route-group conventions,
API-owned tenant/organization resolution, Server Component rendering and the typed
SDK contract per
[14-frontend-architecture.md](../../../docs/architecture/14-frontend-architecture.md)
+ [07-frontend-architecture.md](../../../docs/standards/07-frontend-architecture.md).

Expand Down Expand Up @@ -53,7 +53,7 @@ contract per

| Group | Purpose | Auth | Default render |
|-------|---------|------|----------------|
| `(public)` | Tenant public site (marketing, catalog, blog). | Anonymous by default; auth optional. | SSR + ISR-like cache per `(tenantId, organizationId?, locale, slug)` (under an open gate — see Step 8). |
| `(public)` | Tenant public site (marketing, catalog, blog). | Anonymous by default; auth optional. | Dynamic SSR, no-store per ADR-0053; see Step 8. |
| `(studio)` | Tenant admin Studio. | Tenant-admin or org-admin. Required at the edge. | SSR, no cache (always fresh). |
| `(portal)` | Learner / instructor portal. | Membership in the resolved tenant. | SSR shell + Client Component for interactivity. |

Expand All @@ -76,22 +76,23 @@ unless the screen genuinely needs one.

```tsx
// page.tsx (Server Component by default)
import { sdk } from "@learnstack/sdk/server";
import { UsersTable } from "./_components/users-table";
import { createConfiguredPublicClient } from '@/server/configured-public-client';

export default async function UsersPage({ searchParams }: { searchParams: { q?: string } }) {
const users = await sdk.identity.listUsers({ query: searchParams.q });
return <UsersTable initialUsers={users.items} />;
// The configured server caller supplies this transport; never a tenant-ID option.
export function loadCourses(envelope: string | null) {
const client = createConfiguredPublicClient(envelope);
if (!client) throw new Error('Invalid public ingress');
return client.getCourses(); // Locale comes from the authenticated route.
}
```

> **Open in Phase 02d.** The call shape above is illustrative. At HEAD
> `@learnstack/sdk/server` exports `createServerSdk(options)`, a typed stub that takes
> a `tenantId` and a `locale` and returns `{}`; no `sdk` object and no module namespace
> exist. What the SDK surface becomes once regeneration makes `paths` non-empty is G31,
> and the server transport's options are G35, in
> **P02d-4 delivered.** `@learnstack/sdk/server` exports an injected
> `createServerSdk(transport)` with four typed public GET wrappers; no global `sdk`
> object, tenant-ID option or module namespace exists. The example is a loader,
> not a complete route. P02d-5/G35 delivers the configured trusted transport in
> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
> the passes that close them edit this step.
> that caller precedes P02d-6 public page consumers. Read the verified envelope
> from request-local `headers()`; never create or expose a provenance stamp in a page.

Rules:

Expand All @@ -106,20 +107,48 @@ Rules:

The API resolves tenant and organization from the host
([ADR-0036 § Effective host and the trusted hop](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)).
The frontend never calls `IHostToTenantResolver`, and ADR-0036 makes
`frontend/packages/sdk/src/server.ts` the only frontend place that sets the hop
headers. At HEAD that file is a typed stub, and `src/middleware.ts` is a scaffold
that copies the raw host into `x-tenant-id` and sets `x-locale`; it sets no
`x-organization-id`. Their replacement is recorded as G35 and G36 in
The frontend never calls `IHostToTenantResolver`. P02d-4's SDK is a pure injected
transport contract and sets no hop headers. ADR-0053 replaces ADR-0036's older
exact setter
path with one server-only adapter in apps/web. P02d-5 Step 3's Node middleware
verifies the native envelope, bootstraps the live host and applies enabled-locale
entry before rebuilding downstream request headers. The caller verifies the
envelope again. G35 and G36 are recorded in
[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register).
Don't read `host` directly inside a page.
Don't read `host` directly inside a page. The
[public entry matrix](../../../docs/roadmap/phase-02d-walking-skeleton.md#public-entry-matrix)
is authoritative: bootstrap the live host before locale/redirect decisions;
enabled-locale membership precedes `courses` shorthand and exact `/studio` or
`/portal` scaffold handling. Disabled/malformed/unknown prefixes fail closed;
no locale header, cookie or query supplies authority. The API remains the only
tenant/organization owner. Continue the request-local validated `traceparent`
through bootstrap and the configured caller; Phase 11 owns participation/sampling.

**ADR-0054 native/URL controls delivered — 2026-10-09.**
[The replacement contract](../../../docs/decisions/0054-bounded-public-renderer-admission.md)
requires native GET/HEAD admission on every HTTP callback path, including
middleware-exempt health/assets/scaffolds, with masked no-store refusal of other
methods and bodyless HEAD. Production upgrades close; development retains only
validated GET HMR. The [remediation record](../../../docs/roadmap/phase-02d-walking-skeleton.md#remediation-step-2--native-ingress-and-url-boundary)
owns validation and both independent review rounds. Do not add
Server Actions/write routes or WebSocket consumers without an explicit owning
Phase 02b/Phase 06
admission decision. Preserve signed raw-target route/locale authority when
accounting for pinned Next's URL projection. Redirects retain inert query values,
duplicates and order with equivalent percent encoding; only verified live host,
accepted HTTPS port and local path select the destination.

### Step 5: Authentication + permission gating

For `(studio)` and `(portal)` routes:
Authentication below is the Phase 02b target, not current scaffold behavior.
Today's exact `/studio` and `/portal` roots pass only after public host bootstrap;
no sign-in/session helper is implemented. Phase 02b must explicitly admit its
login/callback namespace before adding authenticated routes.

- The middleware redirects unauthenticated requests to the Keycloak login.
- Permission check happens at the page level via the `auth()` helper:
For those future `(studio)` and `(portal)` routes:

- Unauthenticated requests redirect through the accepted BFF/Keycloak sign-in flow.
- A page-level permission check uses the future `auth()` helper:

```tsx
import { auth } from "@learnstack/auth/server"; // illustrative: no such package exists yet; Phase 02b's session work owns the real helper
Expand Down Expand Up @@ -178,17 +207,14 @@ See [add-i18n-key](../add-i18n-key/SKILL.md).

### Step 8: Public-site SSR caching

This step is under an open gate. How `(public)` routes render, and which Next.js
caches they may use, is G37 in
[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register).
The step is rewritten when that gate closes; until then add no `revalidate`,
`generateStaticParams` or `unstable_cache` to a `(public)` route. The cache key is
**not** tenant-bearing automatically. A statically rendered route, which is what
`revalidate` produces when the page reads no request data, is cached by path, and a
public URL carries no tenant
([Frontend Architecture Standards](../../../docs/standards/07-frontend-architecture.md)).
Every cache key carries the tenant, the organization where applicable, and the locale
([Security Standards § Multi-Tenant + Organization Isolation Review Checklist](../../../docs/standards/11-security.md#multi-tenant--organization-isolation-review-checklist)).
**Accepted P02d-5 G37 — 2026-10-08.** Follow
[ADR-0053](../../../docs/decisions/0053-trusted-public-server-rendering.md): dynamic
public rendering and no-store API transport; no positive `revalidate`, ISR,
`generateStaticParams`, `unstable_cache` or shared bootstrap/data/route cache.
Request-local reuse is isolated to one incoming request. Server Component HMR caching
is disabled. A new server/document request rechecks eligibility; client history is
not a revocation guarantee. Use the configured server caller; never derive tenancy
from a page header or add hop options to the injected SDK. P6 owns page consumers.

### Step 9: Loading + error boundaries

Expand All @@ -213,8 +239,8 @@ Every route ships its own:
recorded in the PR description. The phase that ships a route names its test set in
its decision register.
- Lighthouse budget check on representative public routes — CI's `lighthouse budget`
job is a deferred placeholder, so judge by reading. Whether Phase 02d activates it
is G44 in its
job remains deferred until P02d-7/G44/G45 after P6 pages; judge by reading until
that harness is implemented. Its remaining details are in the
[decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
the pass that closes it rewrites this bullet.

Expand All @@ -234,17 +260,13 @@ Every route ships its own:

## Common pitfalls

- **Mounting under the wrong route group.** `(public)` SSR + ISR is wrong for a
Studio screen — caching across users is a leak. How a `(public)` route renders, and
which Next.js caches it may use, is G37 in
[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register),
held open in [Step 8](#step-8-public-site-ssr-caching); the pass that closes it
edits this pitfall.
- **Hand-rolled `fetch`.** The ESLint rule rejects it; use the SDK.
- **Reading `host` inside a page.** The middleware is the only legal resolver.
Whether the middleware resolves anything, and what it carries, is G25 and G36 in
[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
the pass that closes them edits this pitfall.
- **Mounting under the wrong route group.** Public institution routes are dynamic
and no-store under ADR-0053; Studio/Portal authentication remains Phase 02b.
- **Hand-rolled `fetch`.** Only the configured server adapter has an API transport
exemption. Page consumers call its SDK; no second header setter is permitted.
- **Reading `host` inside a page.** The API resolves authority. The native ingress
authenticates connection provenance; middleware only bootstraps and selects the
enabled route locale. Plain internal headers are not authority.
- **Client Component by default.** Default to Server. Don't sprinkle
`"use client"` to avoid thinking about boundaries; that's how INP regresses.
- **Trusting frontend permission check.** Hidden buttons are not security; the
Expand Down
23 changes: 11 additions & 12 deletions .claude/skills/code-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Each finding → severity (Blocker / Major / Minor) + concrete fix.
| **Bundle size (FE)** | Large dependency imported into a public-route Client Component. Lighthouse JS budget violated. |
| **Re-render storm (FE)** | Context value computed inline in the provider — every consumer re-renders on every render. |
| **Suspense boundary missing** | Server Component awaits slow data on the critical path; missing `<Suspense>` for streaming. |
| **Per-tenant SSR cardinality** | Cache key includes tenant + org + locale + slug; reviewer can confirm memory budget. Whether a `(public)` route may hold rendered output or fetched data in a Next.js cache at all is G37 in [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); until it closes, [add-frontend-route § Step 8](../add-frontend-route/SKILL.md#step-8-public-site-ssr-caching) bars `revalidate`, `generateStaticParams` and `unstable_cache` there, and the pass that closes it edits this row. |
| **Public SSR freshness** | ADR-0053 / Accepted P02d-5 G37 requires dynamic/no-store institution rendering: no shared data/route/ISR/bootstrap cache, positive revalidation, static params or Next unstable cache. Request-local reuse is allowed. Verify source fences plus production same-path/different-host and next-request eligibility controls; P6 supplies page consumers. |

### Step 6 — Refactor / Clean Code lens

Expand Down Expand Up @@ -161,16 +161,15 @@ This is the lens that generic reviewers miss. Walk:
There is no `IModule` type in `backend/src`; a diff that names one is writing
against a contract nobody has built.
- `docs/modules/<m>/audit.md` / `permissions.md` updated.
- For frontend changes: route group is correct, SDK is the only API path,
middleware-resolved `x-tenant-id` / `x-organization-id` honoured, no
hand-rolled `fetch('/v1/...')`. The API treats an `X-Tenant-Id` or `X-Organization-Id`
it receives as an assertion, never a source: it validates the header against its own
resolution and answers a mismatch with `404`
([ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#one-header-names-a-host-and-it-is-still-not-a-source)).
Whether the frontend holds a tenant or organization id at all, and what the middleware
carries inward, is G25 and G36 in
[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
the pass that closes them edits this item.
- For frontend changes: route group is correct; the injected SDK and one configured
server-only adapter are the API path. Under ADR-0052/0053 and Accepted P02d-4/5,
native ingress supplies verified host/peer provenance, middleware applies live
membership-first locale entry, and the API alone resolves tenant/organization.
No tenant/organization IDs or assertion headers enter public representations or
caller options; no raw host/forwarded address selects public scope. API assertions
remain refusal-only under
[ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#one-header-names-a-host-and-it-is-still-not-a-source).
Check private carrier/secret exclusion and the real Client Component import control.
- For customization changes: data-only, no domain term in core code.

If the change is doc-only, the equivalent checks: no `docs/analysis/` refs,
Expand All @@ -186,7 +185,7 @@ change.
| Outbox round-trip test for new integration event | Always |
| Permission denied test for every new permission key | Always |
| Boundary tests for every DSL band threshold | Scoring / completion rules |
| Lighthouse / axe-core for public-route changes | Frontend — Lighthouse **from Phase 02d**, axe-core **from Phase 06**; neither is wired today, so do not raise a Blocker for a missing run. Whether Lighthouse activates in Phase 02d is G44 in [that phase's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), and the pass that closes it edits this row |
| Lighthouse / axe-core for public-route changes | Accepted P02d-5 G44 assigns Lighthouse activation to P02d-7/G44/G45 after P6 pages. It is still a disabled placeholder; no passing audit is claimed. P02d-6/G43 selects the skeleton's accessibility gate; the broader axe/Playwright suite belongs to Phase 06. |

A change without tests is incomplete; flag as Blocker unless the user
explicitly deferred the test.
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/implement-task/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ template. The prompt must:
Deliver the prompt as a code-fence in the response so the user can copy and
dispatch it.

**Lighthouse ownership — Accepted P02d-5, 2026-10-08.** P02d-7/G44/G45
activates the full-stack job after P6 pages; it remains a disabled placeholder.
The entry decision does not provide a Lighthouse command or passing audit.

## Validation (Definition of Done)

The task is **not** done until every box below is checked:
Expand Down
Loading
Loading