Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 6 additions & 4 deletions .claude/skills/add-ef-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,12 @@ migrationBuilder.Sql("""
context filters the row out instead of raising on a pooled connection. Write the
`app.scope` term into the policy even though **nothing sets it**: the flag derives from
the actor's role and roles arrive in
[Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md), which is the earliest
phase that can own the carrier, so the cross-organization read hatch is unreachable at
runtime until then — the correct default, and the reason the two `AS RESTRICTIVE`
guards need a test that sets the variable by hand.
[Phase 03](../../../docs/roadmap/phase-03-identity-admin.md), which is the earliest
phase that can own the carrier
([Security Standards § Tenant Context](../../../docs/standards/11-security.md#tenant-context)),
so the cross-organization read hatch is unreachable at runtime until then — the correct
default, and the reason the two `AS RESTRICTIVE` guards need a test that sets the
variable by hand.

**Roles.** Migrations run as `learnstack_migration` (the table owner);
the application connects as `learnstack_app` (`NOBYPASSRLS`, not the owner). Grant the
Expand Down
97 changes: 68 additions & 29 deletions .claude/skills/add-frontend-route/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ contract per

- Operator portal pages — they live in `operator-portal`, a separate repo.
- Calling the API directly from a Client Component without the SDK — forbidden by
ESLint (`no-restricted-imports`).
ESLint (`no-restricted-globals` on `fetch`,
`frontend/packages/config/eslint/index.cjs`).
- Routes that bypass tenant resolution — every authenticated route requires a
resolved tenant.

Expand All @@ -52,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)`. |
| `(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). |
| `(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 Down Expand Up @@ -84,6 +85,14 @@ export default async function UsersPage({ searchParams }: { searchParams: { q?:
}
```

> **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
> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
> the passes that close them edit this step.

Rules:

- Default to Server Component. `"use client"` only when the screen needs hooks,
Expand All @@ -95,16 +104,15 @@ Rules:

### Step 4: Tenant + organization context (automatic)

The Next.js middleware (`src/middleware.ts`) resolves the host via
`IHostToTenantResolver` and sets:

- `x-tenant-id` header
- `x-organization-id` header (when the host maps to a specific organization)
- `x-locale` header

The SDK reads these from the request context automatically; you don't pass them.
Don't read `host` directly inside a page; the resolution is the middleware's
contract.
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register).
Don't read `host` directly inside a page.

### Step 5: Authentication + permission gating

Expand All @@ -114,7 +122,7 @@ For `(studio)` and `(portal)` routes:
- Permission check happens at the page level via the `auth()` helper:

```tsx
import { auth } from "@learnstack/auth/server";
import { auth } from "@learnstack/auth/server"; // illustrative: no such package exists yet; Phase 02b's session work owns the real helper
import { redirect } from "next/navigation";

export default async function UsersPage() {
Expand Down Expand Up @@ -162,16 +170,25 @@ export default function CoursesPage() {
Translation keys live under `frontend/apps/web/src/i18n/<locale>/courses.json`.
See [add-i18n-key](../add-i18n-key/SKILL.md).

### Step 8: Public-site SSR caching

For `(public)` routes that render CMS content:
> **Open in Phase 02d.** No i18n library is installed and no catalogue exists. Whether
> ADR-0027 picks the library in Phase 02d, and where the one UI string catalogue
> lives — the corpus names three paths — are G39 in
> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
> the pass that closes it edits this step and add-i18n-key.

```tsx
export const revalidate = 60; // ISR-like; tenant publishes invalidate via webhook
```
### Step 8: Public-site SSR caching

Cache key includes tenant + org + locale + slug automatically because the SDK
threads them through.
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)).

### Step 9: Loading + error boundaries

Expand All @@ -184,28 +201,50 @@ Every route ships its own:

### Step 10: Tests

- Component test (`frontend/apps/web/src/app/(studio)/dashboard/users/page.test.tsx`) with
`axe-core` for accessibility.
- Lighthouse budget check on representative public routes (CI).
- Component tests (`frontend/apps/web/src/app/(studio)/dashboard/users/page.test.tsx`)
with Testing Library, per
[Testing Standards § Frontend Test Types](../../../docs/standards/06-testing.md#frontend-test-types).
Automated `axe-core` runs through Playwright, owned by
[Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md) per
[Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md#end-to-end-tests);
the manual keyboard and contrast checks
[Accessibility Standards § Tooling](../../../docs/standards/16-accessibility.md#tooling)
and [§ Testing](../../../docs/standards/16-accessibility.md#testing) require are
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
[decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
the pass that closes it rewrites this bullet.

## Validation

- `pnpm build` / `next build` succeeds.
- `pnpm lint` is green; specifically the `no-restricted-imports` rule that bans
raw `fetch('/v1/...')` from any component.
- `pnpm lint` is green; specifically the `no-restricted-globals` rule on `fetch`,
which bans a direct `fetch` call outside the SDK.
- The route renders under the resolved tenant/org/locale and rejects mismatched
authn.
- A `(studio)` route returns 403 when the actor lacks the required permission;
the API was already authoritative — confirm.
- Lighthouse budgets (LCP < 2.5s, INP < 200ms, CLS < 0.05) green on
representative routes.
- The public-route budgets in
[Performance Standards](../../../docs/standards/15-performance.md) hold on
representative routes — judged by reading until CI's `lighthouse budget` job is
active.

## Common pitfalls

- **Mounting under the wrong route group.** `(public)` SSR + ISR is wrong for a
Studio screen — caching across users is a leak.
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.
- **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
13 changes: 12 additions & 1 deletion .claude/skills/add-i18n-key/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ Manage user-facing translations in `apps/web` consistently per
[08-localization.md](../../../docs/standards/08-localization.md) +
[ADR-0008 Localization Schema](../../../docs/decisions/0008-localization-schema.md).

> **Open in Phase 02d.** None of the machinery below exists today: no
> `frontend/apps/web/src/i18n/` tree, no i18n library, no `pnpm lint:i18n` task, no
> `no-literal-strings` rule, no `_deprecated.json`, and no screenshot or `axe-core`
> test. Where the one UI string catalogue lives — this skill, Localization Standards
> and Localization architecture name three different paths — and whether ADR-0027
> picks the library in Phase 02d are G39 in
> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
> the pass that closes it edits this skill.

## When to use

- A new screen / component renders user-facing English (or any language) text.
Expand Down Expand Up @@ -142,7 +151,9 @@ return <p>{t("count", { count: learners.length })}</p>;
### Step 4: Variable interpolation

ICU placeholders: `{name}`, `{count}`, `{date, date, short}`. The frontend i18n
library (next-intl / react-intl — see ADR-pending) handles ICU natively.
library (next-intl / react-intl — ADR-0027, reserved in
[the decisions index](../../../docs/decisions/README.md#open-adr-drafts)) handles ICU
natively.

### Step 5: Don't branch on locale

Expand Down
12 changes: 12 additions & 0 deletions .claude/skills/add-mediatr-handler/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,18 @@ See [add-audit-coverage](../add-audit-coverage/SKILL.md).

### Step 5: Permission policy

> **Not wired yet.** No `IPermissionRegistry`, `registry.Tenant(...)` or `Roles`
> catalogue exists in `backend/src`, `LearnStack.Api` registers no authorization
> middleware, and `AuthorizationBehavior` passes every request through; the catalogue
> lands with the Identity module in
> [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md). Until then this step's
> registration, Step 7's `[Authorize(Policy = …)]` and the `403` check under
> § Validation have nothing to bind to. A module's `permissions.md` is a forward
> declaration, a handler with no HTTP route stands on reachability, as
> [the Tenancy permission matrix](../../../docs/modules/tenancy/permissions.md)
> records, and an anonymous endpoint carries `[AllowAnonymous]` with its reason
> ([Permissions Standards § HTTP endpoints](../../../docs/standards/19-permissions.md#http-endpoints)).

The endpoint that invokes this handler is guarded by
`[Authorize(Policy = "enrollment.enrollment.write")]`. Register the permission in
the module:
Expand Down
34 changes: 31 additions & 3 deletions .claude/skills/add-page-block/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ and [17-page-builder.md](../../../docs/architecture/17-page-builder.md).
| **Composite renderer** | C# composite registry + React renderer (`default-card`, `content-list`, `card-grid`). | LearnStack engineering. | Tenants compose this in `TenantPageBlock` rows. |
| **Tenant block** | `tenant_page_blocks` row (data only). | Tenant admin via Studio editor. | Tenant-specific shape (`vocabulary-list` for English, `asana-card` for yoga). |

> **Open in Phase 02d.** The code paths in this step and the next two describe intended
> shape. At HEAD the frontend registry is `frontend/apps/web/src/lib/customization/` —
> `PRIMITIVE_KEYS` and `COMPOSITE_KEYS` as closed key lists with `resolveRendererKey`,
> no component and no `registerPrimitive` / `registerComposite` — and the backend's
> closed sets are `PrimitiveRendererKey` and `CompositeRendererKey` in
> `LearnStack.Modules.Customization.Domain`; no `PageBlockRegistry` exists. Where
> primitive and composite components live — `components/blocks/` here, `packages/blocks`
> in Frontend Architecture Standards — is G41 in
> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register),
> and the pass that closes it, which ships the lesson renderer's first components, edits
> this skill with its answer.

### Step 2: Primitive block (path A)

#### A.1 — Author the JSON Schema
Expand Down Expand Up @@ -196,8 +208,20 @@ the page.

- JSON Schema validation test for the new primitive / tenant schema.
- Renderer snapshot test.
- Accessibility test (`axe-core` violations fail).
- Lighthouse budget check for representative pages embedding the block.
- Accessibility test (`axe-core` violations fail). No test runs `axe-core` yet:
[Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md#end-to-end-tests)
puts automated axe through Playwright in
[Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md). Which
accessibility checks fail a build on Phase 02d's pages, jsdom axe among the options,
is G43 in
[that phase's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register),
whose pass edits this bullet with its answer.
- Budgets in [Performance Standards](../../../docs/standards/15-performance.md) for
representative pages embedding the block — judged by reading while CI's
`lighthouse budget` job remains a placeholder. Whether it activates in Phase 02d
is G44 in
[that phase's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
the pass that closes it edits this bullet.

## Validation

Expand All @@ -208,7 +232,11 @@ the page.
editor.
- For a tenant block: a page can be authored that uses the new block; the
renderer dispatches correctly.
- Accessibility (`axe-core`) and contrast checks pass.
- Accessibility: a keyboard walkthrough of the block and a contrast check of its text
and UI colours pass, per
[Accessibility Standards § Testing](../../../docs/standards/16-accessibility.md#testing),
recorded in the PR description. Automated `axe-core` is not a gate yet — Step 7 names
its owner and G43, the Phase 02d gate open on it.

## Common pitfalls

Expand Down
6 changes: 6 additions & 0 deletions .claude/skills/add-permission/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ Add a permission to the module's
naming convention, register it on the right endpoint, and update the per-module
permission matrix.

> **Not wired yet.** `IPermissionRegistry`, `IModule.RegisterPermissions` and the
> `Roles` catalogue land with the Identity module in
> [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md); none exists in
> `backend/src`. Until then only Step 1 and Step 5 apply, as a forward declaration on
> [the Tenancy precedent](../../../docs/modules/tenancy/permissions.md).

## When to use

- A new resource needs an `[Authorize]` policy.
Expand Down
12 changes: 12 additions & 0 deletions .claude/skills/add-tenant-content-type/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ Rules of thumb:
- The `x-taxonomy` you name has to exist **before** the content type that references
it — any revision of it, published or not. Register the taxonomy first.

> **Open in Phase 02d.** Nothing above gives a field a display order or a label in each
> of the tenant's locales: `json_schema` is `jsonb`, which keeps no key order, and the
> profile collects only the three extensions listed. How a content type carries order,
> labels and a composite's field roles, which composite draws a lesson, which primitives
> the phase implements and how a type with no primitive row maps are G18. Which URL
> schemes a field may hold is G19: validation constrains structure, not schemes, and
> `format: "uri"` admits `javascript:`.
> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register)
> holds both, and the pass that closes each edits this note with its answer. G18's row
> records why it closes before the seed publishes a content type: a later answer needs
> successor revisions.

### Step 2: Register it through the command

Registration goes through the module's command, never through a `DbContext` and
Expand Down
Loading
Loading