diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 9b26dcf..4dd6bbe 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -2,7 +2,7 @@ Reusable, task-focused instruction packs ("skills") for AI coding agents working on **LearnStack Hub**. Each subdirectory is one skill; its `SKILL.md` carries YAML frontmatter (`name`, `description`) so the agent runtime can pick or skip it without reading the whole body. -Skills are **project-local** to this repo (`.claude/skills/`). An agent running from the `learnstack-hub` root loads them automatically. They cite LearnStack core's standards / ADRs by sibling path (`../LearnStack/docs/...`) for cross-cutting authority and carry only the **Hub-specific delta** on top — they never duplicate the LearnStack standards corpus. +Skills are **project-local** to this repo (`.claude/skills/`). An agent running from the `LearnStack-Hub` root loads them automatically. They cite LearnStack core's standards / ADRs by absolute GitHub URL (`https://github.com/HodeTech/LearnStack/blob/main/docs/...`) for cross-cutting authority and carry only the **Hub-specific delta** on top — they never duplicate the LearnStack standards corpus. Sibling-relative `../LearnStack/...` paths remain correct for shell commands and filesystem locations, never for a Markdown link. ## The Hub deltas every skill assumes diff --git a/.claude/skills/add-hub-module/SKILL.md b/.claude/skills/add-hub-module/SKILL.md index 17e3bf5..9f60d62 100644 --- a/.claude/skills/add-hub-module/SKILL.md +++ b/.claude/skills/add-hub-module/SKILL.md @@ -15,7 +15,7 @@ description: > ## Purpose -Stand up a new Hub modular-monolith module that complies with the dependency-direction rules from day one: four packages, the right references, a registration extension, a DbContext in the `hub` schema (no RLS), and architecture-test coverage. Mirrors LearnStack's [add-backend-module](https://github.com/HodeTech/LearnStack/blob/main/.claude/skills/add-backend-module/SKILL.md) minus the tenant-isolation layer. +Stand up a new Hub modular-monolith module that complies with the dependency-direction rules from day one: four packages, the right references, a registration extension, a DbContext in the `hub` schema (no RLS), and architecture-test coverage. The module pattern is defined in this repository by [module-topology.md](../../../docs/architecture/module-topology.md) and the four module directories already on `main` under `backend/src/Modules/`; copy the shape from those, not from a sibling-repo skill. It is LearnStack core's four-package layout **minus the entire tenant-isolation layer** — no RLS, no `[TenantOwned]`, no query filter, no `ITenantContext`. ## When to use diff --git a/.claude/skills/commit-and-pr/SKILL.md b/.claude/skills/commit-and-pr/SKILL.md index 2c27567..3349b68 100644 --- a/.claude/skills/commit-and-pr/SKILL.md +++ b/.claude/skills/commit-and-pr/SKILL.md @@ -40,7 +40,7 @@ Conventional Commits `type(scope): subject`: - **Hub scopes:** `hub` (cross-cutting), `hub-domain` (aggregates / modules), `hub-infra` (compose / APISIX / Dapr / Vault / EF), `hub-portal` (operator portal), `hub-docs` (documentation). - **subject:** imperative, ≤ 72 chars. - **body:** one short paragraph saying _why_ (the diff is _what_). For a packet, name the packet (`P02c-1`). -- **trailer:** `Co-Authored-By: Claude Opus 5 (1M context) ` (or `Codex …` for Codex sessions). HEREDOC every multi-line message. +- **trailer:** one `Co-Authored-By:` line per agent that **materially contributed**, each naming that agent's own identity — never a hard-coded string for a session some other agent ran. [CLAUDE.md § Commit conventions](../../../CLAUDE.md#commit-conventions) carries the per-runtime strings; LearnStack's [Git Workflow Standards § Trailers](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/14-git-workflow.md#trailers) is the authority if the two disagree. HEREDOC every multi-line message. ```bash git commit -m "$(cat <<'EOF' @@ -48,7 +48,7 @@ feat(hub-domain): P02c-1 — LearnStackTenant aggregate + DbContext + migration -Co-Authored-By: Claude Opus 5 (1M context) +Co-Authored-By: EOF )" ``` diff --git a/.env.example b/.env.example index 8e34b52..ba35722 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,11 @@ HUB_DAPR_SIDECAR_GRPC_PORT=50002 # ─── Hub API host (LearnStack.Hub.Api, runs on workstation via `dotnet run`) ─ ASPNETCORE_ENVIRONMENT=Development +# The composition root reads DeploymentMode exactly once and fails closed: +# it may be omitted only when ASPNETCORE_ENVIRONMENT=Development, and an +# unrecognised value is rejected rather than coerced. Values (ADR-0020): +# Development | SaaS | Dedicated | SelfHostedOnline | SelfHostedAirGapped. +Hub__DeploymentMode=Development # Different from LearnStack.Api's 5080 so both can run side-by-side. # # Bind to 0.0.0.0 (not localhost / 127.0.0.1) so containers can reach the diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9cf6edd..8ce4279 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,7 +2,8 @@ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9e0c89..bb51a84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,11 +206,12 @@ jobs: - name: Markdown link audit (changed docs) # Mirror of LearnStack core's link audit. Skips externals + anchors. - # Hub's docs cross-link to LearnStack core via `../LearnStack/...` - # relative paths — those links resolve only on the developer's - # workstation (sibling layout). CI can't validate them because the - # LearnStack core repo isn't checked out alongside; the audit skips - # `../LearnStack/` paths explicitly. + # Hub's docs cross-link to LearnStack core by ABSOLUTE GitHub URL, + # which the external-URL skip already covers. A sibling-relative + # `../LearnStack/...` documentation link is **rejected**, not skipped: + # it 404s on github.com and depends on a local checkout. Sibling paths + # are reserved for shell commands and filesystem locations, which are + # not Markdown links and never reach this audit. env: EVENT_NAME: ${{ github.event_name }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} diff --git a/AGENTS.md b/AGENTS.md index e8b26c0..9040738 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,29 +6,8 @@ ## Differences from CLAUDE.md -There are no rule differences. The only thing that varies between agent runtimes is the **`Co-Authored-By` commit trailer**, which names the assistant that contributed: - -- Claude Code sessions: - `Co-Authored-By: Claude Opus 5 (1M context) ` -- OpenAI Codex sessions: - `Co-Authored-By: Codex Opus 4.7 (1M context) ` - -If multiple agents contributed materially to the same commit, include one trailer per agent. [LearnStack's Git Workflow Standards § Trailers](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/14-git-workflow.md#trailers) is the authority for both strings; if this file and that section disagree, that section wins. - -## Skills - -**This repository maintains its own skill catalogue** at [`.claude/skills/`](.claude/skills/README.md) — 18 skills, git-tracked through an un-ignore rule in `.gitignore`. Load them from here, not from the sibling LearnStack repo. - -The Hub catalogue is Hub-tailored: the `add-hub-*` workflows encode Hub's deltas from LearnStack core — no Row Level Security, `OperatorId` rather than `UserId`, a six-step MediatR pipeline rather than eight, the `hub` schema, the `learnstack_hub` database. Skills cite LearnStack's standards and ADRs by absolute URL (`https://github.com/HodeTech/LearnStack/blob/main/docs/...`) for cross-cutting authority and carry only the Hub-specific workflow on top; they do not duplicate the standards. - -Entry-point selection is the same as in LearnStack core: [implement-task](.claude/skills/implement-task/SKILL.md) for substantive work, [start-task](.claude/skills/start-task/SKILL.md) for scoping only, [standards-check](.claude/skills/standards-check/SKILL.md) followed by [code-review](.claude/skills/code-review/SKILL.md) for review. Pick exactly one entry point; it dispatches the rest. - -## Sibling paths - -The two repositories sit side by side on disk as `LearnStack/` and `LearnStack-Hub/`, with those exact capitalisations. That layout governs **shell commands and filesystem paths** — `cd ../LearnStack`. A lower-cased spelling works on macOS's case-insensitive filesystem and fails on Linux CI. - -Documentation **links** into LearnStack are absolute URLs: `https://github.com/HodeTech/LearnStack/blob/main/docs/...`. A relative link does not cross a repository boundary on github.com, and the CI link audit no longer exempts cross-repo links — so a relative one now fails the build rather than reaching a reader. +There are no rule differences, and the one thing that varies between agent runtimes — the **`Co-Authored-By` commit trailer**, which names the agent that actually contributed — is defined in [CLAUDE.md § Commit conventions](CLAUDE.md#commit-conventions) along with the per-runtime strings and the one-trailer-per-contributing-agent rule. It is not restated here, because two copies of a string that must match a third ([LearnStack's Git Workflow Standards § Trailers](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/14-git-workflow.md#trailers), which is the authority) is how the copies drift. ## Maintaining this file -Do **not** copy CLAUDE.md content into AGENTS.md. If a guidance rule needs to change, change CLAUDE.md; the rule applies everywhere by virtue of the redirect above. The two sections above are the only content that legitimately lives here, because they describe this file's own runtime rather than the project's rules. +Do **not** copy CLAUDE.md content into AGENTS.md. If a guidance rule needs to change, change CLAUDE.md; the rule applies everywhere by virtue of the redirect above. Nothing in this file is a rule of its own — the sections above describe only why this filename exists and where to read instead. diff --git a/CLAUDE.md b/CLAUDE.md index 49cf299..58079ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ For any task in this repo, read in this order: Then pick an entry-point skill from **this repo's** catalogue at [`.claude/skills/`](.claude/skills/README.md). Hub maintains its own Hub-tailored skill set — the `add-hub-*` workflows encode Hub's deltas from LearnStack core (no RLS, `OperatorId` not `UserId`, the 6-step MediatR pipeline, the `hub` schema, the `learnstack_hub` database). The entry point for substantive work is [implement-task](.claude/skills/implement-task/SKILL.md); for scoping-only use [start-task](.claude/skills/start-task/SKILL.md); for review run [standards-check](.claude/skills/standards-check/SKILL.md) then [code-review](.claude/skills/code-review/SKILL.md). -> Hub skills are project-local: an agent running from the `LearnStack-Hub` root loads them from `.claude/skills/`. They cite LearnStack core's standards / ADRs by sibling path (`../LearnStack/docs/...`) for the cross-cutting authority and carry only the Hub-specific workflow on top — they do not duplicate the standards. +> Hub skills are project-local: an agent running from the `LearnStack-Hub` root loads them from `.claude/skills/`. They cite LearnStack core's standards / ADRs by absolute GitHub URL (`https://github.com/HodeTech/LearnStack/blob/main/docs/...`) for the cross-cutting authority and carry only the Hub-specific workflow on top — they do not duplicate the standards. ## Hard rules @@ -131,8 +131,14 @@ Hub follows LearnStack's engineering standards by reference unless explicitly ov - Conventional Commits: `type(scope): subject`. Subject in imperative mood; ≤ 72 chars. - Hub-specific scopes: `hub`, `hub-portal`, `hub-domain`, `hub-infra`, `hub-docs`. -- Commits made with AI assistance carry the trailer: - `Co-Authored-By: Claude Opus 5 (1M context) ` +- Commits made with AI assistance carry a `Co-Authored-By:` trailer — **one per agent that + materially contributed**, each naming that agent's own runtime-specific identity, never a + fixed string standing in for whichever agent actually ran: + - Claude Code sessions: `Co-Authored-By: Claude Opus 5 (1M context) ` + - OpenAI Codex sessions: `Co-Authored-By: Codex Opus 4.7 (1M context) ` + + [LearnStack's Git Workflow Standards § Trailers](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/14-git-workflow.md#trailers) + is the authority for the strings; if this list and that section disagree, that section wins. ## Cross-repo coordination diff --git a/README.md b/README.md index b1d6e4e..fc4b60e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ LearnStack Hub is **not** an LMS, **not** a tenant-facing surface, and **never** ## Status -**P02c-0 — Repository bootstrap** ✅. Solution scaffold, frontend monorepo, compose stack, CI, and the docs skeleton are in place. No Hub domain code is on `main`. +**P02c-0 — Repository bootstrap** ✅. Solution scaffold, frontend monorepo, compose stack, CI, and the docs skeleton are in place. **P02c-1 (Hub Domain Core) shipped 2026-08-09.** It was reviewed against the restructured corpus first: although it was written before [ADR-0033](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0033-audit-durability-model.md), [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) and [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md), it implements none of what they changed — its entitlement wire shape already carries `grace_until` and `generation`, it hosts no endpoint, and its audit behavior is a shell. @@ -29,7 +29,7 @@ The architecture deep-dive lives in the sibling repo: [docs/architecture/24-lear Hub documentation **links** to LearnStack core by absolute URL (`https://github.com/HodeTech/LearnStack/blob/main/docs/...`), per [Documentation Standards § Layout](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/13-documentation.md). A relative path does not cross a repository boundary on github.com and depends on a sibling checkout being present and identically capitalised; an absolute URL works everywhere. Shell commands and filesystem references still use `../LearnStack` — those are paths, not links — so the expected on-disk layout still matters: -``` +```text / ├── LearnStack/ (https://github.com/HodeTech/LearnStack) └── LearnStack-Hub/ (this repo) diff --git a/backend/src/Core/LearnStack.Hub.Api/Program.cs b/backend/src/Core/LearnStack.Hub.Api/Program.cs index d9fea1d..9f7aecb 100644 --- a/backend/src/Core/LearnStack.Hub.Api/Program.cs +++ b/backend/src/Core/LearnStack.Hub.Api/Program.cs @@ -29,13 +29,15 @@ var builder = WebApplication.CreateBuilder(args); // DeploymentMode is read exactly once, here at the composition root; modules -// never read it (Modules_Do_Not_Reference_DeploymentMode). -var deploymentMode = Enum.TryParse( +// never read it (Modules_Do_Not_Reference_DeploymentMode). The resolution rule +// fails closed and is unit-tested in DeploymentModeResolverTests: only the +// Development environment may leave it unset, and only an exact member name is +// accepted. Silently coercing an unset or unrecognised value to Development +// would hand a production host the development error-tracking and resilience +// providers without a word in the log. +var deploymentMode = DeploymentModeResolver.Resolve( builder.Configuration["Hub:DeploymentMode"], - ignoreCase: true, - out var parsed) - ? parsed - : DeploymentMode.Development; + builder.Environment.IsDevelopment()); builder.AddHubSerilog(); builder.AddHubOpenTelemetry(); diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs index d75f0bc..79d095f 100644 --- a/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs +++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs @@ -9,9 +9,11 @@ namespace LearnStack.Hub.Application.Pipeline; /// Hub MediatR pipeline. Hub drops exactly one behavior LearnStack core's seven /// carry — TenantContextBehavior — because Hub is operator-administered, /// not tenant-isolated. (ADR-0032 writes core's as an eight-step list because it -/// counts the Handler; this file counts behaviors.) Outermost (validation) first, innermost (handler) last; the -/// MediatR_Pipeline_Order_Matches_Canonical_Sequence architecture test -/// asserts this DI registration order. +/// counts the Handler; this file counts behaviors.) Outermost (validation) first, innermost (handler) last. +/// MediatR_Pipeline_Order_Matches_Canonical_Sequence asserts the declared +/// order in , and +/// AddHubMediatRPipeline_Registers_The_Canonical_Order asserts that +/// registers exactly that sequence. /// public static class MediatRPipelineRegistration { diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Hosting/DeploymentModeResolver.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Hosting/DeploymentModeResolver.cs new file mode 100644 index 0000000..b779652 --- /dev/null +++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Hosting/DeploymentModeResolver.cs @@ -0,0 +1,54 @@ +namespace LearnStack.Hub.SharedKernel.Hosting; + +/// +/// Resolves the configured at the composition root. +/// It is a pure function of the configured string and the host environment so it +/// can be unit-tested; the composition root remains the only place that reads +/// Hub:DeploymentMode from configuration. +/// +/// It fails closed. Only the Development environment may leave +/// the value unset, and only an exact (case-insensitive) member name is accepted. +/// Enum.TryParse is deliberately not used: it also accepts numeric text +/// ("3") and comma-separated lists ("SaaS,Dedicated", which ORs to +/// 3), both of which land on a defined member and would slip past an +/// Enum.IsDefined guard while meaning nothing an operator intended to write. +/// +/// +public static class DeploymentModeResolver +{ + /// + /// Resolves the mode, or throws with a + /// message naming the key and its valid values. + /// + /// The raw Hub:DeploymentMode value; surrounding whitespace is ignored. + /// Whether the host environment is Development. + public static DeploymentMode Resolve(string? configuredValue, bool isDevelopmentEnvironment) + { + var trimmed = configuredValue?.Trim(); + + if (string.IsNullOrEmpty(trimmed)) + { + if (!isDevelopmentEnvironment) + { + throw new InvalidOperationException( + $"Hub:DeploymentMode is not configured. It is required outside the Development environment. {ValidValuesSuffix()}"); + } + + return DeploymentMode.Development; + } + + foreach (var candidate in Enum.GetValues()) + { + if (string.Equals(candidate.ToString(), trimmed, StringComparison.OrdinalIgnoreCase)) + { + return candidate; + } + } + + throw new InvalidOperationException( + $"Hub:DeploymentMode '{trimmed}' is not a valid DeploymentMode. {ValidValuesSuffix()}"); + } + + private static string ValidValuesSuffix() => + $"Expected exactly one of: {string.Join(", ", Enum.GetNames())}."; +} diff --git a/backend/src/Modules/README.md b/backend/src/Modules/README.md index 8d1ff8a..ccb183f 100644 --- a/backend/src/Modules/README.md +++ b/backend/src/Modules/README.md @@ -1,24 +1,24 @@ # Hub Modules -This directory is intentionally **empty** in P02c-0. Hub-side module aggregates land in subsequent packets. +Four modules are on `main` (P02c-1); the remaining seven land with their owning packets. -## Planned module topology +## Module topology The 11 Hub modules ([ADR-0019 § Hub data model](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md)) arrive across multiple Phase 02c packets: -| Module | Lands in | Aggregates | -| ---------------------------------------- | ------------- | ---------------------------------------------------------------------------- | -| `LearnStack.Hub.Modules.TenantLifecycle` | **P02c-1** | `LearnStackTenant` (mirror) | -| `LearnStack.Hub.Modules.Plans` | **P02c-1** | `Plan`, `PlanTier`, plan defaults (features / limits / compliance) | -| `LearnStack.Hub.Modules.Subscriptions` | **P02c-1** | `HubSubscription` (lifecycle state machine) | -| `LearnStack.Hub.Modules.Entitlements` | **P02c-1** | `Entitlement` (projection from plan + subscription + compliance) | -| `LearnStack.Hub.Modules.CustomDomains` | **P02c-5** | `CustomDomain` (state machine, DNS + TLS lifecycle) | -| `LearnStack.Hub.Modules.Compliance` | **P02c-5** | `CompliancePolicy` (per-tenant cap overrides) | -| `LearnStack.Hub.Modules.Usage` | **P02c-2** | `UsageAggregate` (rolled-up tenant metrics from `POST /api/v1/usage/report`) | -| `LearnStack.Hub.Modules.LicenseKeys` | **P02c-6** | `LicenseKey` (RSA-2048 signed `.lic` file metadata) | -| `LearnStack.Hub.Modules.Invoicing` | **Phase 09b** | `HubInvoice`, `HubInvoiceLine`, `WebhookLedger` | -| `LearnStack.Hub.Modules.Audit` | **P02c-4** | `AuditEntry` (operator audit log; separate from LearnStack tenant audit) | -| `LearnStack.Hub.Modules.Operators` | **P02c-4** | Operator role + permission mapping (Keycloak `learnstack-hub` realm-backed) | +| Module | Lands in | Aggregates | +| ---------------------------------------- | ------------- | --------------------------------------------------------------------------------- | +| `LearnStack.Hub.Modules.TenantLifecycle` | **P02c-1** | `LearnStackTenant` (mirror) | +| `LearnStack.Hub.Modules.Plans` | **P02c-1** | `Plan` (with its `PlanTier` enum and the features / limits / compliance defaults) | +| `LearnStack.Hub.Modules.Subscriptions` | **P02c-1** | `HubSubscription` (lifecycle state machine) | +| `LearnStack.Hub.Modules.Entitlements` | **P02c-1** | `Entitlement` (projection from plan + subscription + compliance) | +| `LearnStack.Hub.Modules.CustomDomains` | **P02c-5** | `CustomDomain` (state machine, DNS + TLS lifecycle) | +| `LearnStack.Hub.Modules.Compliance` | **P02c-5** | `CompliancePolicy` (per-tenant cap overrides) | +| `LearnStack.Hub.Modules.Usage` | **P02c-2** | `UsageAggregate` (rolled-up tenant metrics from `POST /api/v1/usage/report`) | +| `LearnStack.Hub.Modules.LicenseKeys` | **P02c-6** | `LicenseKey` (RSA-2048 signed `.lic` file metadata) | +| `LearnStack.Hub.Modules.Invoicing` | **Phase 09b** | `HubInvoice`, `HubInvoiceLine`, `WebhookLedger` | +| `LearnStack.Hub.Modules.Audit` | **P02c-4** | `AuditEntry` (operator audit log; separate from LearnStack tenant audit) | +| `LearnStack.Hub.Modules.Operators` | **P02c-4** | Operator role + permission mapping (Keycloak `learnstack-hub` realm-backed) | Each module follows the same four-layer pattern as LearnStack core (`Application.Contracts`, `Application`, `Domain`, `Infrastructure`). @@ -29,4 +29,4 @@ Each module follows the same four-layer pattern as LearnStack core (`Application - `ModuleDomain_DoesNotDependOn_OtherModuleDomain` — cross-module Domain references are forbidden; cross-module communication goes through Application.Contracts or integration events. - `ModuleDomain_DoesNotDependOn_AnyApplicationOrInfrastructure` — Domain may only reference SharedKernel. -P02c-0 ships only the meta-test (`NetArchTest_DetectsAPlantedViolation`) and placeholder versions of the rules above. They become real once P02c-1 lands aggregates to test against. +All four are live: P02c-1 landed the module assemblies these rules scan. `Meta_NetArchTest_DetectsAPlantedViolation` keeps the suite non-skippable by proving the scanner can still fail. diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs b/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs index 17162e1..f126966 100644 --- a/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs @@ -1,5 +1,7 @@ using FluentAssertions; using LearnStack.Hub.Application.Pipeline; +using MediatR; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace LearnStack.Hub.Tests.Architecture; @@ -28,6 +30,28 @@ public void MediatR_Pipeline_Order_Matches_Canonical_Sequence() MediatRPipelineRegistration.CanonicalBehaviorOrder.Should().Equal(expected); } + /// + /// The declared list is only a claim until + /// actually registers it. MediatR resolves open-generic + /// descriptors in registration + /// order — outermost first — so the registered order *is* the execution order. + /// + [Fact] + public void AddHubMediatRPipeline_Registers_The_Canonical_Order() + { + var services = new ServiceCollection(); + + services.AddHubMediatRPipeline(typeof(LearnStack.Hub.Application.AssemblyMarker).Assembly); + + var registered = services + .Where(d => d.ServiceType == typeof(IPipelineBehavior<,>)) + .Select(d => d.ImplementationType!) + .ToArray(); + + // The registered pipeline — not the declared list — is what actually runs. + registered.Should().Equal(MediatRPipelineRegistration.CanonicalBehaviorOrder); + } + [Fact] public void Pipeline_Has_No_TenantContextBehavior() { diff --git a/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/DeploymentModeResolverTests.cs b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/DeploymentModeResolverTests.cs new file mode 100644 index 0000000..d4f584a --- /dev/null +++ b/backend/tests/LearnStack.Hub.Tests.Unit/SharedKernel/DeploymentModeResolverTests.cs @@ -0,0 +1,80 @@ +using FluentAssertions; +using LearnStack.Hub.SharedKernel.Hosting; +using Xunit; + +namespace LearnStack.Hub.Tests.Unit.SharedKernel; + +/// +/// The composition root branches its error-tracking and resilience providers on +/// , so a value that resolves to the wrong member — +/// or silently to Development — is a production misconfiguration nobody sees. +/// These are the regression cases for that. +/// +public sealed class DeploymentModeResolverTests +{ + [Theory] + [InlineData("SaaS", DeploymentMode.SaaS)] + [InlineData("saas", DeploymentMode.SaaS)] + [InlineData("SELFHOSTEDAIRGAPPED", DeploymentMode.SelfHostedAirGapped)] + [InlineData(" Dedicated ", DeploymentMode.Dedicated)] + public void Resolve_Accepts_A_Member_Name_Case_Insensitively_And_Trimmed( + string configured, DeploymentMode expected) + { + DeploymentModeResolver.Resolve(configured, isDevelopmentEnvironment: false) + .Should().Be(expected); + } + + /// + /// Enum.TryParse would accept every one of these. "3" lands on + /// SelfHostedOnline and "SaaS,Dedicated" ORs to 3 and lands there too, both + /// passing an Enum.IsDefined guard while meaning nothing an operator + /// wrote on purpose. + /// + [Theory] + [InlineData("3")] + [InlineData("0")] + [InlineData("-1")] + [InlineData("SaaS,Dedicated")] + [InlineData("SaaS, Dedicated")] + [InlineData("Production")] + [InlineData("Self-Hosted-Online")] + public void Resolve_Rejects_Numeric_Combined_And_Unknown_Values(string configured) + { + var resolve = () => DeploymentModeResolver.Resolve(configured, isDevelopmentEnvironment: false); + + resolve.Should().Throw() + .WithMessage("*Hub:DeploymentMode*"); + } + + [Theory] + [InlineData("3")] + [InlineData("SaaS,Dedicated")] + public void Resolve_Rejects_Invalid_Values_In_Development_Too(string configured) + { + var resolve = () => DeploymentModeResolver.Resolve(configured, isDevelopmentEnvironment: true); + + resolve.Should().Throw(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Resolve_Throws_When_Missing_Outside_Development(string? configured) + { + var resolve = () => DeploymentModeResolver.Resolve(configured, isDevelopmentEnvironment: false); + + resolve.Should().Throw() + .WithMessage("*required outside the Development environment*"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Resolve_Defaults_To_Development_Only_When_Missing_In_Development(string? configured) + { + DeploymentModeResolver.Resolve(configured, isDevelopmentEnvironment: true) + .Should().Be(DeploymentMode.Development); + } +} diff --git a/docs/architecture/entitlement-projection.md b/docs/architecture/entitlement-projection.md index e00dca5..9b744e0 100644 --- a/docs/architecture/entitlement-projection.md +++ b/docs/architecture/entitlement-projection.md @@ -146,11 +146,11 @@ These are LearnStack-core-side behaviours that consume the projection; documente fail-closed keys are refused. It never throws out of a feature-flag check. See [ADR-0034 § The entitlement read path](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) and [LearnStack Phase 02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md); this document - does not restate them. Superseded detail: serve cached projection until `expires_at`; within `grace_until` keep serving; past `grace_until` → read-only mode. + does not restate them. Hub's only obligation is to keep emitting a projection whose shape matches the contract above, with a correct monotonic `generation`. ## Architecture-test hooks -- `EntitlementProjection_Shape_IsStable` (from [ADR-0021 § Architecture tests](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md)) — snapshot-test the serialised projection JSON against a checked-in `entitlement-v1.schema.json`. A breaking change requires a schema-version bump. Recommended to land this in P02c-1 since the projection serialiser is the contract surface. +- `EntitlementProjection_Shape_IsStable` (from [ADR-0021 § Architecture tests](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md)) — snapshot-test the serialised projection JSON against a checked-in `entitlement-v1.schema.json`. A breaking change requires a schema-version bump. **Required, not recommended, and required in both repositories**: the schema file is byte-identical on each side and each side asserts its own serialiser against it, so a one-sided shape change fails a build rather than a customer's projection. Shipped with the P02c-1 serialiser; the two snapshots move together in a coordinated pair of pull requests. - `generation` monotonicity is not an architecture test (it's a runtime invariant) — cover it with a unit test on the `Entitlement.Recompute` method (generation strictly increases) and an integration test on the projection service. diff --git a/docs/architecture/repository-layout.md b/docs/architecture/repository-layout.md index 747fe89..f03ccfd 100644 --- a/docs/architecture/repository-layout.md +++ b/docs/architecture/repository-layout.md @@ -2,15 +2,17 @@ LearnStack Hub mirrors LearnStack core's modular-monolith repository pattern. The two repositories sit side by side on disk as `LearnStack/` and `LearnStack-Hub/`, with those -exact capitalisations; cross-repo links from this file are written -`../../../LearnStack/...`. +exact capitalisations. That layout governs **filesystem paths and shell commands** +(`cd ../LearnStack`) only; cross-repo documentation **links** from this file are absolute +GitHub URLs (`https://github.com/HodeTech/LearnStack/blob/main/docs/...`), because a +relative path does not cross a repository boundary on GitHub. This tree describes what is on `main`. `backend/src/Modules/` carries the four [P02c-1](../roadmap/p02c-1-hub-domain-core.md) modules — `TenantLifecycle`, `Plans`, `Subscriptions`, `Entitlements` — merged 2026-08-09. The remaining seven land in later packets, which are frozen from P02c-2 onward. -``` +```text LearnStack-Hub/ ├── backend/ │ ├── LearnStack.Hub.slnx # .NET solution @@ -27,16 +29,17 @@ LearnStack-Hub/ │ │ │ ├── LearnStack.Hub.Infrastructure.Audit/ # Hub operator audit pipeline │ │ │ └── LearnStack.Hub.Api/ # ASP.NET Core host │ │ └── Modules/ # 4 modules on main (P02c-1); 7 planned (see below) -│ │ └── README.md # only file on disk today; describes planned topology +│ │ ├── TenantLifecycle/ Plans/ Subscriptions/ Entitlements/ # four packages each +│ │ └── README.md # the module topology table, co-located with the code │ └── tests/ -│ ├── LearnStack.Hub.Tests.Unit/ # domain + application unit tests (SmokeTests.cs) -│ ├── LearnStack.Hub.Tests.Integration/ # Testcontainers — placeholder until P02c-2 +│ ├── LearnStack.Hub.Tests.Unit/ # domain + application unit tests +│ ├── LearnStack.Hub.Tests.Integration/ # Testcontainers Postgres — the entitlement-rebuild round trip (P02c-1) │ ├── LearnStack.Hub.Tests.Architecture/ # NetArchTest + file-system rules (mandatory, non-skippable) │ │ ├── HubBoundaryTests.cs # Hub_NeverStores_TenantData, Hub_Modules_DoNotReference_LearnStack_Internals │ │ ├── ModuleDependencyTests.cs # module dependency direction │ │ ├── RepositoryLayoutTests.cs # No_Source_Folder_Named_Verticals, Frontend_Has_Only_The_OperatorPortal_App │ │ └── RepositoryPaths.cs # working-copy path resolution shared by the file-system rules -│ └── LearnStack.Hub.Tests.Contract/ # OpenAPI contract assertions — placeholder until P02c-2 +│ └── LearnStack.Hub.Tests.Contract/ # entitlement-v1.schema.json + its snapshot test; OpenAPI assertions from P02c-2 ├── frontend/ │ ├── pnpm-workspace.yaml │ ├── pnpm-lock.yaml @@ -76,7 +79,7 @@ LearnStack-Hub/ │ ├── modules/ # per-module deep dives — 4 shipped (see below) │ └── glossary.md # Hub-specific terms ├── scripts/ -│ └── seed.sh # idempotent dev seed (orchestrator only until P02c-1 unfreezes) +│ └── seed.sh # idempotent dev seed — orchestrates `dotnet run -- --seed` (plan tiers + demo tenant) ├── .claude/ │ └── skills/ # 18 Hub-tailored skills + README; git-tracked via a .gitignore un-ignore rule ├── .github/ @@ -105,7 +108,7 @@ LearnStack-Hub/ | Directory | Files on disk | State | | ------------------ | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | `docs/architecture/` | `README.md`, `contract-with-learnstack.md`, `cross-cutting-foundation.md`, `entitlement-projection.md`, `module-topology.md`, `repository-layout.md` | Five architecture docs plus the index. `learnstack-api-client.md` (P02c-2) and `operator-portal.md` (P02c-4) are still to come — see [architecture/README.md](README.md). | -| `docs/modules/` | `README.md`, `tenant-lifecycle.md`, `plans.md`, `subscriptions.md`, `entitlements.md` | Four module specs shipped, one per P02c-1 module. They were authored as design specification ahead of the implementation; they become the living description when P02c-1 unfreezes and lands. | +| `docs/modules/` | `README.md`, `tenant-lifecycle.md`, `plans.md`, `subscriptions.md`, `entitlements.md` | Four module deep dives, one per P02c-1 module. Authored as design specification ahead of the implementation; since P02c-1 merged (2026-08-09) they are the living description of the code on `main`. | | `docs/roadmap/` | `README.md`, one document per packet, `P02c-1-implementation-prompt.md` | The authoritative Hub plan, owned in this repository. | | `docs/decisions/` | `README.md`, `template.md` | No `HUB-NNNN` ADR has been needed yet. | | `docs/operations/` | `README.md` | Runbooks land with the first non-dev deployment. | @@ -117,10 +120,10 @@ The four [P02c-1](../roadmap/p02c-1-hub-domain-core.md) modules — `TenantLifec | Module subdirectory under `backend/src/Modules/` | Lands in | Aggregates | | ------------------------------------------------ | -------------------------------------------- | ----------------------------------------------- | -| `TenantLifecycle/` | P02c-1 (spec written) | `LearnStackTenant` (mirror) | -| `Plans/` | P02c-1 (spec written) | `Plan`, `PlanTier` | -| `Subscriptions/` | P02c-1 (spec written) | `HubSubscription` | -| `Entitlements/` | P02c-1 (spec written) | `Entitlement` (projection) | +| `TenantLifecycle/` | P02c-1 (on `main`) | `LearnStackTenant` (mirror) | +| `Plans/` | P02c-1 (on `main`) | `Plan` | +| `Subscriptions/` | P02c-1 (on `main`) | `HubSubscription` | +| `Entitlements/` | P02c-1 (on `main`) | `Entitlement` (projection) | | `Usage/` | P02c-2 | `UsageAggregate` | | `Audit/` | P02c-4 | `AuditEntry` (operator audit) | | `Operators/` | P02c-4 | Operator role + permission mapping | diff --git a/docs/glossary.md b/docs/glossary.md index 539fc3d..2f17e77 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -14,7 +14,7 @@ The catalogue entry that defines a tier — `name`, `tier` (starter / growth / s The per-tenant binding to a plan. Tracks the lifecycle state machine — `Trial → Active → PastDue → Canceled → Expired` — plus billing cycle anchors (`current_period_start`, `current_period_end`, `cancel_at_period_end`) and the payment-provider link (`stripe` / `iyzico`). Lands in P02c-1. **`Entitlement`** -The flattened, denormalised computation over `Plan` + `HubSubscription` + `CompliancePolicy` (+ `LicenseKey`) that yields a tenant's effective feature + limit + compliance-cap set. LearnStack's glossary calls this the *Entitlement Aggregate (Hub-side)*; it is the only writer of an entitlement anywhere. What crosses the wire and lands in `platform_entitlement_cache` is the *Entitlement Projection* — a distinct term, defined in [LearnStack's glossary](https://github.com/HodeTech/LearnStack/blob/main/docs/glossary.md). Recomputed atomically whenever a `Plan`, `HubSubscription`, `CompliancePolicy`, or `LicenseKey` changes. Pushed to LearnStack core via `PUT /api/internal/tenants/{id}/entitlements` and mirrored into `platform_entitlement_cache`. The `generation` field is the monotonic version counter that cache invalidation rides on. Lands in P02c-1. +The flattened, denormalised computation over `Plan` + `HubSubscription` that yields a tenant's effective feature + limit + compliance-cap set. LearnStack's glossary calls this the *Entitlement Aggregate (Hub-side)*; it is the only writer of an entitlement anywhere. What crosses the wire and lands in `platform_entitlement_cache` is the *Entitlement Projection* — a distinct term, defined in [LearnStack's glossary](https://github.com/HodeTech/LearnStack/blob/main/docs/glossary.md). Recomputed atomically whenever a `Plan` or `HubSubscription` changes. Two further inputs join later and each brings its own recompute trigger with it: `CompliancePolicy` in [P02c-5](roadmap/p02c-5-custom-domain-lifecycle.md) (the projection's `compliance.caps` is an empty `{}` until then) and `LicenseKey` in [P02c-6](roadmap/p02c-6-license-key.md) (`grace_until` is null until then). The trigger table is authoritative in [entitlement-projection.md § When recompute fires](architecture/entitlement-projection.md). Pushed to LearnStack core via `PUT /api/internal/tenants/{id}/entitlements` and mirrored into `platform_entitlement_cache`. The `generation` field is the monotonic version counter that cache invalidation rides on. Lands in P02c-1. **`generation`** Monotonic version counter on `Entitlement`. Starts at 1, incremented atomically by exactly one on every recompute, never resets and never decrements. The acceptance rule LearnStack applies on a push is stated once, in [entitlement-projection.md § The `generation` counter](architecture/entitlement-projection.md). @@ -23,7 +23,7 @@ Monotonic version counter on `Entitlement`. Starts at 1, incremented atomically The metadata Hub stores for an RSA-2048-signed `.lic` file issued to a Self-Hosted tenant. Holds the signing key id (`kid`, which survives rotation), the `entitlement_generation` frozen into the payload at issuance, `issued_at` / `expires_at` / `grace_until`, and `revoked_at` / `revocation_reason`. The column list is authoritative in [P02c-6](roadmap/p02c-6-license-key.md); this entry follows it. The signed file itself is delivered to the customer; Hub stores only metadata + the issuance audit trail. Lands in P02c-6. **`CustomDomain`** -The per-tenant custom-domain registration. Tracks the lifecycle state machine — `Pending → Verifying → Active → Failed → Revoked` — plus DNS challenge type (`Dns01` / `Http01`), cert metadata (Vault key, expiry, last renewal), and verification attempt counter. Lands in P02c-5. +The per-tenant custom-domain registration. Tracks the lifecycle state machine — `Pending → Verifying → Propagating → Active`, with `Failed` and `Revoked` reachable from it — plus DNS challenge type (`Dns01` / `Http01`), cert metadata (Vault key, expiry, last renewal), and verification attempt counter. `Propagating` is the state a domain sits in after its certificate is issued and before both the host-mapping push and the certificate replication have acknowledged; the `.activated` event fires only on the move to `Active`. The machine is authoritative in [P02c-5](roadmap/p02c-5-custom-domain-lifecycle.md). Lands in P02c-5. **`CompliancePolicy`** Per-tenant compliance-cap overrides on top of the plan-default compliance set. Each row carries a `cap_key`, `allowed` (boolean), `forced` (boolean), and an optional `value`. Pushed to LearnStack core inside the entitlement projection's `compliance.caps` sub-field. Lands in P02c-5. diff --git a/docs/modules/README.md b/docs/modules/README.md index 2b29230..7484adf 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -1,26 +1,28 @@ # Hub Module Deep Dives -Per-module documentation lands here as modules ship in P02c-1+ and Phase 09b. P02c-0 ships only this placeholder. +One deep dive per module. The four [P02c-1](../roadmap/p02c-1-hub-domain-core.md) modules — `TenantLifecycle`, `Plans`, `Subscriptions`, `Entitlements` — are implemented on `main` under `backend/src/Modules/` and documented here. The remaining seven land with their owning packets. ## Module topology -| Module | Aggregates | Phase | Doc status | -| ----------------- | ----------------------------------------------- | --------- | ---------------------------------------------------- | -| `TenantLifecycle` | `LearnStackTenant` (mirror) | P02c-1 | ✅ [tenant-lifecycle.md](tenant-lifecycle.md) (spec) | -| `Plans` | `Plan`, `PlanTier` | P02c-1 | ✅ [plans.md](plans.md) (spec) | -| `Subscriptions` | `HubSubscription` | P02c-1 | ✅ [subscriptions.md](subscriptions.md) (spec) | -| `Entitlements` | `Entitlement` (projection) | P02c-1 | ✅ [entitlements.md](entitlements.md) (spec) | -| `CustomDomains` | `CustomDomain` | P02c-5 | ⏳ | -| `Compliance` | `CompliancePolicy` | P02c-5 | ⏳ | -| `Usage` | `UsageAggregate` | P02c-2 | ⏳ | -| `LicenseKeys` | `LicenseKey` | P02c-6 | ⏳ | -| `Invoicing` | `HubInvoice`, `HubInvoiceLine`, `WebhookLedger` | Phase 09b | ⏳ | -| `Audit` | `AuditEntry` (operator audit) | P02c-4 | ⏳ | -| `Operators` | (Keycloak-backed; permission mapping) | P02c-4 | ⏳ | - -> **"(spec)" status:** the P02c-1 module docs were authored as the design specification -> _before_ implementation. The P02c-1 agent implements against them. The cross-cutting -> design they depend on lives in [../architecture/module-topology.md](../architecture/module-topology.md), +| Module | Aggregates | Phase | Doc status | +| ----------------- | ----------------------------------------------- | --------- | --------------------------------------------- | +| `TenantLifecycle` | `LearnStackTenant` (mirror) | P02c-1 | ✅ [tenant-lifecycle.md](tenant-lifecycle.md) | +| `Plans` | `Plan` | P02c-1 | ✅ [plans.md](plans.md) | +| `Subscriptions` | `HubSubscription` | P02c-1 | ✅ [subscriptions.md](subscriptions.md) | +| `Entitlements` | `Entitlement` (projection) | P02c-1 | ✅ [entitlements.md](entitlements.md) | +| `CustomDomains` | `CustomDomain` | P02c-5 | ⏳ | +| `Compliance` | `CompliancePolicy` | P02c-5 | ⏳ | +| `Usage` | `UsageAggregate` | P02c-2 | ⏳ | +| `LicenseKeys` | `LicenseKey` | P02c-6 | ⏳ | +| `Invoicing` | `HubInvoice`, `HubInvoiceLine`, `WebhookLedger` | Phase 09b | ⏳ | +| `Audit` | `AuditEntry` (operator audit) | P02c-4 | ⏳ | +| `Operators` | (Keycloak-backed; permission mapping) | P02c-4 | ⏳ | + +> **On the four P02c-1 docs:** they were authored as the design specification _before_ +> implementation, and the P02c-1 agent implemented against them. Since P02c-1 merged +> (2026-08-09) they are the living description of code on `main`, and a change to the +> code changes them. The cross-cutting design they depend on lives in +> [../architecture/module-topology.md](../architecture/module-topology.md), > [../architecture/cross-cutting-foundation.md](../architecture/cross-cutting-foundation.md), > and [../architecture/entitlement-projection.md](../architecture/entitlement-projection.md). diff --git a/docs/modules/entitlements.md b/docs/modules/entitlements.md index 70b7117..dd9d7bc 100644 --- a/docs/modules/entitlements.md +++ b/docs/modules/entitlements.md @@ -45,15 +45,20 @@ In P02c-1 the service's final step — publishing `learnstack.hub.entitlement` v `EntitlementsDbContext` — `hub` schema, table `entitlements`. `HasDefaultSchema("hub")`. JSONB columns for `features` / `limits` / `compliance_caps`. PK = `tenant_id` (the `LearnStackTenantId` Vogen converter registered). No RLS. -## Architecture / contract tests (recommended in P02c-1) +## Architecture / contract tests (P02c-1) -- `EntitlementProjection_Shape_IsStable` ([ADR-0021 § Architecture tests](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md)) — snapshot-test the serialised `EntitlementProjectionDto` JSON against a checked-in `entitlement-v1.schema.json` under `LearnStack.Hub.Tests.Contract`. This is the contract guard; land it here because P02c-1 is where the projection serialiser is born. +- `EntitlementProjection_Shape_IsStable` ([ADR-0021 § Architecture tests](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md)) — snapshot-test the serialised `EntitlementProjectionDto` JSON against a checked-in `entitlement-v1.schema.json`. This is the contract guard, and it is **mandatory in both repositories**, not optional in either: `backend/tests/LearnStack.Hub.Tests.Contract/entitlement-v1.schema.json` with `EntitlementProjectionShapeTests` here, and the byte-identical schema with LearnStack's own snapshot test on that side, per [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). The wire shape has one author and two asserters — both snapshots move in the same coordinated pair of pull requests, or the contract has drifted and one build fails instead of a customer's projection. - Unit test: `generation` strictly increases across successive `Recompute` calls; starts at 1. - Integration test (Testcontainers, lights up the `backend-integration` CI job): create tenant → trial subscription → recompute → assert `Entitlement` row exists with generation 1, correct tier/features/limits; change plan → recompute → assert generation 2 + updated fields. -## Audit coverage +## Audit coverage (formal matrix lands in P02c-4) -Recompute is a system operation, not an operator action — it is **not** in the operator-audit MUST list (the operator actions that _trigger_ recompute — plan change, subscription change — are audited in their own modules). `GetEntitlementQuery` is MAY. +Recompute is a system operation, not an operator action, so no operation in this module is MUST-class — the operator actions that _trigger_ recompute are audited in [plans.md](plans.md) and [subscriptions.md](subscriptions.md). + +| Operation | Class | Snapshot | +| ----------------------------- | ----- | ------------------------------------------------------------------------ | +| `RecomputeEntitlementCommand` | MAY | system-initiated; the triggering operator command carries the MUST entry | +| `GetEntitlementQuery` | MAY | — | ## Out of scope for P02c-1 diff --git a/docs/roadmap/P02c-1-implementation-prompt.md b/docs/roadmap/P02c-1-implementation-prompt.md index 83effba..f01ef48 100644 --- a/docs/roadmap/P02c-1-implementation-prompt.md +++ b/docs/roadmap/P02c-1-implementation-prompt.md @@ -3,7 +3,8 @@ > **Purpose.** A copy-paste kickoff prompt for the agent that implements Phase 02c > Packet 1 (Hub Domain Core). Hand this whole file to the agent at session start. > -> **The agent runs from `learnstack-hub` root.** Sibling LearnStack core repo is at `../LearnStack/`. +> **The agent runs from `LearnStack-Hub/` root** — that exact capitalisation; a lower-cased +> spelling works on macOS and fails on Linux. Sibling LearnStack core repo is at `../LearnStack/`. > > **Use the project's own workflow skills.** This repo carries a Hub-tailored > `.claude/skills/` catalogue (see [`.claude/skills/README.md`](../../.claude/skills/README.md)). @@ -134,7 +135,7 @@ Every skill repeats these; they are listed here as the kickoff checklist. - **FeatureKey/LimitKey wire-strings match LearnStack core's registry exactly.** - **`hub` schema, `learnstack_hub` database.** Every DbContext `HasDefaultSchema("hub")`. - **One DbContext per module.** Cross-module FKs are plain `uuid` + index, not EF navigations. -- **English docs; Conventional Commits; AI co-author trailer** (`Co-Authored-By: Claude Opus 5 (1M context) `). +- **English docs; Conventional Commits; AI co-author trailer** — one `Co-Authored-By:` line per agent that materially contributed, each naming that agent's own identity rather than a fixed string ([CLAUDE.md § Commit conventions](../../CLAUDE.md#commit-conventions) carries the per-runtime strings). ## 6. Verification diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 7a4e36a..fd73032 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -40,8 +40,9 @@ Packet 3b, which grows with every packet built on top. ### Reconciliations owed on the merged P02c-1 -Tracked here rather than in the packet document, because they are follow-ups on shipped -code and P02c-1's own record is closed: +Owned by [p02c-1-hub-domain-core.md § Phase Exit Decision](p02c-1-hub-domain-core.md); +repeated here because they outlive the packet and a reader of this index should not have +to open it to learn they exist: - **Audit seam (ADR-0033).** `AuditLogBehavior` is step 3 and wraps `TransactionBehavior`; its success-path TODO writes after commit. ADR-0033 puts the MUST-class write on the @@ -153,7 +154,7 @@ A packet that changes both repositories lands as two pull requests in one sessio ## Status ledger -**Updated 2026-08-08.** This ledger tracks the state of _artefacts_, not commits. A row changes when the artefact changes state, not when a commit touches it — the previous commit-bound version of this file went stale four commits after it was written. +**Updated 2026-08-09.** This ledger tracks the state of _artefacts_, not commits. A row changes when the artefact changes state, not when a commit touches it — the previous commit-bound version of this file went stale four commits after it was written. | Artefact | State | Where | | ------------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/roadmap/hub-billing.md b/docs/roadmap/hub-billing.md index 3a11d60..1961ddf 100644 --- a/docs/roadmap/hub-billing.md +++ b/docs/roadmap/hub-billing.md @@ -1,8 +1,17 @@ # Hub Billing and Invoicing > **This is the authoritative plan.** It was migrated out of LearnStack's -> `phase-09b-hub-billing.md`, which is now a pointer at this file. Hub billing is Hub work -> and belongs in the repository that ships it. +> [phase-09b-hub-billing.md](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-09b-hub-billing.md). +> Hub billing is Hub work and belongs in the repository that ships it. +> +> ⚠️ **Coordination owed, and this note comes down when it is discharged.** That LearnStack +> file is still the full plan on LearnStack's `main`; its conversion to a pointer is +> prepared but unmerged. Until it lands, two documents claim to be authoritative for the +> same track and the link above leads to the competing one. The ordering is not optional: +> **the LearnStack pointer conversion merges first, or in the same session as** any Hub-side +> change that depends on this file being the single source — including the usage-ingestion +> status and the adapter project names below. A Hub-only merge leaves a reader who follows +> the link with a stale plan and no way to tell which one governs. > > Identifier note: the track keeps the **09b** slot in LearnStack's phase numbering > because that identifier appears in commit messages, branch names and cross-repository @@ -82,10 +91,11 @@ Pinned once, in one place, because getting it wrong is expensive and silent: ### Usage ingestion and aggregation -- `POST /api/v1/usage/report` — already in - [ADR-0034's endpoint set](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) - and already handled since [P02c-2](p02c-2-internal-api-and-contract.md) — produces the - raw stream from LearnStack's `IUsageReporter`. +- `POST /api/v1/usage/report` — already enumerated in + [ADR-0034's endpoint set](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md), + and its handler **will be built by** [P02c-2](p02c-2-internal-api-and-contract.md) — + produces the raw stream from LearnStack's `IUsageReporter`. This track assumes that + ingestion exists; it does not build it. - A Hangfire job rolls raw reports into `UsageAggregate` daily. - Reports carry an idempotency key. A replayed report **must not double-count**: the ingestion path is idempotent on that key, and the daily rollup is recomputed from raw @@ -99,7 +109,7 @@ Pinned once, in one place, because getting it wrong is expensive and silent: projection and emits `usage.alert.soft_limit_reached` over `POST /api/v1/usage/report` ([LearnStack Architecture 21 § Soft vs Hard Limits](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/21-feature-flags.md)). - [P02c-2](p02c-2-internal-api-and-contract.md) ingests that stream. **This track adds + [P02c-2](p02c-2-internal-api-and-contract.md) will ingest that stream. **This track adds the Hub half**: distinguishing an alert row from an ordinary usage row, retaining it, surfacing it in the operator portal, and optionally notifying the tenant admin. @@ -136,11 +146,20 @@ Adding a fourth is a code edit, not an ADR. > `IPaymentProvider` (Phase 09, tenant-facing storefront). The two share a shape — > idempotency key, webhook signature verification, status mapping — but describe different > billing relationships: `IPaymentProvider` charges *learners* on behalf of a tenant; -> `IHubPaymentProvider` charges *tenants* on behalf of LearnStack. Hub adapters live in -> `LearnStack.Hub.Infrastructure.Payments.{Stripe,Iyzico,Manual}`; LearnStack adapters live +> `IHubPaymentProvider` charges *tenants* on behalf of LearnStack. LearnStack adapters live > in `LearnStack.Infrastructure.Payments.*` in the other repository. Running both in one > process is forbidden by the codebase separation invariant > ([ADR-0019](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md)). +> +> **Hub adapter project names are fixed by the SDK import boundary** in +> [CLAUDE.md](../../CLAUDE.md), which permits a vendor SDK type only inside the project +> named for that vendor: +> +> | Adapter | Project | SDK | +> |---|---|---| +> | Stripe | `LearnStack.Hub.Infrastructure.Stripe` | Stripe.net — importable here and nowhere else | +> | Iyzico | `LearnStack.Hub.Infrastructure.Iyzico` | Iyzipay — importable here and nowhere else | +> | Manual / wire transfer | `LearnStack.Hub.Infrastructure.Payments.Manual` | none — it is an operator action plus an audit entry, so it carries no vendor dependency and sits outside the SDK boundary | Provider SDK types never leave their adapter assembly; SDK exceptions are translated into `ProviderException` at the boundary, and `IProviderResilience` carries diff --git a/docs/roadmap/hub-marketplace.md b/docs/roadmap/hub-marketplace.md index 922c22f..5b6b4b1 100644 --- a/docs/roadmap/hub-marketplace.md +++ b/docs/roadmap/hub-marketplace.md @@ -1,9 +1,16 @@ # Hub Marketplace > **This is the authoritative plan.** It was migrated out of LearnStack's -> `phase-12-hub-marketplace.md`, which is now a pointer at this file. The **12** slot stays -> in LearnStack's phase numbering because the identifier is referenced across both -> repositories; the plan lives here. +> [phase-12-hub-marketplace.md](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-12-hub-marketplace.md). +> The **12** slot stays in LearnStack's phase numbering because the identifier is +> referenced across both repositories; the plan lives here. +> +> ⚠️ **Coordination owed, and this note comes down when it is discharged.** That LearnStack +> file is still the full plan on LearnStack's `main`; its conversion to a pointer is +> prepared but unmerged. **The LearnStack pointer conversion merges first, or in the same +> session as** any Hub-side change that depends on this file being the single source — +> including the ADR-ownership rule and the activation gate below. Until it lands, two +> documents claim the same track and the link above leads to the competing one. > > **Status: post-MVP and optional.** The platform works fully without it. If the > marketplace never ships, no LearnStack feature breaks. The track exists because @@ -75,8 +82,19 @@ storing listings in the Hub violates the invariant that the whole Hub/LearnStack separation rests on. This is not a detail to be worked out during implementation. It is the design question the -track opens with, and it must be answered by an ADR before any code. Three shapes worth -considering, none of them chosen here: +track opens with, and it must be answered by an ADR before any code. + +**That ADR is a LearnStack ADR, not a Hub one.** It amends or reinterprets ADR-0034 and it +decides where tenant-authored data may live across the boundary, which makes it +cross-cutting by definition — so it is filed in `../LearnStack/docs/decisions/` under the +next free LearnStack number and owned by the cross-repository decision owner. It is **not** +a `HUB-NNNN` decision: that series is reserved for decisions affecting only the Hub +codebase, and a carve-out to a shared invariant is the opposite of that. Once the ADR is +accepted, both roadmaps reference it by number — this document and LearnStack's +[Phase 12 pointer](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-12-hub-marketplace.md) — so +neither side can activate the track against an unrecorded answer. + +Three shapes worth considering, none of them chosen here: 1. **Metadata in the Hub, body outside.** The Hub stores listing metadata — title, author tenant, version, review state, install count — and the bundle body lives in object diff --git a/docs/roadmap/p02c-0-repository-bootstrap.md b/docs/roadmap/p02c-0-repository-bootstrap.md index 1412d0c..7b1d520 100644 --- a/docs/roadmap/p02c-0-repository-bootstrap.md +++ b/docs/roadmap/p02c-0-repository-bootstrap.md @@ -47,7 +47,7 @@ The full layout is documented in [repository-layout.md](../architecture/reposito ### Architecture-test placeholders -Three tests land as structural placeholders that become real when module assemblies exist: the meta-test that keeps the suite non-skippable, `No_Source_Folder_Named_Verticals`, and `Hub_NeverStores_TenantData`. +Four tests land, three of them as structural placeholders that become real when module assemblies exist: `Meta_NetArchTest_DetectsAPlantedViolation` (which keeps the suite non-skippable), `No_Source_Folder_Named_Verticals`, `Hub_NeverStores_TenantData`, and `Frontend_Has_Only_The_OperatorPortal_App` — the last already real, since it asserts against the frontend directory this packet ships. ## Deliverables diff --git a/docs/roadmap/p02c-1-hub-domain-core.md b/docs/roadmap/p02c-1-hub-domain-core.md index 90f660d..af75992 100644 --- a/docs/roadmap/p02c-1-hub-domain-core.md +++ b/docs/roadmap/p02c-1-hub-domain-core.md @@ -27,7 +27,7 @@ Adds `FeatureFlags/` with the `FeatureKey` / `LimitKey` value objects and their Mirrors LearnStack's Phase 02a Packet 3 surface, Hub-adjusted. Specified in full in [cross-cutting-foundation.md](../architecture/cross-cutting-foundation.md): - `HubExceptionHandler : IExceptionHandler` as the single L1 handler, `Result.ToActionResult()` explicit at every endpoint, `ProblemDetailsFactory` + `HttpStatusMap` with the `https://errors.hub.learnstack.dev/` problem-type prefix. -- The **six-step MediatR pipeline**: Validation → Logging → AuditLog → Authorization → Transaction → OutboxFlush → Handler. `Validation`, `Logging` and `Transaction` are live; `AuditLog`, `Authorization` and `OutboxFlush` ship as shells whose registration order is the binding part. +- **Six MediatR behaviors, followed by the Handler**: Validation → Logging → AuditLog → Authorization → Transaction → OutboxFlush → Handler. `Validation`, `Logging` and `Transaction` are live; `AuditLog`, `Authorization` and `OutboxFlush` ship as shells whose registration order is the binding part. - **No `TenantContextBehavior`.** The Hub has no per-request tenant context to assert and no Row Level Security session variable to set. Hub requests are operator-scoped, and the operator identity rides on the `learnstack-hub` realm JWT. - Serilog as the primary logger with an OTLP sink, and the OpenTelemetry SDK for traces and metrics — with the OTel `LoggerProvider` deliberately not registered alongside, so nothing double-exports. No `TenantContextSpanProcessor`; an `OperatorContextSpanProcessor` waits for the Operators module in [P02c-4](p02c-4-operator-portal.md). - `IErrorTrackingProvider` with its three implementations, and `IProviderResilience`, both branched once at the composition root by `DeploymentMode`. Modules never read `DeploymentMode`. diff --git a/docs/roadmap/p02c-2-internal-api-and-contract.md b/docs/roadmap/p02c-2-internal-api-and-contract.md index 133dce4..e34a08e 100644 --- a/docs/roadmap/p02c-2-internal-api-and-contract.md +++ b/docs/roadmap/p02c-2-internal-api-and-contract.md @@ -28,15 +28,17 @@ The Hub's own tenant-facing and operator-facing API (`/api/v1/tenants/*`, `/api/ ### The authentication chain -Three independent layers on every call in both directions, per [ADR-0019](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) and unchanged by ADR-0034: +The current authority is [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md), which carries the endpoint set and the single authentication chain that guards it in both directions; [ADR-0019](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) is where the chain originates. Three independent layers on every call, in both directions: - **mTLS** with client certificates signed by the LearnStack-internal CA. - **A signed RS256 JWT** with `aud=learnstack-internal` and an expiry of at most five minutes, replay-protected by a short-TTL inbox keyed on `jti`. - **An HMAC-SHA256 body signature** in `X-Signature`, using a per-deployment shared secret. -All three secrets — the client certificate and key, the JWT signing key, the HMAC key — are read through `ISecretProvider`. None of them appears in configuration files, in logs, or in an error message. Rejecting a request that is missing any one layer is a tested behaviour, not an assumed one. +All three secrets — the client certificate and key, the JWT signing key, the HMAC key — are read through `ISecretProvider`. **Production secret material** appears in no configuration file, log line, error message or trace attribute. The `HUB_INTERNAL_API_HMAC_KEY` placeholder in `.env.example` is not an exception to that rule: it is non-secret, development-only fixture data with an obvious placeholder shape, and it never names a production value. Rejecting a request that is missing any one layer is a tested behaviour, not an assumed one. -`/api/internal/*` binds to an internal listener and is never reachable on the internet-facing one. `Internal_API_Endpoints_AreNot_Public` enforces it. +**Both internal prefixes** bind to an internal listener and are never reachable on the internet-facing one: LearnStack hosts `/api/internal/*` and the Hub hosts `/api/v1/internal/*`. `Internal_API_Endpoints_AreNot_Public` covers **both** patterns — a rule written only for `/api/internal/*` would leave every Hub-hosted endpoint on this surface unguarded, since none of them match that prefix. The Hub's own operator- and tenant-facing API (`/api/v1/tenants/*` and the rest) stays on the public listener; `/api/v1/internal/*` is the segment that does not. + +Realm separation is enforced on the same endpoints: a `learnstack` (tenant) realm token is rejected on every Hub `/api/v1/internal/*` route, per the two-realm boundary in [ADR-0004 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md). The mTLS + JWT + HMAC chain and the realm check are independent — passing the transport chain does not make a tenant token acceptable. ### The `Usage` module @@ -53,7 +55,16 @@ Two things travel when a projection is recomputed, and they are not the same thi - **The projection itself**, pushed over `PUT /api/internal/tenants/{id}/entitlements`. This is the contract-bearing path and it is HTTP. It carries the `generation` counter, which is what lets the receiver reject an out-of-order delivery instead of overwriting newer state with older. - **An eager-invalidation signal**, published as `learnstack.hub.entitlement`. This is an optimisation over waiting for a cache to expire, and it is the first genuine cross-process integration event in either system — which is precisely the trigger [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) names for promoting `IEventBus` from its in-process default to a broker-backed adapter. This packet ships the publish behind `IOutbox` and `IEventBus` so that the transport choice stays at the composition root and is settled by that trigger, not by this packet. -Correctness does not depend on the signal arriving. If it is lost, the projection is still authoritative and the receiver still converges on the next read. +**They are two outbox records, not one.** A recompute enqueues one record for the HTTP push and one for the event, each with its own handler, its own idempotency key and its own retry schedule, so neither delivery can be dropped by the other's success and a partial failure retries only the half that failed. Coordinating both behind a single record would make them one delivery with two side effects: a handler that pushed and then failed to publish would either retry the push (duplicating it) or mark the record done (silently losing the event). + +Convergence follows from that split, and it is **receiver-side** idempotency that makes it work — "exactly once" is a property of the effect, not of the transport. Both deliveries retry after a lost acknowledgement, because a sender that cannot distinguish "never arrived" from "arrived, ack lost" must retry, and both receivers absorb the repeat: + +- **The push** is idempotent on `generation`. The rule LearnStack applies is stated once, in [entitlement-projection.md § The `generation` counter](../architecture/entitlement-projection.md): a payload older than the cached generation is rejected, so a retry or an out-of-order delivery cannot overwrite newer state. A replay at the _current_ generation carries the same bytes the receiver already holds — the Hub increments by exactly one per recompute and is the only writer — so applying it again changes nothing. +- **The event** is idempotent on `(tenant_id, generation)` at the consumer's inbox guard, so a redelivery is a no-op. + +Correctness does not depend on the signal arriving at all: if the event is lost after its retries are exhausted, the projection is still authoritative and the receiver still converges on the next read. + +> **Open contract question, owned by ADR-0034, not by this packet.** The rule above says nothing about a payload that arrives at the _current_ generation with _different_ bytes. That should be impossible — one writer, +1 per recompute — so it is a symptom, not a case to converge on, and the two defensible answers are "reject with a conflict" and "protect the receiver with a durable idempotency key on the delivery rather than on the generation". Choosing between them changes observable behaviour on the LearnStack side of a two-repository contract, so it is settled by an ADR in `../LearnStack/docs/decisions/` amending [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) and landed in both repositories — not by this document, and not by whichever handler is written first. [P02c-7](p02c-7-exit-gate.md)'s Gate 2 gains the divergent-replay case when that ADR exists. ### OpenAPI and SDK generation @@ -86,7 +97,7 @@ Correctness does not depend on the signal arriving. If it is lost, the projectio - No secret used by the chain is readable from configuration, a log line, an error response, or a trace attribute. - `/api/internal/*` returns nothing on the internet-facing listener. - Reporting the same usage metric twice with the same idempotency key records it once. -- Recomputing an entitlement enqueues exactly one outbox row, and flushing it produces one push carrying the current `generation`. +- Recomputing an entitlement enqueues exactly two outbox rows — the `PUT .../entitlements` push and the `learnstack.hub.entitlement` publish — each carrying the current `generation`. Failing one leaves the other's row untouched and retryable. "Exactly one" is measured at the receiver, in **effective applications**, not in transport attempts: a delivery retried after a lost acknowledgement is required behaviour, and the test asserts that replaying it leaves the receiver's state and its `generation` unchanged. - The OpenAPI document generates an SDK that typechecks, and the contract test fails on an unannounced change. - `backend-integration` runs on every pull request and is a required check. diff --git a/docs/roadmap/p02c-4-operator-portal.md b/docs/roadmap/p02c-4-operator-portal.md index 30e973c..a84de16 100644 --- a/docs/roadmap/p02c-4-operator-portal.md +++ b/docs/roadmap/p02c-4-operator-portal.md @@ -29,18 +29,25 @@ stream) — which is why several shells left open in P02c-1 close here rather th is enforced on both sides of the login — the portal validates issuer and `azp`, and the Hub API rejects any token whose issuer is the tenant realm. - **Backend-for-frontend session.** Tokens are held server-side by the Next.js route - handlers under `apps/operator-portal/src/app/api/` and surfaced to the browser only as + handlers under `frontend/apps/operator-portal/src/app/api/` and surfaced to the browser only as an `HttpOnly`, `Secure`, `SameSite=Lax` session cookie. No access token, refresh token or id token ever reaches `localStorage`, `sessionStorage`, or a client component. An operator session is a platform-administrator session; an XSS bug that could read a token would be a full control-plane compromise, so the token never sits where script can read it. -- **MFA is enforced, not offered.** The realm's browser flow requires OTP, and the - `CONFIGURE_TOTP` required action is set on the operator realm role. The realm export - checked into `infra/keycloak/` carries this configuration, and an integration test - asserts that a login attempt that skips the OTP step does not produce a session. A - realm where MFA is optional is a misconfiguration the portal reports at startup rather - than tolerates. +- **MFA is enforced, not offered.** The realm's browser flow requires OTP, and + `CONFIGURE_TOTP` is a `requiredAction` on **every user** in the realm — not on a role, + because a role-scoped required action leaves an operator who holds no role able to log + in without it. There is exactly one owning export, + `../LearnStack/infra/keycloak/realms/learnstack-hub.json`: the realm JSON lives in the + LearnStack repository because LearnStack's compose stack imports both realms at first + boot, and this repository's [`infra/keycloak/README.md`](../../infra/keycloak/README.md) + documents that ownership rather than holding a second copy. One integration test — + `OperatorLogin_SkippingOtp_ProducesNoSession`, in + `frontend/apps/operator-portal`'s test suite — reads that export and asserts both facts: + the required action is present, and a login attempt that skips the OTP step produces no + session. A realm where MFA is optional is a misconfiguration the portal reports at + startup rather than tolerates. - Refresh happens in the route handler on a short access-token lifetime; the session cookie's lifetime is bounded by the refresh token, and logout revokes at the realm. @@ -126,7 +133,7 @@ The app is **`frontend/apps/operator-portal`**. LearnStack-side documents call it `learnstack-hub-web` ([Architecture 24 § 6](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md) and LearnStack's `CLAUDE.md` among them). **That name is stale.** The Hub repository ships -`apps/operator-portal`, and +`frontend/apps/operator-portal`, and [`RepositoryLayoutTests.Frontend_Has_Only_The_OperatorPortal_App`](../../backend/tests/LearnStack.Hub.Tests.Architecture/RepositoryLayoutTests.cs) fails the build if a second frontend app appears or that directory is renamed. P02c-4 corrects the LearnStack-side references in the coordinated cross-repo pass described in @@ -177,8 +184,9 @@ until an ADR explains how a support read happens without the Hub touching tenant - `frontend/apps/operator-portal` — BFF session route handlers, dashboard, tenant list, tenant detail with the read-only entitlement viewer, plan list, audit stream. - `@learnstack-hub/ui` primitives; ESLint flat config; Next 16. -- `infra/keycloak/` realm export carrying the MFA-required browser flow and the operator - roles. +- The MFA-required browser flow and the operator roles added to the single owning realm + export, `../LearnStack/infra/keycloak/realms/learnstack-hub.json`, as a coordinated + cross-repo change per [`infra/keycloak/README.md`](../../infra/keycloak/README.md). - `OperatorContextSpanProcessor`. - Test coverage: unit tests for permission mapping; integration tests for the login flow (including the MFA-skip rejection and the wrong-realm rejection); an audit test @@ -213,9 +221,10 @@ until an ADR explains how a support read happens without the Hub touching tenant one call", and the BFF boundary erodes. Mitigated by keeping the SDK client session-cookie-based with no token parameter, so there is no signature that accepts one. - **MFA becoming advisory.** A developer disables OTP locally for convenience and the - realm export follows. Mitigated by asserting the required action in an integration test - against the checked-in realm export, not against a running instance an operator - configured by hand. + realm export follows. Mitigated by `OperatorLogin_SkippingOtp_ProducesNoSession` + asserting the required action against the checked-in export at + `../LearnStack/infra/keycloak/realms/learnstack-hub.json`, not against a running + instance an operator configured by hand. - **The entitlement viewer becoming an editor.** The most requested next feature will be "let me just override this one flag for this one tenant". Doing so makes the projection no longer a projection and breaks `generation` monotonicity as a cache-coherency diff --git a/docs/roadmap/p02c-5-custom-domain-lifecycle.md b/docs/roadmap/p02c-5-custom-domain-lifecycle.md index f2a6d02..3a68e99 100644 --- a/docs/roadmap/p02c-5-custom-domain-lifecycle.md +++ b/docs/roadmap/p02c-5-custom-domain-lifecycle.md @@ -36,18 +36,83 @@ stateDiagram-v2 Pending --> Verifying: StartVerification() Verifying --> Verifying: RecordVerificationFailure(error) Verifying --> Failed: attempts exhausted - Verifying --> Active: MarkVerified(certRef, issuedAt, expiresAt) + Verifying --> Propagating: MarkVerified(certRef, issuedAt, expiresAt) + Propagating --> Propagating: RecordPropagationFailure(error) + Propagating --> Failed: attempts exhausted + Propagating --> Active: MarkPropagated() (push + replication both acknowledged) Active --> Active: Renew(newExpiresAt) Active --> Revoked: Revoke() Failed --> Verifying: StartVerification() (operator retry) + Failed --> Propagating: RetryPropagation() (operator retry, cert already issued) Failed --> Revoked: Revoke() ``` -States: `Pending | Verifying | Active | Failed | Revoked`. Text fallback — a submitted -domain is `Pending`; verification moves it to `Verifying`, where failures accumulate -until the attempt budget is exhausted (`Failed`) or the challenge succeeds (`Active`); -an active domain renews in place and can be revoked; a failed domain can be retried by an -operator or revoked. +States: `Pending | Verifying | Propagating | Active | Failed | Revoked`. Text fallback — a +submitted domain is `Pending`; verification moves it to `Verifying`, where failures +accumulate until the attempt budget is exhausted (`Failed`) or the challenge succeeds. +A successful challenge does **not** reach `Active`: it moves to `Propagating`, where the +domain waits on both halves of § Propagation — the `PUT .../host-mappings` push +acknowledged by LearnStack, and the certificate replication into the LearnStack-owned +secret store acknowledged by the secret store. Only when both acknowledge does +`MarkPropagated()` move it to `Active`, and only that transition emits +`learnstack.hub.custom-domain.activated`. The intermediate state exists because +`Verifying → Active` on a certificate alone is exactly the split brain the § Risks entry +names: a domain that resolves but does not serve. An active domain renews in place and can +be revoked; a failed domain is retried by an operator — re-verifying if the challenge +failed, re-propagating if only the push or the replication did — or revoked. + +#### What `MarkPropagated()` requires + +"Both acknowledged" is a **durable** claim, not an in-memory one, or a restart between the +two acknowledgements silently loses one and the domain either sticks in `Propagating` +forever or activates on half a propagation. + +- **One propagation attempt id per entry into `Propagating`.** A `propagation_id` + (UUIDv7, minted by `IGuidFactory`) is written on the aggregate when it enters the state, + and both channels carry it: the host-mapping push sends it as its idempotency key, and + the replication request is tagged with it. It is the correlation key in logs and in the + operator queue, and it changes on each `RetryPropagation()` so a late acknowledgement + from a superseded attempt is recognised and discarded rather than counted. +- **Two acknowledgement columns, scoped to the attempt that produced them.** + `host_mapping_acked_at` and `cert_replication_acked_at` are nullable timestamps on the + aggregate. A timestamp alone cannot say *which* attempt acknowledged, so the scoping is + made explicit at both ends: + - Every acknowledgement carries the `propagation_id` it answers, and the write is + conditional on that id equalling the aggregate's **current** one and on the column + being null. An acknowledgement for a superseded attempt is discarded, and a + redelivered acknowledgement for the current one is a no-op — neither can double-count. + - `RetryPropagation()` mints a new `propagation_id` **and clears both columns in the + same transaction**. A new attempt therefore starts from two nulls and cannot inherit a + half-acknowledgement from the attempt it replaces, which would let one channel's old + success stand in for a channel that never answered this time. +- **`MarkPropagated()` is a guard, not a signal.** It returns + `Result.Fail(business_rule_violation)` unless both columns are non-null under the + current `propagation_id`. It is invoked after each acknowledgement lands, so whichever + arrives second is the one that opens the gate — neither channel needs to know about the + other. +- **Restart recovery is a query, not a memory.** On startup, and on every run of the + propagation job, domains in `Propagating` are re-driven from the two columns: + - Either column null → that channel alone is re-sent under the current + `propagation_id`, which is safe because both sides are idempotent on it. The + already-acknowledged channel is **not** re-sent. + - Both columns non-null → the acknowledgements landed but the transition did not + commit, so recovery calls `MarkPropagated()` and completes it. Without this branch a + crash in the window between the second acknowledgement and the state write would leave + a fully propagated domain stuck in `Propagating` until its timeout expired, and it + would then be reported as a propagation failure that never happened. + + Nothing is reconstructed from process state. +- **A timeout is a state, not a hang.** A `Propagating` domain whose attempt budget or + wall-clock window expires with either column still null moves to `Failed` with + `LastPropagationError` naming *which* channel did not acknowledge — the difference + between a LearnStack-side push failure and a secret-store replication failure is the + whole diagnostic, and an operator queue that only says "propagation failed" sends + someone to the wrong system. +- **The event follows the committed transition.** `learnstack.hub.custom-domain.activated` + is enqueued on the outbox **in the same transaction** as the `Propagating → Active` + write, so it is published if and only if that transition committed. It is never emitted + from an acknowledgement handler, which would announce an activation that a later + rollback un-did. Every transition is a method returning `Result` and emitting a domain event. Invalid transitions return `Result.Fail(business_rule_violation)` and never throw @@ -81,10 +146,23 @@ authenticated context, never from a submitted field. first request after cutover and before issuance fails. The submission UI states this ordering; DNS-01 has no such problem, which is why it is the default rather than a preference. -- A Hangfire recurring job runs CNAME verification with a bounded attempt budget and - backoff (ADR-0022's default: every 60 seconds, up to 60 attempts). `VerificationAttempts` - and `LastVerificationError` are on the aggregate so the operator queue can show *why* a - domain is stuck, not just that it is. +- A Hangfire recurring job runs the verification poll with a bounded attempt budget and + backoff (ADR-0022's default: every 60 seconds, up to 60 attempts). **What it checks is + selected by the domain's challenge mode**, not fixed — a job that only ever did one of + the two would silently never succeed for domains in the other mode: + - **DNS-01** — query the `_acme-challenge.{domain}` TXT record and compare it against the + expected token. + - **HTTP-01** — request `http://{domain}/.well-known/acme-challenge/{token}` **on port + 80** and compare the response body against the expected key authorization. The CNAME + pointing at the LearnStack edge is a *precondition* for that request reaching us, not + the check itself: a correct CNAME with no token served is a failed challenge, and + treating the CNAME as sufficient would mark a domain verified that the CA will refuse. + Redirects are followed only to `https://` on the **same** host (which is what a + HTTP→HTTPS edge does, and what RFC 8555 permits); a redirect to any other host is a + failure, because following one would let a third party answer the challenge. + + `VerificationAttempts` and `LastVerificationError` are on the aggregate so the operator + queue can show *why* a domain is stuck, not just that it is. - A second recurring job renews certificates inside their 30-day pre-expiry window. ### `ITlsCertificateProvider` and the ACME adapter @@ -114,7 +192,7 @@ names: | Topic | Emitted when | LearnStack-side effect | |---|---|---| -| `learnstack.hub.custom-domain.activated` | `MarkVerified` succeeds | **Invalidate** the resolver cache entry for the host. The mapping itself arrives over `PUT /api/internal/tenants/{id}/host-mappings` — see § Propagation below | +| `learnstack.hub.custom-domain.activated` | `MarkPropagated` succeeds — i.e. on entering `Active`, after both the host-mapping push and the certificate replication have acknowledged. Never on `MarkVerified` alone | **Invalidate** the resolver cache entry for the host. The mapping itself arrives over `PUT /api/internal/tenants/{id}/host-mappings` — see § Propagation below | | `learnstack.hub.custom-domain.deactivated` | `Revoke` succeeds | **Invalidate** the resolver cache entry for the host. The row is removed by the same push endpoint | | `learnstack.hub.custom-domain.renewed` | `Renew` succeeds | Refresh the certificate reference; no mapping change | @@ -198,16 +276,23 @@ Added to the P02c-4 shell: Custom Domains → Pending Queue, Active List, Renewa The LearnStack-side event consumer, the `host-mappings` handler and the `platform_host_to_tenant` writes are the paired half of this packet and live in LearnStack -[Phase 02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md), merged in the -same session per [CLAUDE.md § Cross-repo coordination](../../CLAUDE.md). +[Phase 02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md). They land as +two pull requests in one session, per [CLAUDE.md § Cross-repo coordination](../../CLAUDE.md): +the **Hub pull request opens first** and carries the canonical contract shape, the +**LearnStack pull request references the Hub PR's commit hash** and is written against +that shape, and **both merge in the same session** — either-side merge alone leaves the +contract dangling. The LearnStack **edge** half is demand-gated. Per [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md), both the APISIX adapter and custom-domain TLS automation land in [LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md), -with the trigger *"a tenant needs its own domain in production"*. Before that trigger -fires, LearnStack terminates TLS with its default ASP.NET hosting rather than APISIX SSL -objects. +on their two **separate** triggers — TLS automation on *"a tenant needs its own domain in +production"*, APISIX on *"a non-dev deployment needs edge rate limiting, host routing, or +JWT pre-validation"*. The first custom domain in production satisfies both, which is why +they land together, but they are two rows with two conditions and neither implies the +other. Before those triggers fire, LearnStack terminates TLS with its default ASP.NET +hosting rather than APISIX SSL objects. The split is clean because routing and termination are separable: host **resolution** works as soon as `platform_host_to_tenant` carries the row, so a request with a custom @@ -221,23 +306,87 @@ serving that host on a publicly trusted certificate at the edge. at the Hub ([ADR-0004](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md)). Enumerated in [ADR-0034 § The endpoint set](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). + **Its security boundary is settled before a line of it is written**, because it is the + one endpoint on this surface that carries a tenant-originated request: + + - It sits under the Hub's existing protected internal prefix, `/api/v1/internal/*`, + bound to the internal listener and never the internet-facing one. That prefix is + covered by `Internal_API_Endpoints_AreNot_Public` — the rule matches **both** internal + patterns, not only LearnStack's `/api/internal/*`, per + [P02c-2 § The authentication chain](p02c-2-internal-api-and-contract.md). + - It carries the full three-layer chain — mTLS, RS256 JWT (`aud=learnstack-internal`, + ≤ 5 min, `jti` replay-protected) and HMAC-SHA256 body signature — like every other + endpoint in the ADR-0034 set. No exemption for being "just a form submission". + - **The accepted issuer set is an allow-list of exactly one:** the `learnstack-hub` + realm. The token's `iss` must equal that realm's issuer URL, and **every** other + issuer is rejected — the `learnstack` tenant realm among them, but not only it. This + is stated as an allow-list rather than as "reject `learnstack`" on purpose: a + deny-list naming one realm says nothing about a third realm, a renamed realm, or a + second Keycloak someone stands up, and each of those would be accepted by a rule that + only knows what to refuse. Hub authenticates against the `learnstack-hub` realm + **only** ([ADR-0004 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md)). + That is also why the hop exists: the tenant admin authenticates to Studio against the + tenant realm, and Studio proxies with its own `learnstack-hub` service credentials + rather than forwarding the tenant's token. Forwarding it would dissolve the two-realm + boundary at exactly the endpoint that decides which tenant owns a host. + - **The route `{id}` is the only tenant selector, and it is checked, not trusted.** The + request body carries no tenant field at all — one that appears is rejected as + malformed rather than ignored, because silently ignoring it lets a caller believe a + submission was scoped the way it wrote it. The token is scoped to the tenant it acts + for, and the handler asserts that the route `{id}` equals that scope **before** + `Create` runs, refusing a mismatch with `403`. A service credential that can reach the + endpoint at all must not thereby be able to attach a domain to any tenant it names in + the path. `CustomDomain_TenantId_NeverReadFrom_RequestBody` covers the body half of + this; the route-versus-scope check is handler behaviour and is covered by its own + negative test. + + The LearnStack side of the hop goes through **`IHubTenantSync`** — the named adapter for + tenant-administration crossings, and under ADR-0034's second invariant the only type on + that side permitted to hold a Hub client for it. The Studio proxy calls the adapter; the + adapter calls the Hub. `Hub_Client_Referenced_Only_By_Named_Adapters` fails the + LearnStack build if anything else does. This packet's Hub pull request fixes the request + and response contract — the submitted FQDN and challenge mode in, the created + `CustomDomain` id and its initial state out — **before** the LearnStack-side adapter + method is written against it, per the coordination protocol below. + - `LearnStack.Hub.Modules.CustomDomains` — aggregate, state machine, public-suffix validation, uniqueness constraints, commands and queries. - `LearnStack.Hub.Modules.Compliance` — `CompliancePolicy`, cap merge into the projection, recompute trigger. - `ITlsCertificateProvider` port plus the `LearnStack.Hub.Infrastructure.Acme` adapter, resilience-decorated, with a distinct rate-limited outcome. -- DNS-01 and HTTP-01 challenge runners; the CNAME verification recurring job; the renewal - recurring job. +- DNS-01 and HTTP-01 challenge runners; the verification recurring job covering **both** + the TXT-record poll (DNS-01) and the `/.well-known/acme-challenge/{token}` fetch on port + 80 (HTTP-01); the renewal recurring job. - `PUT /api/internal/tenants/{id}/host-mappings` on the outbound `LearnStackApiClient`, carrying host tuples and certificate references only. - Secret-store replication path from `learnstack-hub/certs/{domain}` to the LearnStack-side path, with the LearnStack side referencing by path. - The three `learnstack.hub.custom-domain.*` integration events. - Operator portal: pending queue, active list, renewal watch, caps editor. -- Tests: unit tests for every state transition and every rejection at `Create`; an - integration test issuing against an ACME staging directory end to end; a contract test - asserting `entitlement-v1.schema.json` contains no host or certificate fields. +- Tests: + - Unit tests for every state transition and every rejection at `Create`. + - Propagation: `MarkPropagated()` refused with one acknowledgement and accepted with + both; a duplicate acknowledgement for the same `propagation_id` changing nothing; an + acknowledgement carrying a superseded `propagation_id` discarded; **partial-ack + retry** — one channel acknowledges, `RetryPropagation()` mints a new id and clears + both columns, and the new attempt is not opened by the previous attempt's + acknowledgement; **crash recovery** in both shapes — one column null re-sends only + that channel, and both columns non-null with the domain still `Propagating` completes + the transition rather than waiting for the timeout; a timeout landing in `Failed` with + `LastPropagationError` naming the channel that did not acknowledge; and the + `.activated` outbox row existing only when the transition committed. + - Submission endpoint: a token from the `learnstack` realm rejected, and a token from + any issuer outside the single-entry allow-list rejected; a route `{id}` that does not + match the token's tenant scope refused with `403` before `Create` runs; a body + carrying a tenant field rejected as malformed. + - Verification job, **both** paths: DNS-01 TXT poll over found / absent / mismatched; + HTTP-01 token fetch over valid body, absent (404), mismatched body, failed request + (connection refused or timeout), same-host `https://` redirect (followed, succeeds) + and cross-host redirect (not followed, fails). + - An integration test issuing against an ACME staging directory end to end. + - A contract test asserting `entitlement-v1.schema.json` contains no host or certificate + fields. ## Completion Criteria diff --git a/docs/roadmap/p02c-7-exit-gate.md b/docs/roadmap/p02c-7-exit-gate.md index 19a8146..86759d5 100644 --- a/docs/roadmap/p02c-7-exit-gate.md +++ b/docs/roadmap/p02c-7-exit-gate.md @@ -75,23 +75,40 @@ A domain goes from submitted to resolving, without an operator editing infrastru ### Gate 4 — The internal API rejects a request missing any leg of the auth chain -Both directions, three negative tests each. +Both directions, all **three authentication legs** — which is a count of legs, not a count +of test cases. Each leg carries a separate, separately named negative test per variant +below, so seven tests per direction, fourteen in total. A single "auth rejects bad +requests" test covering one variant per leg would leave four of the seven variants +unexercised. The chain is mTLS + RS256 JWT (`aud=learnstack-internal`, five-minute expiry, `jti` replay protection) + HMAC body signature, applied to every endpoint in [ADR-0034's set](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). -| Removed | Expected | -|---|---| -| Client certificate | Connection refused at the TLS layer; no application handler runs | -| JWT (absent, expired, wrong audience, or replayed `jti`) | `401`, request not processed | -| HMAC body signature (absent or over a tampered body) | `401`, request not processed | +| Leg | Variant, each its own test | Expected | +|---|---|---| +| mTLS | Client certificate absent | The **TLS handshake fails** while the listener is up and accepting — the client sees a handshake-level rejection, not a TCP `connection refused`. A refused connection would pass this test with the listener simply down, which proves nothing about mTLS. No application handler runs, and no HTTP response is produced | +| JWT | Absent | `401`, request not processed | +| JWT | Past its five-minute expiry | `401`, request not processed | +| JWT | Wrong `aud` | `401`, request not processed | +| JWT | Replayed `jti` | `401`, request not processed | +| HMAC | `X-Signature` absent | `401`, request not processed | +| HMAC | Signature over a tampered body | `401`, request not processed | Run against **both** internal surfaces: LearnStack's `/api/internal/*` (Hub → LearnStack) -and the Hub's `/api/v1/internal/*` (LearnStack → Hub). Each rejection is logged with the -correlation id and the failed check; the response body says nothing about which leg -failed, because an attacker probing the chain should learn nothing from the shape of the -refusal. +and the Hub's `/api/v1/internal/*` (LearnStack → Hub). + +Each rejection is logged with the failed check and a correlation id — but the mTLS leg +fails **before HTTP exists**, so there is no request, no header, and no application +correlation id to log. That leg is logged at the transport level instead: the connection +id the server assigns, plus the remote endpoint and the handshake failure reason. A gate +that demanded an application correlation id on a handshake failure would be asking for a +value that cannot exist, and the usual way that requirement gets "satisfied" is by moving +the certificate check into the application, which is the opposite of what this gate +protects. + +For the JWT and HMAC legs the response body says nothing about which leg failed, because +an attacker probing the chain should learn nothing from the shape of the refusal. `Internal_API_Endpoints_AreNot_Public` stays green: neither internal surface is bound to an internet-facing listener. @@ -164,7 +181,8 @@ the packet that owns it, and that packet's status is reopened. A defect found he - A cross-repository end-to-end suite that boots both stacks (Testcontainers for Postgres, the ACME staging directory, and both Keycloak realms) and runs Gates 1–5 as executable scenarios. -- Negative-path tests for the auth chain, both directions, all three legs. +- Negative-path tests for the auth chain: both directions, all three legs, one separately + named test per variant in the Gate 4 table (fourteen in total). - The registry-reconciliation check for `FeatureKey` / `LimitKey`. - A short runbook for running the gate locally, in [docs/operations/](../operations/README.md).