Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
4e23a0b
docs(tooling): match the hook description to the hook's real scope
cemililik Aug 18, 2026
038fcad
docs(decisions): decide what may resolve a tenant, and what may only …
cemililik Aug 18, 2026
7d832a7
feat(api): route every endpoint under /api/v{N} and publish the contract
cemililik Aug 18, 2026
9af781a
fix(api): close the escapes the versioned-route rule could not see
cemililik Aug 18, 2026
51eec7c
fix(api): refuse a controller that cannot carry the contract
cemililik Aug 18, 2026
26ac260
feat(api): give every error one shape, and name the parameter that fa…
cemililik Aug 18, 2026
5e28130
fix(api): make the single-shape promise true in the paths people write
cemililik Aug 19, 2026
7b82cb5
fix(api): stop a body from contradicting its own status
cemililik Aug 19, 2026
f2171c5
feat(api): decide the sort grammar, and refuse a field the endpoint d…
cemililik Aug 19, 2026
f23e7bb
fix(api): build under the gate CI actually applies, and stop sorting …
cemililik Aug 19, 2026
f201116
fix(api): delete an error nobody could receive, and the reason I wrot…
cemililik Aug 19, 2026
5f39b08
feat(api): bind the tenant headers as assertions, and stop defaulting…
cemililik Aug 19, 2026
416ce14
fix(api): stop handing anonymous clients the error tracker
cemililik Aug 19, 2026
9cd8ec6
fix(api): validate the host this function returns, and cap the anonym…
cemililik Aug 19, 2026
8229044
feat(api): make a retry safe, and a conditional write conditional
cemililik Aug 19, 2026
a27442e
fix(api): stop a sweep from deleting a claim someone just won
cemililik Aug 19, 2026
fab9bb3
fix(api): stop capacity from cancelling the guarantee it protects
cemililik Aug 19, 2026
4487409
docs(decisions): decide what an idempotency key identifies, and recon…
cemililik Aug 19, 2026
4e7a3fb
test(api): pin the anonymous-principal decision instead of leaving it…
cemililik Aug 19, 2026
d69a276
docs(decisions): correct ADR-0036's normalization order, measured
cemililik Aug 19, 2026
e3ed68d
feat(api): make the published limits true, and stop a client filling …
cemililik Aug 20, 2026
32e7e6e
feat(sdk): wire the generation pipeline the scaffold only claimed to …
cemililik Aug 20, 2026
10f565f
feat(api): publish a strongly-typed id as the primitive it actually s…
cemililik Aug 20, 2026
80a3380
test(api): cover the one line that lets a header displace the host
cemililik Aug 20, 2026
87528c5
docs: make the corpus describe the binary Packet 4 actually shipped
cemililik Aug 20, 2026
4198a90
docs(roadmap): record what Packet 4 shipped, including what it got wrong
cemililik Aug 20, 2026
34b7cfb
docs: record Packet 4 in the file every agent reads first
cemililik Aug 20, 2026
1f78ea2
docs(decisions): say ADR-0024's codegen instructions in TypeScript
cemililik Aug 20, 2026
a133a01
fix(api): close a rate-limit bypass, and stop a nullable id erasing i…
cemililik Aug 20, 2026
8a82042
fix(api): publish the id in `GET /{id}`, and stop three guards from b…
cemililik Aug 20, 2026
ddd2101
fix(tooling): stop an empty commit from disabling the secret scan
cemililik Aug 20, 2026
8172770
docs(standards): count the integration rules, and cover the lockfile …
cemililik Aug 20, 2026
afd6596
fix(api): report which header failed, and refuse a half-configured ho…
cemililik Aug 20, 2026
0a534b5
fix(api): refuse a wildcard mixed with a tag, and bound every echoed …
cemililik Aug 20, 2026
dc114d7
docs(architecture): say what the SDK carries, and stop naming a route…
cemililik Aug 20, 2026
2bf38a8
docs(standards): finish the sentence the 405 row trails off in
cemililik Aug 20, 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
9 changes: 9 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ restage() { git add -- "$@"; }
# once and degrade to a message rather than blocking; CI is the real gate.
leakwatch_takes_files() {
local probe

# An empty staged set — `git commit --amend` with nothing new, for one —
# expands to an unbound variable under `set -u`, and the resulting error
# made the caller take the "this build only scans directories" branch and
# skip the secret scan over a capability problem that does not exist.
# Nothing staged means nothing to scan, so the answer is moot: say yes and
# let the (equally empty) scan loop below no-op.
(( ${#all_staged[@]} == 0 )) && return 0

for probe in "${all_staged[@]}"; do
[[ -f "$probe" ]] || continue
if leakwatch scan fs "$probe" --config .leakwatch.yaml \
Expand Down
7 changes: 4 additions & 3 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,10 @@ The pre-commit hook (activated by `make install`) runs, on staged files only:
`frontend/`**; `next lint --fix` on JS / TS under `frontend/apps/web` — the one
workspace with a `lint` script, so this is exactly what `pnpm -r lint` covers in
CI; and, when the binary is on PATH, `leakwatch scan fs <staged-file>`. So the
lint / typecheck / test / secret-scan pass above is mostly a sanity check. CI
re-runs every check as a hard gate, so a bypassed local commit will fail the PR
build.
three commands above are mostly a sanity check. There is deliberately no
`make secret-scan` to pair with them — the hook and CI are the scanner's only
runners, and CI re-runs every check as a hard gate, so a bypassed local commit
will fail the PR build.

Prettier stops at the `frontend/` boundary, and the root `.prettierignore`
is what enforces it in your editor — `.vscode/settings.json` maps `[markdown]`
Expand Down
21 changes: 16 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
# the GitHub Settings → Branches page matches the corpus.
#
# What lights up in Phase 01:
# - backend : `dotnet build` + unit + architecture + contract tests
# - backend : `dotnet build` + unit + architecture + contract tests, and
# from Phase 02a Packet 4 the WebApplicationFactory HTTP tests
# in LearnStack.Tests.Integration, which need no Docker
# - frontend : pnpm install + typecheck + lint + build + Vitest
# - meta : `make lint`-style format verification
#
Expand All @@ -16,8 +18,11 @@
# ([if-cond] constant expression). Activation is never *only* the variable —
# see .github/CONTRIBUTING.md § Branch protection for the three edits:
# - backend-integration : Testcontainers needs a real Docker socket inside
# the runner — works on `ubuntu-latest` natively. Activates when the
# first integration test lands (Phase 02a) so we have something to run.
# the runner — works on `ubuntu-latest` natively. Activates in Phase 02a
# Packet 7, with the first cross-tenant isolation test. The HTTP tests
# already in LearnStack.Tests.Integration do NOT wait for it: they use
# WebApplicationFactory, need no Docker, and run in the `backend` job
# today. Packet 7 splits the Docker-bound tests out into this job.
# - openapi-diff : oasdiff against the prior `main` spec. Activates
# in Phase 02d, which ships the first real `/api/v1/*` read endpoints and
# retires `/healthz` as the only documented surface.
Expand Down Expand Up @@ -91,12 +96,18 @@ jobs:
CI: "true"
run: dotnet build LearnStack.slnx --no-restore --configuration Release

- name: Test (unit + architecture + contract; integration excluded)
# No --filter. LearnStack.Tests.Integration holds WebApplicationFactory
# tests that need no Docker, and they carry rules the structural tests
# cannot: no-op'ing VersionedRouteConvention leaves every architecture
# test green and turns most of this assembly red. Excluding it meant a
# broken route convention shipped green. Testcontainers
# tests arrive in Packet 7 and move to the backend-integration job then;
# this job's NAME is a required check and does not change.
- name: Test (unit + architecture + contract + HTTP integration)
working-directory: backend
run: |
dotnet test LearnStack.slnx \
--no-restore --no-build --configuration Release \
--filter "FullyQualifiedName!~LearnStack.Tests.Integration" \
--logger "trx;LogFileName=test-results.trx" \
--results-directory ../artifacts/backend-tests

Expand Down
16 changes: 16 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,19 @@

/*
!/frontend/

# Generated from the backend's OpenAPI document. Reformatting the generator's
# output makes every `pnpm generate` dirty the tree, which is what would stop
# `generate && git diff --exit-code` being a usable freshness gate.
#
# Listed here AND in frontend/.prettierignore on purpose, because prettier reads
# the ignore file relative to its working directory and the three callers do not
# share one: the editor extension runs from the repository root, and the
# pre-commit hook and `make format` both run from `frontend/`.
/frontend/packages/sdk/src/generated/

# Same argument, and the asymmetry is worth closing: a lockfile is generated and
# prettier is not its author. `frontend/.prettierignore` covers the two callers
# that run from `frontend/`; this file covers the editor, which runs from here.
# Measured — without it, an explicit Format Document rewrites 3761 lines of it.
/frontend/pnpm-lock.yaml
21 changes: 20 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ repository holds only LearnStack's side of the boundary, in

**Phase 01 complete.
[Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress —
packets 0–3 and 3b shipped; packets 4–10 were re-scoped on 2026-08-08 after
packets 0–3, 3b and 4 shipped; packets 4–10 were re-scoped on 2026-08-08 after
a four-report audit of the corpus.**

**Phase 01** shipped the .NET 10 solution scaffold under `backend/`
Expand Down Expand Up @@ -69,6 +69,25 @@ overlay boots, and compose ports bind loopback. Its record is
[Delivery Record (Packet 3b)](docs/roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-3b),
kept separate from the frozen packets 0–3 record.

**Packet 4** shipped the API conventions: `/api/v{N}` routing with startup
guards for the escapes a route rule cannot see, one RFC 7807 shape on every
error including the three the framework used to produce without one, cursor
pagination and the sort grammar, the
[ADR-0036](docs/decisions/0036-tenant-resolution-trusted-inputs.md) tenancy
edge (host normalization, the trusted hop, headers as assertions, the anonymous
rate limiter, a required `Deployment:Mode`), idempotency keys and ETag
concurrency under
[ADR-0037](docs/decisions/0037-idempotency-key-contract.md), a request-body
limit that a test can actually assert, the first working SDK generation, and
the OpenAPI mapping for strongly-typed identifiers that ADR-0023 had assigned
to it. Its record —
[Delivery Record (Packet 4)](docs/roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-4)
— lists the six defects the packet introduced and caught in its own review
rounds alongside what it built, because most of them answered a **success** the
client had no way to question — and the two that did not were worse: one handed
back a truncated body under a `200`, the other a `500` per request that an
anonymous caller could trigger at will.

**The 2026-08-08 restructure** re-scoped packets 3b–10 along three lines,
all recorded in the Phase 02a Status block:

Expand Down
27 changes: 20 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,27 @@ build-backend: ## `dotnet build` the solution.
build-frontend: ## `pnpm -r build` the frontend monorepo.
(cd frontend && pnpm -r build)

.PHONY: sdk
sdk: ## Regenerate @learnstack/sdk types from a running API's OpenAPI document.
@# Needs the API up. `make dev` starts the compose stack, NOT the API — run
@# `dotnet run --project backend/src/LearnStack.Api` in another shell first.
@# Override the source with LEARNSTACK_OPENAPI=<url-or-path>.
(cd frontend && pnpm --filter @learnstack/sdk generate)

# ─── Tests ────────────────────────────────────────────────────────────────
.PHONY: test
test: test-backend test-frontend ## Run all test suites (backend + frontend).

.PHONY: test-backend
test-backend: ## `dotnet test` (unit + architecture + contract; integration skipped — see test-integration).
(cd backend && dotnet test LearnStack.slnx \
--filter "FullyQualifiedName!~LearnStack.Tests.Integration" \
--nologo)
test-backend: ## `dotnet test` (unit + architecture + contract + HTTP integration — same set as CI).
(cd backend && dotnet test LearnStack.slnx --nologo)

.PHONY: test-integration
test-integration: ## Testcontainers-backed integration tests (requires Docker).
test-integration: ## Just the LearnStack.Tests.Integration assembly (a subset of test-backend).
@# Today this assembly holds only WebApplicationFactory HTTP tests and needs
@# no Docker, so `make test-backend` already covers it. The target stays as
@# the fast inner loop while working on that assembly, and becomes the
@# Docker-bound entry point in Packet 7 when Testcontainers tests land.
(cd backend && dotnet test tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj --nologo)

.PHONY: test-frontend
Expand All @@ -128,7 +137,11 @@ lint-frontend: ## `pnpm -r lint` (Next/ESLint).
.PHONY: format
format: ## Apply formatters in place (backend dotnet-format + frontend prettier).
(cd backend && dotnet format LearnStack.slnx --no-restore)
(cd frontend && pnpm -r exec prettier --write .)
@# One invocation from `frontend/`, not `pnpm -r exec`: that runs prettier once
@# per package with the PACKAGE as its working directory, where
@# frontend/.prettierignore is not found — measured, it reformatted the
@# generated SDK schema every run.
(cd frontend && pnpm exec prettier --write .)

# ─── Typecheck (frontend) ─────────────────────────────────────────────────
.PHONY: typecheck
Expand All @@ -149,7 +162,7 @@ install: .env hooks ## Restore backend NuGet + frontend pnpm deps + activate git
.PHONY: hooks
hooks: ## Activate the repo's pre-commit hook (.githooks/pre-commit).
@git config core.hooksPath .githooks
@printf "$(CYAN)git hooks → .githooks/ (pre-commit: dotnet format on *.cs, prettier + next lint under frontend/, leakwatch if available)$(RESET)\n"
@printf "$(CYAN)git hooks → .githooks/ (pre-commit: dotnet format *.cs | prettier frontend/ | next lint frontend/apps/web | leakwatch if present)$(RESET)\n"

# ─── Env scaffolding ──────────────────────────────────────────────────────
# `.env` is gitignored; this rule copies `.env.example` on first run so the
Expand Down
11 changes: 11 additions & 0 deletions backend/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@
<!--
Reserved for later Phase 02a packets (EF migrations + design-time).
-->
<!--
Phase 02a Packet 4 — API conventions (ADR-0024, ADR-0036).
Scalar renders the OpenAPI document Microsoft.AspNetCore.OpenApi emits.
Swashbuckle is NOT used: it does not ship a .NET 10 document generator,
and ADR-0024 § Implementation Notes fixes Microsoft.AspNetCore.OpenApi as
the generator. Standards 04 § OpenAPI was corrected to match.
-->
<ItemGroup Label="Phase 02a Packet 4 (in use)">
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.20" />
</ItemGroup>

<ItemGroup Label="Reserved for later Phase 02a packets">
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0" />
Expand Down
48 changes: 48 additions & 0 deletions backend/src/LearnStack.Api/Common/ApiControllerBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;

namespace LearnStack.Api.Common;

/// <summary>
/// Base type for every tenant-facing controller. Carries the two attributes
/// that would otherwise be repeated — and occasionally forgotten — on each
/// one: <see cref="ApiControllerAttribute"/> and the
/// <c>[controller]</c> route token that
/// <see cref="Versioning.VersionedRouteConvention"/> prefixes with
/// <c>api/v{N}</c>.
/// </summary>
/// <remarks>
/// <para>
/// The sanctioned action shape is the one
/// <see href="../../../../docs/decisions/0032-exception-handling-logging-and-observability.md">ADR-0032
/// § Sub-decision 6</see> fixes — explicit at every endpoint, no action filter
/// and no MediatR unwrap behavior:
/// </para>
/// <code>
/// [HttpPost]
/// public async Task&lt;IActionResult&gt; Create(CreateCourseCommand cmd, CancellationToken ct)
/// =&gt; (await _mediator.Send(cmd, ct)).ToActionResult();
/// </code>
/// <para>
/// <b>Derive from this type.</b> An earlier version of this comment said the
/// base class was "a convenience, not the enforcement point" and that a
/// controller deriving from <see cref="ControllerBase"/> directly was still
/// routed correctly. Both halves were wrong, and the second was actively
/// harmful: such a controller has no controller-level route template, so MVC
/// routes every action on it at the bare <c>api/v{N}</c> with the resource
/// segment dropped, and two of them collide as a 500
/// <c>AmbiguousMatchException</c> at request time. It also lacks
/// <see cref="ApiControllerAttribute"/>, so a malformed body bypasses the
/// automatic 400 and surfaces as a 500 rather than the single Problem Details
/// shape Standards 09 § API Surface fixes.
/// </para>
/// <para>
/// <see cref="Versioning.VersionedRouteConvention"/> now refuses to start
/// against a controller missing either, so the requirement is a startup
/// failure rather than a convention nobody is obliged to follow. Declaring
/// <c>[ApiController]</c> and a <c>[Route]</c> by hand satisfies it too; this
/// type is simply the one place that already does.
/// </para>
/// </remarks>
[ApiController]
[Route("[controller]")]
public abstract class ApiControllerBase : ControllerBase;
121 changes: 121 additions & 0 deletions backend/src/LearnStack.Api/Common/ClientErrorProblemDetails.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;

namespace LearnStack.Api.Common;

/// <summary>
/// Gives a LearnStack Problem Details body to the client errors nothing else
/// covers — the ones the framework produces with no body at all.
/// </summary>
/// <remarks>
/// <para>
/// Standards 04 § Error Responses admits exactly one error shape and
/// Standards 09 § API Surface fixes its fields. Three statuses arrived outside
/// it, from two different places:
/// </para>
/// <list type="bullet">
/// <item>
/// <b>404 and 405</b> come from <b>routing</b>, before MVC. No action runs, so
/// no MVC hook fires — <see cref="MapLearnStackClientErrors"/>, built on
/// <c>UseStatusCodePages</c>, is what sees them.
/// </item>
/// <item>
/// <b>415</b> and any bodyless <see cref="StatusCodeResult"/> come from
/// <b>MVC</b>. <see cref="ApiControllerAttribute"/> already converts those into
/// ASP.NET's own <see cref="ProblemDetails"/> — the right idea, the wrong
/// shape, with no <c>code</c>, no <c>messageKey</c> and no
/// <c>correlationId</c>. <see cref="LearnStackClientErrorFactory"/> replaces
/// that conversion rather than layering over it.
/// </item>
/// </list>
/// <para>
/// Both funnel through <see cref="ProblemDetailsFactory.ForStatus"/>, so a
/// 404 from routing and a 404 from a controller are indistinguishable on the
/// wire — which is the point of having one shape.
/// </para>
/// </remarks>
public static class ClientErrorProblemDetails
{
/// <summary>
/// Writes a Problem Details body for any 4xx/5xx response that reached the
/// client with none.
/// </summary>
/// <remarks>
/// Registered right after <c>UseExceptionHandler</c>. "Before routing" is
/// not something a caller can arrange in minimal hosting — the implicit
/// <c>UseRouting</c> is inserted ahead of user middleware — so what
/// actually matters is the order relative to the exception handler, and
/// that a routing 404 unwinds back through this middleware on the way out.
/// </remarks>
public static WebApplication MapLearnStackClientErrors(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);

app.UseStatusCodePages(async context =>
{
var http = context.HttpContext;

// UseStatusCodePages only invokes this for an empty body, but the
// guard is cheap and the alternative — a second body appended to a
// partial response — is a malformed payload rather than an error.
if (http.Response.HasStarted)
{
return;
}

// An exception that reached the L1 handler has already produced a
// body and a status; re-writing it here would replace a specific
// error with a generic one derived from the status alone.
if (http.Features.Get<IExceptionHandlerFeature>() is not null)
{
return;
}

var problem = ProblemDetailsFactory.ForStatus(http.Response.StatusCode, http);

// The content type goes through WriteAsJsonAsync, not through a
// prior assignment to Response.ContentType: the method sets its own
// ("application/json") and silently overwrites anything already
// there, which is how these two answered with the right body under
// the wrong media type.
await http.Response.WriteAsJsonAsync(
problem,
options: null,
contentType: ProblemDetailsMediaType.Value,
cancellationToken: http.RequestAborted);
});

return app;
}
}

/// <summary>
/// Replaces MVC's client-error conversion so a bodyless
/// <see cref="StatusCodeResult"/> from an <see cref="ApiControllerAttribute"/>
/// action carries the LearnStack shape rather than ASP.NET's.
/// </summary>
internal sealed class LearnStackClientErrorFactory : IClientErrorFactory
{
public IActionResult GetClientError(ActionContext actionContext, IClientErrorActionResult clientError)
{
ArgumentNullException.ThrowIfNull(actionContext);
ArgumentNullException.ThrowIfNull(clientError);

var status = clientError.StatusCode ?? StatusCodes.Status500InternalServerError;

// actionContext.HttpContext, not an injected IHttpContextAccessor: MVC
// hands the context in, and registering the accessor app-wide to reach
// something already in the parameter list costs an AsyncLocal write on
// every request for nothing.
var problem = ProblemDetailsFactory.ForStatus(status, actionContext.HttpContext);

return new ObjectResult(problem)
{
StatusCode = status,
ContentTypes = { ProblemDetailsMediaType.Value },
};
}
}
Loading
Loading