diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1bc6337 --- /dev/null +++ b/.env.example @@ -0,0 +1,62 @@ +# LearnStack — local dev environment variables (single source of truth). +# +# Copy to `.env` at the repo root (NOT committed — `.gitignore` covers it). +# `infra/compose/dev.yml` and `infra/compose/e2e.yml` read this via the +# `${VAR:-default}` interpolation Compose performs at file-parse time +# (every reference has a dev-safe fallback so the stack still boots even +# without `.env`). The Dapr Vault token chain runs through Dapr's own +# `secretKeyRef` + local-env-secret-store indirection — `.env` → compose +# env → daprd's process env → `secretstore-envvar.yaml` (the +# `secretstores.local.env` component named `envvar-secrets`) → +# `secretstore-vault.yaml` `secretKeyRef`. Dapr does not support a +# `{{env.VAR}}` template; the indirection is the only sanctioned shape. +# See `infra/dapr/README.md` § Vault token for the full walk. +# +# Every value below is a **dev-only** default. Production wires the real +# values through `ISecretProvider` against an authenticated Vault cluster +# per Standards 12 § Secrets Management — `.env.example` is the local +# parity, not the production source. + +# ─── Postgres (PostgreSQL 18 per ADR-0031) ─────────────────────────────── +POSTGRES_USER=learnstack +POSTGRES_PASSWORD=learnstack +POSTGRES_DB=learnstack + +# ─── Keycloak (two realms per ADR-0004 Amendment 1) ────────────────────── +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin-dev-secret + +# ─── Vault (-dev mode; token shared with Dapr secret-store component) ──── +# Boots the `vault` compose service via `-dev-root-token-id` AND is passed +# into the `dapr-sidecar-api` service env so Dapr's `secretstore-vault.yaml` +# component can resolve `vaultToken` through `secretKeyRef` against the +# local-env secret store (`secretstore-envvar.yaml` → `envvar-secrets`). +# Changing this value updates Vault boot + Dapr auth in one shot. +VAULT_ROOT_TOKEN=learnstack-dev-root-token + +# ─── LiveKit (dev key/secret; production rotates via ILiveClassProvider) ─ +LIVEKIT_API_KEY=devkey +LIVEKIT_API_SECRET=devsecret-32-byte-min-length-padding-xyz + +# ─── Coturn (long-term credentials; production uses use-auth-secret) ───── +COTURN_USER=devuser +COTURN_PASSWORD=devsecret + +# ─── SeaweedFS S3 (dev identities; production loads from Vault) ────────── +# These values MUST match `infra/seaweedfs/s3-identities.json` because that +# file is what SeaweedFS actually reads at boot (the binary does not support +# env-var substitution in its identity config). The vars here document the +# canonical credential for the future backend `IStorageProvider` adapter +# (Phase 02b+) and any developer S3 CLI scripts; changing them WITHOUT +# updating the JSON above means the backend tries to talk with a key the +# server has never seen. Production swaps both for Vault-issued credentials. +SEAWEEDFS_ACCESS_KEY=learnstack +SEAWEEDFS_SECRET_KEY=learnstack-dev-secret + +# ─── Backend host (LearnStack.Api, runs on workstation via `dotnet run`) ─ +ASPNETCORE_ENVIRONMENT=Development +ASPNETCORE_URLS=http://localhost:5080 + +# ─── Frontend host (Next.js apps/web; see frontend/apps/web/.env.local.example +# for the apps/web-only overrides — Next reads .env.local from the app dir, +# not the repo root, so the FE has its own copy mirroring these values) ─── diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..1f1dc63 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# LearnStack pre-commit hook. +# +# Activated via `git config core.hooksPath .githooks` (run by `make install`). +# Formats staged files in place ONLY — never the working tree at large — so +# the check is fast and does not pollute unrelated changes. +# +# Languages handled: +# *.cs → dotnet format (whitespace + style) +# *.{ts,tsx,js,jsx,mjs,cjs} → prettier --write + ESLint via pnpm +# *.{json,md} → prettier --write +# +# Intentionally NOT handled: *.yml / *.yaml. Compose / Dapr / APISIX YAMLs +# are comment-heavy and prettier reflows them in a way that hurts review +# readability. CI runs `yamllint` on those files separately. +# +# Secret scanning (per Standards 12 § Secrets Management): runs Leakwatch +# (`leakwatch scan fs ` per file) when the binary is on PATH. +# If not installed, the hook warns once and continues — CI re-runs the +# same scan as a hard gate so nothing reaches main without a check. The +# project config (`.leakwatch.yaml` + `.leakwatchignore`) lives at repo +# root and applies to both invocations. +# +# WIP isolation: formatters mutate the working tree, then we re-stage the +# changed paths. Without isolation that re-stage would silently capture any +# unstaged edits the developer was holding back. We stash unstaged changes +# first and pop on EXIT, so only the originally-indexed content gets +# committed. +# +# Bypass once: `git commit --no-verify` (allowed for emergency fixes only; +# CI re-checks every check this hook runs so a bypassed local commit will +# fail the PR build). + +set -eu -o pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +# ─── Stash unstaged changes so we only format what's actually indexed ─── +# `--keep-index` leaves staged content in place; `--include-untracked` +# avoids losing newly-untracked files. The EXIT trap pops the stash even +# if formatters fail, so a developer never loses their WIP. We detect WIP +# two ways — `git diff --quiet` for tracked-but-unstaged changes (exit 1 +# if any), and `git ls-files --others --exclude-standard` for untracked. +# A prior `git status --porcelain | awk` probe added nothing actionable +# (the two checks below already cover every WIP shape) and was removed. +STASH_REF="" +unstaged_changes=$(git diff --quiet || echo "yes") +unstaged_untracked=$(git ls-files --others --exclude-standard | head -n 1) +if [[ -n "$unstaged_changes" || -n "$unstaged_untracked" ]]; then + STASH_REF="learnstack-precommit-$(date +%s)" + git stash push --keep-index --include-untracked --quiet -m "$STASH_REF" + # Capture the stash sha so we can match by message (positional refs + # shift if the hook is reentered concurrently). + pop_stash() { + if [[ -n "$STASH_REF" ]]; then + # Find the stash entry by message and pop it. + entry=$(git stash list | awk -F: -v m="$STASH_REF" '$0 ~ m { print $1; exit }') + if [[ -n "$entry" ]]; then + git stash pop --quiet "$entry" || \ + printf "pre-commit: stash pop failed for %s — recover with: git stash list\n" "$entry" >&2 + fi + fi + } + trap pop_stash EXIT +fi + +# ─── Collect staged paths grouped by file type ────────────────────────── +staged_files() { + git diff --cached --name-only --diff-filter=ACMR -z "$@" +} + +cs_files=() +js_like_files=() +prettier_only_files=() +all_staged=() + +# Single staged-list walk; bucket into per-language arrays AND keep a flat +# `all_staged` copy for the Leakwatch loop below (avoids a second `git +# diff --cached` invocation on every commit). +while IFS= read -r -d '' f; do + all_staged+=("$f") + case "$f" in + *.cs) cs_files+=("$f") ;; + *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) js_like_files+=("$f") ;; + *.json|*.md) prettier_only_files+=("$f") ;; + esac +done < <(staged_files) + +restage() { git add -- "$@"; } + +# ─── Secret scanning (Leakwatch if available) ─────────────────────────── +# Leakwatch's CLI takes a path argument; we pass each staged file +# individually so the scan stays scoped to what's about to be committed +# (the alternative — `leakwatch scan fs .` — walks the entire tree). +# Iterating costs one CLI invocation per file but each one is fast +# (Aho-Corasick pre-filter); for small commits this is sub-second. + +if command -v leakwatch >/dev/null 2>&1; then + if [[ ${#all_staged[@]} -gt 0 ]]; then + printf "pre-commit: leakwatch scan (%d file(s)) …\n" "${#all_staged[@]}" + for f in "${all_staged[@]}"; do + # Skip files that don't exist (D for delete in --diff-filter). + [[ -f "$f" ]] || continue + # Capture stdout+stderr; on failure replay the scanner output to + # the developer (suppressing it would leave them guessing which + # detector fired). Exit code drives the gate; output drives the + # diagnosis. + if ! scan_output=$(leakwatch scan fs "$f" --config .leakwatch.yaml --min-severity medium --no-verify 2>&1); then + printf "\npre-commit: leakwatch found a likely secret in %s\n\n" "$f" >&2 + printf "%s\n\n" "$scan_output" >&2 + printf "If it is a legitimate dev credential, add an inline\n" >&2 + printf "\`# leakwatch:ignore\` comment or extend .leakwatchignore.\n" >&2 + exit 1 + fi + done + fi +else + printf "pre-commit: leakwatch not on PATH — skipping local secret scan (CI re-runs it).\n" >&2 + printf " install: brew install cemililik/tap/leakwatch\n" >&2 + printf " or: go install github.com/cemililik/leakwatch@latest\n" >&2 +fi + +# ─── Backend: dotnet format ───────────────────────────────────────────── +if [[ ${#cs_files[@]} -gt 0 ]]; then + if ! command -v dotnet >/dev/null 2>&1; then + printf "pre-commit: dotnet SDK not found — staged C# files left unformatted.\n" >&2 + exit 1 + fi + printf "pre-commit: dotnet format (%d file(s)) …\n" "${#cs_files[@]}" + rel_cs=() + for f in "${cs_files[@]}"; do + rel_cs+=("${f#backend/}") + done + (cd backend && dotnet format LearnStack.slnx --include "${rel_cs[@]}" --no-restore) + restage "${cs_files[@]}" +fi + +# ─── Frontend: prettier + ESLint via pnpm ─────────────────────────────── +# Aggregate JS-like + JSON/MD into the prettier batch; run ESLint --fix on +# JS-like files only. The path-rewrite (`/#/../`) turns repo-root paths into +# frontend-relative ones since the pnpm scripts execute from `frontend/`. +prettier_batch=("${js_like_files[@]:-}" "${prettier_only_files[@]:-}") +real_prettier_batch=() +for f in "${prettier_batch[@]}"; do + [[ -n "$f" ]] && real_prettier_batch+=("$f") +done + +if [[ ${#real_prettier_batch[@]} -gt 0 || ${#js_like_files[@]} -gt 0 ]]; then + if ! command -v pnpm >/dev/null 2>&1; then + printf "pre-commit: pnpm not found — staged JS/JSON/MD files left unformatted.\n" >&2 + exit 1 + fi + + if [[ ${#real_prettier_batch[@]} -gt 0 ]]; then + printf "pre-commit: prettier --write (%d file(s)) …\n" "${#real_prettier_batch[@]}" + (cd frontend && pnpm exec prettier --write --log-level warn "${real_prettier_batch[@]/#/../}") + restage "${real_prettier_batch[@]}" + fi + + if [[ ${#js_like_files[@]} -gt 0 ]]; then + # ESLint runs only on JS-like files (not on JSON/MD). + # `--max-warnings 0` turns ANY warning into a hook failure. + printf "pre-commit: eslint --fix (%d file(s)) …\n" "${#js_like_files[@]}" + (cd frontend && pnpm exec eslint --fix --max-warnings 0 "${js_like_files[@]/#/../}") + restage "${js_like_files[@]}" + fi +fi diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..fc38172 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,105 @@ +# Contributing to LearnStack + +The full engineering corpus lives in [`docs/`](../docs/) — this file is the +short, branch-protection-and-PR-hygiene companion. + +## Branch protection (settings on `main`) + +Configure these in **GitHub → Settings → Branches → Branch protection rules +→ Branch name pattern: `main`** so the corpus matches what GitHub enforces: + +- **Require a pull request before merging** + - Require approvals: **1** (raise to 2 once the team grows past two + active contributors). + - Dismiss stale approvals when new commits are pushed: **on**. + - Require review from CODEOWNERS: **off** (no CODEOWNERS file yet). +- **Require status checks to pass before merging** + - Require branches to be up to date before merging: **on**. + - Required status checks (the job names from `.github/workflows/ci.yml`): + - `backend (build + unit + arch + contract)` + - `frontend (typecheck + lint + build + test)` + - `meta (commit hygiene + link audit)` + - `secret scan (leakwatch)` + - Deferred checks — flip the `if: false` guards in `ci.yml` AND add the + job name here when the owning phase lands: + - `backend integration (Testcontainers — deferred)` — Phase 02a. + - `openapi diff (deferred to Phase 03)` — Phase 03. + - `lighthouse budget (deferred to Phase 04)` — Phase 04. +- **Require conversation resolution before merging**: on. +- **Require signed commits**: optional (off until the team rolls out signing keys). +- **Require linear history**: on (we use squash-merge or rebase-merge, never bubble). +- **Do not allow bypassing the above settings**: on (admins included). +- **Restrict who can push to matching branches**: off (PRs only — no direct push). +- **Allow force pushes**: off. +- **Allow deletions**: off. + +The CI workflow is intentionally fast (~3 min target). If a step exceeds +that budget for two consecutive merges, raise a follow-up issue rather +than skipping the step on `main`. + +## Commit messages + +Per CLAUDE.md § Commit conventions: + +- **Conventional Commits**: `type(scope): subject` with subject in + imperative mood, ≤ 72 chars. +- AI-assisted commits carry the trailer + `Co-Authored-By: Claude Opus 4.7 (1M context) ` + (replace the model name when authoring with a different assistant). +- `docs(scope)` for doc-only commits; scope ∈ `architecture | decisions | + standards | roadmap` or omitted for cross-cutting changes. + +## Pull requests + +- Title mirrors the primary commit's subject. +- Description has three sections: + 1. **What** — bullet list of changes grouped by area. + 2. **Why** — one paragraph; link to the ADR / phase / issue. + 3. **Verification** — what suites you ran locally, what manual checks + you walked. +- Link the related ADR / phase doc with relative paths (`../docs/...`). + +## Local checks before pushing + +```bash +make install # one-time per clone: deps + git hooks +make lint # dotnet format --verify + ESLint +make typecheck # tsc --noEmit +make test # unit + arch + contract + vitest +``` + +The pre-commit hook (activated by `make install`) runs `dotnet format` + +prettier + ESLint + (if installed) `leakwatch scan fs ` on +staged files — so the lint / typecheck / test / secret-scan pass above is +mostly a sanity check. CI re-runs every check as a hard gate, so a +bypassed local commit will fail the PR build. + +The secret scanner is [Leakwatch](https://github.com/cemililik/Leakwatch) +— MIT licensed, verifier-equipped, hybrid Aho-Corasick + regex + entropy +detection engine. Config lives at `.leakwatch.yaml` + `.leakwatchignore` +at the repo root. Install once for the local pre-commit scan (CI runs it +regardless, this is just earlier feedback): + +```bash +brew install cemililik/tap/leakwatch # macOS (Homebrew) +# or: +go install github.com/cemililik/leakwatch@latest +``` + +If Leakwatch flags an intentional dev credential, prefer: + +1. **Inline ignore** at the literal — `# leakwatch:ignore` (or + `# leakwatch:ignore:` for a targeted skip) at the end + of the line carrying the dev credential. Lowest blast radius. +2. **`.leakwatchignore`** path entry — for whole files where every + value is dev-only (env templates, the LiveKit / Coturn confs). +3. **`.leakwatch.yaml` config tweak** — last resort; document the why. + +## Never + +- `git push --force` to `main`. Branch protection blocks it; do not work + around it. +- Bypass the pre-commit hook (`--no-verify`) for anything but a documented + emergency — CI will catch it and the PR will fail. +- Edit an Accepted ADR's Decision section. Open a new ADR that supersedes + it, with the same number rule preserved. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..68b7696 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,325 @@ +# LearnStack — CI baseline. +# +# Per docs/roadmap/phase-01-repository-tooling.md § CI Baseline. Runs on +# every push to `main` and every pull request. Required status checks on +# `main` are listed in `.github/CONTRIBUTING.md` § Branch protection so +# the GitHub Settings → Branches page matches the corpus. +# +# What lights up in Phase 01: +# - backend : `dotnet build` + unit + architecture + contract tests +# - frontend : pnpm install + typecheck + lint + build + Vitest +# - meta : `make lint`-style format verification +# +# Deferred to later phases (jobs are scaffolded as `if: false` placeholders +# so the activation is a one-line flip): +# - backend-integration : Testcontainers needs a real Docker socket inside +# the runner — works on `ubuntu-latest` natively. Activates when the +# first integration test lands (Phase 02a) so we have something to run. +# - openapi-diff : oasdiff against the prior `main` spec. Activates +# in Phase 03 when the first real endpoint replaces `/healthz` as the +# only documented surface (until then there is nothing to diff). +# - lighthouse-budget : LHCI against the built Next.js app. Activates +# when Phase 04 ships the first content-bearing public page (the +# current placeholder routes are not worth scoring). + +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +# Cancel in-progress runs on the same ref so PR force-pushes don't queue. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + # Cancel stale PR builds on force-push; KEEP main builds running so + # back-to-back merges don't lose the signal from the older one. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + DOTNET_SDK_VERSION: "10.0.100" + NODE_VERSION: "20.11.0" + PNPM_VERSION: "9.12.3" + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true" + +jobs: + # ─── Backend ──────────────────────────────────────────────────────────── + backend: + name: backend (build + unit + arch + contract) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_SDK_VERSION }} + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('backend/**/*.csproj', 'backend/Directory.Packages.props') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Restore + working-directory: backend + run: dotnet restore LearnStack.slnx + + - name: Format verify (dotnet format) + working-directory: backend + run: dotnet format LearnStack.slnx --verify-no-changes --no-restore + + - name: Build (TreatWarningsAsErrors) + working-directory: backend + env: + CI: "true" + run: dotnet build LearnStack.slnx --no-restore --configuration Release + + - name: Test (unit + architecture + contract; integration excluded) + working-directory: backend + run: | + dotnet test LearnStack.slnx \ + --no-restore --no-build --configuration Release \ + --filter "FullyQualifiedName!~LearnStack.Tests.Integration" \ + --logger "trx;LogFileName=test-results.trx" \ + --results-directory ../artifacts/backend-tests + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-test-results + path: artifacts/backend-tests + if-no-files-found: warn + + # ─── Backend integration (deferred — Testcontainers harness lights up Phase 02a) ─ + backend-integration: + name: backend integration (Testcontainers — deferred) + runs-on: ubuntu-latest + if: false # activate when LearnStack.Tests.Integration has its first test + steps: + - run: echo "Placeholder — Phase 02a wires the first Testcontainers integration test." + + # ─── Frontend ─────────────────────────────────────────────────────────── + frontend: + name: frontend (typecheck + lint + build + test) + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('frontend/pnpm-lock.yaml') }} + restore-keys: | + pnpm-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm -r typecheck + + - name: Lint + run: pnpm -r lint + + - name: Build + run: pnpm -r build + + - name: Test (Vitest) + run: pnpm -r test + + # ─── OpenAPI breaking-change check (deferred — Phase 03) ────────────── + openapi-diff: + name: openapi diff (deferred to Phase 03) + runs-on: ubuntu-latest + if: false # activate when /api/v1/* endpoints replace the `/healthz` placeholder + steps: + - run: echo "Placeholder — Phase 03 wires oasdiff against the prior main spec." + + # ─── Lighthouse budget (deferred — Phase 04) ────────────────────────── + lighthouse-budget: + name: lighthouse budget (deferred to Phase 04) + runs-on: ubuntu-latest + if: false # activate when the first content-bearing public page ships + steps: + - run: echo "Placeholder — Phase 04 wires LHCI against the built Next.js app." + + # ─── Meta (commit-message format, link audit) ───────────────────────── + meta: + name: meta (commit hygiene + link audit) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # link audit walks the full tree + persist-credentials: false + + - name: Markdown link audit (changed docs) + # Template values from `github.event.*` are passed through `env:` so + # they expand into shell variables AT THE SHELL'S quoting boundary, + # never as raw substitution inside the `run:` block. This is the + # GHA-documented script-injection defense — even though `base.ref` + # and `before` are not user-controlled here, defense-in-depth keeps + # the pattern consistent across every step that consumes context. + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + # Walk every relative-path link in the changed Markdown files of + # this PR. Externals (http(s):, mailto:) and pure-anchor links + # (#section) are skipped explicitly so the check stays narrow. + # Relative links are accepted in any shape the project uses: + # [x](./foo.md) — current-dir prefixed + # [x](../foo.md) — parent-dir prefixed + # [x](docs/foo.md) — bare repo-relative (CLAUDE.md convention) + # Anchors (#frag) are stripped before the file-existence check. + if [[ "$EVENT_NAME" == "pull_request" ]]; then + base="origin/${PR_BASE_REF}" + git fetch --no-tags --depth=1 origin "${PR_BASE_REF}" + else + base="${PUSH_BEFORE_SHA}" + fi + changed=$(git diff --name-only "$base"...HEAD -- '*.md' || true) + if [[ -z "$changed" ]]; then + echo "No changed Markdown files." + exit 0 + fi + broken=0 + while IFS= read -r f; do + # `]\(([^)#][^)]*)\)` — capture every non-anchor link target. + # Then filter out externals. + while IFS= read -r link; do + # Skip external schemes. + case "$link" in + http://*|https://*|mailto:*|tel:*|ftp://*) continue ;; + esac + # Strip anchor + query suffixes for the existence check. + link_path="${link%%#*}" + link_path="${link_path%%\?*}" + [[ -z "$link_path" ]] && continue + # Markdown resolves relative links against the source + # file's directory by default; the project ALSO uses + # repo-relative shapes (`docs/foo.md`) per CLAUDE.md + # § Cross-link. Try source-relative first, fall back to + # repo-relative — a link that resolves either way is ok. + source_relative="$(dirname "$f")/$link_path" + if [[ -e "$source_relative" || -e "$link_path" ]]; then + : + else + echo "BROKEN: $f → $link" + broken=$((broken + 1)) + fi + done < <(grep -oE '\]\(([^)#][^)]*)\)' "$f" | sed -E 's/^\]\((.+)\)$/\1/') + done <<< "$changed" + if [[ $broken -gt 0 ]]; then + echo "::error::$broken broken relative link(s) in changed Markdown." + exit 1 + fi + + - name: docs/analysis residual scan + run: | + # Per CLAUDE.md: docs/analysis/ is gitignored and MUST NOT be + # *referenced* from committed files. Distinguish: + # - illegal: `[text](docs/analysis/...)` Markdown link, OR any + # code-import shape that resolves to docs/analysis/: + # `from "..."`, `require("...")`, `import("...")`, + # `using docs.analysis.*;` + # - legal: any mention inside backticks (`docs/analysis/`), + # inline code, or prose about the rule itself + # Restrict to link / import shapes so meta-references in CLAUDE.md + # / standards / roadmap pass cleanly. Extension coverage matches + # what the codebase actually ships (no Python / Java code paths). + residual=$(grep -rnE \ + '\]\(docs/analysis/|(from|import|require)[ (]["'"'"']docs/analysis/' \ + --include='*.md' --include='*.cs' \ + --include='*.ts' --include='*.tsx' \ + --include='*.js' --include='*.jsx' \ + --include='*.mjs' --include='*.cjs' \ + . 2>/dev/null || true) + if [[ -n "$residual" ]]; then + echo "::error::Illegal references to docs/analysis/ (link target or import):" + echo "$residual" + exit 1 + fi + + # ─── Secret scan (Leakwatch; gates per Standards 12 § Secrets Management) ─ + # Leakwatch is the project's chosen scanner — MIT licensed, verifier- + # equipped, hybrid Aho-Corasick + regex + entropy engine, YAML custom + # rules. Config lives at `.leakwatch.yaml` + `.leakwatchignore`. + # + # We install the CLI via `go install` (not the third-party action wrapper) + # so we control the version pin and the verification posture explicitly. + # `--no-verify` skips the live-API verifier because CI runners must stay + # hermetic — dev credentials are filtered out via entropy threshold + + # `.leakwatchignore`; production secrets never reach the repo. + secret-scan: + name: secret scan (leakwatch) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history so push-event scans see prior commits + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Install Leakwatch + run: go install github.com/cemililik/leakwatch@v1.5.0 + + - name: Scan + run: | + leakwatch scan fs . \ + --config .leakwatch.yaml \ + --format sarif \ + --output results.sarif \ + --min-severity medium \ + --no-verify + + - name: Upload SARIF artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: leakwatch-results + path: results.sarif + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 8b4eb5c..b245c5c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,10 +26,14 @@ dist/ build/ coverage/ -# Environment +# Environment — track only the explicitly-listed *.example templates, +# never real envs. `!*.example` would un-ignore every future `foo.example` +# file in the tree (e.g. `infra/foo.conf.example`); the explicit allowlist +# below keeps each new template an opt-in decision. .env .env.* !.env.example +!frontend/apps/web/.env.local.example # Logs *.log diff --git a/.leakwatch.yaml b/.leakwatch.yaml new file mode 100644 index 0000000..29142b8 --- /dev/null +++ b/.leakwatch.yaml @@ -0,0 +1,86 @@ +# LearnStack — Leakwatch configuration. +# +# Active hygiene: every commit is scanned by `leakwatch scan fs ` +# in the pre-commit hook (when the binary is on PATH) + `leakwatch scan fs .` +# in CI (`.github/workflows/ci.yml` § secret-scan). Per Standards 12 +# § Secrets Management: +# "No secret may appear in code, in `appsettings.*.json` checked into +# git, or in container env vars. The pre-commit hook scans for +# high-entropy strings; CI fails on hits." +# +# The intentional dev credentials in this repo (Vault root token, Keycloak +# admin password, LiveKit dev key, …) are filtered out by: +# 1. `detection.entropy.threshold: 4.2` — short low-entropy literals like +# `admin-dev-secret` / `demo-dev-secret` fall below the threshold. +# 2. `.leakwatchignore` — env templates and dev-only config files +# (LiveKit's 32-byte padded secret, Coturn's user/pass, the SeaweedFS +# S3 identity JSON) are excluded by path. +# 3. Inline `# leakwatch:ignore` comments where a single high-entropy +# literal must stay in an otherwise-scanned file. +# +# If a new dev-only credential surfaces and trips a real scan, prefer: +# inline ignore (per-line) > .leakwatchignore (per-path) > config tweaks +# Path-level exclusions hide every detector on that file, so use sparingly. + +# ── Scan Engine ───────────────────────────────────────────────────────── +scan: + concurrency: 4 # CI runner-friendly; raise locally if needed + max-file-size: 10485760 # 10 MB — skips compiled binaries / large images + +# ── Detection ─────────────────────────────────────────────────────────── +detection: + entropy: + enabled: true + threshold: 4.2 # Slightly more selective than the 4.0 default + # so short low-entropy dev literals + # (admin-dev-secret, demo-dev-secret) don't fire + +# ── Verification ──────────────────────────────────────────────────────── +# Disabled in this config: CI must stay hermetic (no outbound API calls to +# vendor verifier endpoints for dev tokens). Local developers running +# `leakwatch scan fs .` outside CI can override with `--verify`. +verification: + enabled: false + +# ── Filter ────────────────────────────────────────────────────────────── +filter: + exclude-paths: + # Build / generated artifacts (do not commit, but defense-in-depth): + - "node_modules/**" + - "**/dist/**" + - "**/build/**" + - "**/.next/**" + - "**/bin/**" + - "**/obj/**" + - "**/coverage/**" + # Lock files (high churn, no human secrets): + - "**/*.lock" + - "**/package-lock.json" + - "**/yarn.lock" + - "**/pnpm-lock.yaml" + - "**/go.sum" + # Minified assets: + - "**/*.min.js" + - "**/*.min.css" + - "**/*.map" + # Binary assets: + - "**/*.png" + - "**/*.jpg" + - "**/*.jpeg" + - "**/*.gif" + - "**/*.ico" + - "**/*.woff" + - "**/*.woff2" + - "**/*.ttf" + # Local-only research (gitignored anyway): + - "docs/analysis/**" + exclude-detectors: [] # No detectors disabled globally — see ignores above + +# ── Output ────────────────────────────────────────────────────────────── +# CI overrides via CLI flags (`--format sarif --output results.sarif`); the +# defaults below cover developer-local runs. +output: + format: table + file: "" + show-raw: false + severity-threshold: medium diff --git a/.leakwatchignore b/.leakwatchignore new file mode 100644 index 0000000..9d99cdb --- /dev/null +++ b/.leakwatchignore @@ -0,0 +1,57 @@ +# LearnStack — Leakwatch path ignores. +# +# Glob syntax (same as .gitignore). Each entry must cite WHY the path +# carries intentional dev-only literals; production never reads from these +# files, and every production credential surface goes through +# `ISecretProvider` per Standards 20 § Secrets Management. +# +# Prefer inline `# leakwatch:ignore` over .leakwatchignore for single +# literals — path-level exclusion turns off EVERY detector on the file. + +# ─── Env templates (carry placeholder shapes, never real secrets) ─────── +.env.example +frontend/apps/web/.env.local.example + +# ─── LiveKit + Coturn dev configs ─────────────────────────────────────── +# `devsecret-32-byte-min-length-padding-xyz` is intentionally high-entropy +# to satisfy LiveKit's 32-byte secret length requirement; production +# rotates per-session via ILiveClassProvider per ADR-0005 / Phase 08c. +infra/livekit/livekit.yaml +infra/coturn/turnserver.conf + +# ─── SeaweedFS S3 identities ──────────────────────────────────────────── +# Dev S3 access+secret pair SeaweedFS reads at boot. Phase 02b storage +# adapter wires Vault-issued credentials for non-dev modes. (Credential +# values not quoted here on purpose — Leakwatch scans this file too, and +# any literal in the comment would self-flag.) +infra/seaweedfs/s3-identities.json + +# ─── Keycloak realm seeds ─────────────────────────────────────────────── +# Confidential-client secret + seeded demo-user passwords. Production +# realm provisioning (Terraform / keycloak-config-cli) issues per- +# deployment values per ADR-0004 Amendment 1. +infra/keycloak/realms/learnstack.json +infra/keycloak/realms/learnstack-hub.json + +# ─── TLS architecture documentation ───────────────────────────────────── +# These docs include illustrative PEM blocks ("-----BEGIN ... PRIVATE +# KEY-----") that explain how the custom-domain flow works per ADR-0022. +# They are documentation examples, never live keys; production keys are +# issued by Let's Encrypt and stored in Vault. +docs/architecture/27-custom-domain-tls.md +docs/decisions/0022-custom-domain-tls.md + +# ─── Infra README orientation tables ──────────────────────────────────── +# Each subdirectory's README documents the dev credentials inline (Vault +# root token, Keycloak admin pass, Meilisearch master key, LiveKit dev +# key, demo-user passwords, the SeaweedFS access pair) for orientation — +# the README is the first file a contributor reads when standing up that +# service. The actual literals live in `.env.example` + the corresponding +# config file; the READMEs mirror them so the orientation table is +# self-contained. Excluded for symmetry with the config files those +# READMEs document. +infra/compose/README.md +infra/dapr/README.md +infra/keycloak/README.md +infra/livekit/README.md +infra/seaweedfs/README.md diff --git a/.vscode/extensions.json b/.vscode/extensions.json index ba170e4..84bc9c3 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,6 +6,8 @@ "ms-dotnettools.csharp", "ms-dotnettools.csdevkit", "editorconfig.editorconfig", - "yzhang.markdown-all-in-one" + "yzhang.markdown-all-in-one", + "redhat.vscode-yaml", + "ms-azuretools.vscode-docker" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index e928546..bfa187d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,17 @@ "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[csharp]": { "editor.defaultFormatter": "ms-dotnettools.csharp" } + "[csharp]": { "editor.defaultFormatter": "ms-dotnettools.csharp" }, + + "// yaml": "Declare Compose's override-merge tags so the redhat.vscode-yaml extension stops flagging `!reset` / `!override` in `infra/compose/e2e.yml` (Compose accepts them — `docker compose config -q` validates — but generic YAML parsers don't know they exist).", + "yaml.customTags": [ + "!reset", + "!reset sequence", + "!reset mapping", + "!reset scalar", + "!override", + "!override sequence", + "!override mapping", + "!override scalar" + ] } diff --git a/CLAUDE.md b/CLAUDE.md index 9deae9f..55294fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,22 +21,28 @@ Self-Hosted — backed by the companion **`learnstack-hub`** repository ## What state this is in -**Phase 01 packets 1-6 shipped.** The repository now has the .NET 10 -solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 -test projects including the non-skippable `LearnStack.Tests.Architecture`), -the `pnpm` frontend monorepo under `frontend/` (`apps/web` Next.js App -Router + `packages/{config,ui,sdk}`), and the full local-dev compose -stack at `infra/compose/dev.yml` — PostgreSQL 18, Valkey, SeaweedFS, -Mailpit, Meilisearch, Keycloak (two realms), LiveKit OSS + Coturn, -Kafka + kafka-ui, Vault, Dapr sidecar + placement, APISIX in file- -driven standalone mode. The remaining Phase-01 packets (7-8) — `make` -orchestrator, `.env.example`, pre-commit hook, `e2e.yml`, GitHub Actions -CI, `make seed` — land incrementally; see -[docs/roadmap/phase-01-repository-tooling.md](docs/roadmap/phase-01-repository-tooling.md). -Module-level code references in the docs (e.g. -`LearnStack.Modules.Education.Application`, `ILiveClassProvider`, -`ITenantSearch`) still describe intended shape — the projects are -scaffolded but their domain bodies are empty. +**Phase 01 complete — repository scaffolding, local infrastructure, DX, +and CI baseline. No domain code yet — Phase 02a starts that.** + +What shipped: the .NET 10 solution scaffold under `backend/` (core + 7 +modules × 4 projects + 4 test projects including the non-skippable +`LearnStack.Tests.Architecture`), the `pnpm` frontend monorepo under +`frontend/` (`apps/web` Next.js App Router + `packages/{config,ui,sdk}`), +the full local-dev compose stack at `infra/compose/dev.yml` — PostgreSQL +18, Valkey, SeaweedFS, Mailpit, Meilisearch, Keycloak (two realms), +LiveKit OSS + Coturn, Kafka + kafka-ui, Vault, Dapr sidecar + placement, +APISIX in file-driven standalone mode — and the DX + CI surround +(repo-root `Makefile`, `.env.example` single source of truth, +`.githooks/pre-commit` formatter + Leakwatch, `infra/compose/e2e.yml` +ephemeral overlay, `.github/workflows/ci.yml` with backend + frontend + +meta + secret-scan required checks, `scripts/seed.sh`). + +Every module assembly is empty of domain code today. Module-level +references in the docs (e.g. `LearnStack.Modules.Education.Application`, +`ILiveClassProvider`, `ITenantSearch`) describe **intended** shape that +the corpus anchors against — Phase 02a (Platform Kernel + +Multi-Tenancy) is where those types actually land. Phase 02c (Hub +Foundation, separate `learnstack-hub` repo) runs in parallel. ## Where to start diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5924b56 --- /dev/null +++ b/Makefile @@ -0,0 +1,145 @@ +# LearnStack — repo-root orchestrator. +# +# Run `make help` for the target list. Every recipe runs from the repo root, +# so `${VAR:-default}` interpolation in `infra/compose/dev.yml` reads the +# repo-root `.env` (the developer's copy of `.env.example`). + +SHELL := /usr/bin/env bash +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := help +.ONESHELL: + +# Compose layering — dev.yml is always the base; e2e.yml overlays for the +# end-to-end test suite (Playwright + Testcontainers harness). +COMPOSE_DEV := docker compose -f infra/compose/dev.yml +COMPOSE_E2E := docker compose -f infra/compose/dev.yml -f infra/compose/e2e.yml + +# Colour helpers (no-op when stdout is not a TTY). +ifeq ($(shell test -t 1 && echo 1),1) + CYAN := \033[36m + RESET := \033[0m +else + CYAN := + RESET := +endif + +# ─── Help ───────────────────────────────────────────────────────────────── +.PHONY: help +help: ## Show this help, listing every target and its one-line description. + @printf "LearnStack Makefile — common targets:\n\n" + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z0-9_.-]+:.*?## / {printf " $(CYAN)%-18s$(RESET) %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +# ─── Dev infrastructure ─────────────────────────────────────────────────── +.PHONY: dev +dev: .env ## Bring the local dev stack up (Postgres, Valkey, Keycloak, …). + $(COMPOSE_DEV) up -d + @printf "\n$(CYAN)Stack up.$(RESET) Tail logs with: make logs\n" + +.PHONY: down +down: ## Stop the dev stack (preserves volumes). + $(COMPOSE_DEV) down + +.PHONY: clean +clean: ## Stop the dev stack AND drop named volumes (destructive — wipes data). + $(COMPOSE_DEV) down -v + +.PHONY: logs +logs: ## Tail compose logs (Ctrl+C to detach). + $(COMPOSE_DEV) logs -f --tail=100 + +.PHONY: ps +ps: ## Show service health summary. + $(COMPOSE_DEV) ps + +.PHONY: e2e-up +e2e-up: .env ## Bring the dev stack up with the e2e overlay (tmpfs volumes — ephemeral). + $(COMPOSE_E2E) up -d + @printf "\n$(CYAN)E2E stack up.$(RESET) Data is ephemeral — every restart wipes state.\n" + +.PHONY: e2e-down +e2e-down: ## Stop the e2e overlay (tmpfs volumes evaporate automatically). + $(COMPOSE_E2E) down + +# ─── Build ──────────────────────────────────────────────────────────────── +.PHONY: build +build: build-backend build-frontend ## Build backend + frontend. + +# Multi-line recipes that `cd` into different subdirs MUST wrap each `cd` in +# a subshell (`(cd X && …)`), because `.ONESHELL:` keeps every line of the +# recipe in the SAME shell — without subshells the cwd of line 1 leaks into +# line 2 and the second `cd ` blows up. + +.PHONY: build-backend +build-backend: ## `dotnet build` the solution. + (cd backend && dotnet build LearnStack.slnx --nologo) + +.PHONY: build-frontend +build-frontend: ## `pnpm -r build` the frontend monorepo. + (cd frontend && pnpm -r build) + +# ─── Tests ──────────────────────────────────────────────────────────────── +.PHONY: test +test: test-backend test-frontend ## Run all test suites (backend + frontend). + +.PHONY: test-backend +test-backend: ## `dotnet test` (unit + architecture + contract; integration skipped — see test-integration). + (cd backend && dotnet test LearnStack.slnx \ + --filter "FullyQualifiedName!~LearnStack.Tests.Integration" \ + --nologo) + +.PHONY: test-integration +test-integration: ## Testcontainers-backed integration tests (requires Docker). + (cd backend && dotnet test tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj --nologo) + +.PHONY: test-frontend +test-frontend: ## `pnpm -r test` (Vitest component + lib tests). + (cd frontend && pnpm -r test) + +# ─── Lint / format ──────────────────────────────────────────────────────── +.PHONY: lint +lint: lint-backend lint-frontend ## Run linters (backend dotnet-format check + frontend ESLint). + +.PHONY: lint-backend +lint-backend: ## `dotnet format` verify (no changes — fails on diff). + (cd backend && dotnet format LearnStack.slnx --verify-no-changes --no-restore) + +.PHONY: lint-frontend +lint-frontend: ## `pnpm -r lint` (Next/ESLint). + (cd frontend && pnpm -r lint) + +.PHONY: format +format: ## Apply formatters in place (backend dotnet-format + frontend prettier). + (cd backend && dotnet format LearnStack.slnx --no-restore) + (cd frontend && pnpm -r exec prettier --write .) + +# ─── Typecheck (frontend) ───────────────────────────────────────────────── +.PHONY: typecheck +typecheck: ## `pnpm -r typecheck` (tsc --noEmit across the monorepo). + (cd frontend && pnpm -r typecheck) + +# ─── Seed ───────────────────────────────────────────────────────────────── +.PHONY: seed +seed: dev ## Bring the stack up and seed demo data (idempotent). + ./scripts/seed.sh + +# ─── Bootstrap ──────────────────────────────────────────────────────────── +.PHONY: install +install: .env hooks ## Restore backend NuGet + frontend pnpm deps + activate git hooks. + (cd backend && dotnet restore LearnStack.slnx) + (cd frontend && pnpm install --frozen-lockfile) + +.PHONY: hooks +hooks: ## Activate the repo's pre-commit hook (.githooks/pre-commit). + @git config core.hooksPath .githooks + @printf "$(CYAN)git hooks → .githooks/ (pre-commit: dotnet format + prettier + eslint + leakwatch if available)$(RESET)\n" + +# ─── Env scaffolding ────────────────────────────────────────────────────── +# `.env` is gitignored; this rule copies `.env.example` on first run so the +# developer does not have to remember the step. `cp -n` (no-clobber) is +# portable across macOS + Linux and avoids overwriting an edited `.env`; +# `touch .env` afterward keeps the timestamp ahead of `.env.example` so the +# rule does not re-fire on every invocation after a rebase shifts mtimes. +.env: .env.example + @cp -n .env.example .env + @touch .env + @printf "$(CYAN).env ready (copied from .env.example if missing).$(RESET)\n" diff --git a/README.md b/README.md index f444ff4..2e5148f 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,24 @@ Dedicated control plane, plan editor, custom-domain admin, and license-key issua ## Status -Phase 01 packets 1-6 shipped. The repository now holds the .NET 10 solution -scaffold (7 modules × 4 projects + 4 test projects with `No_Source_Folder_Named_Verticals` -architecture test), the `pnpm` frontend monorepo (`apps/web` + `packages/{config,ui,sdk}`), -and the full local-dev `docker-compose` stack — PostgreSQL 18, Valkey, SeaweedFS, -Mailpit, Meilisearch, Keycloak (two realms), LiveKit OSS + Coturn, Kafka + kafka-ui, -Vault, Dapr sidecar + placement, APISIX (file-driven standalone). The remaining -Phase-01 packets — `make` targets, `.env.example`, pre-commit, `e2e.yml`, GitHub -Actions CI, `make seed` — land in packets 7-8; see [docs/roadmap/phase-01-repository-tooling.md](docs/roadmap/phase-01-repository-tooling.md). +Phase 01 complete. The repository now holds the .NET 10 solution scaffold (7 +modules × 4 projects + 4 test projects with `No_Source_Folder_Named_Verticals` +architecture test), the `pnpm` frontend monorepo (`apps/web` + +`packages/{config,ui,sdk}`), the full local-dev `docker-compose` stack — +PostgreSQL 18, Valkey, SeaweedFS, Mailpit, Meilisearch, Keycloak (two realms), +LiveKit OSS + Coturn, Kafka + kafka-ui, Vault, Dapr sidecar + placement, APISIX +(file-driven standalone) — and the DX + CI surround: repo-root `Makefile`, +`.env.example` single source of truth, `.githooks/pre-commit` formatter, +`infra/compose/e2e.yml` ephemeral overlay, `.github/workflows/ci.yml`, and +`scripts/seed.sh`. See [docs/roadmap/phase-01-repository-tooling.md](docs/roadmap/phase-01-repository-tooling.md) +for the per-packet history. Next: Phase 02a (Platform Kernel + Multi-Tenancy) +and Phase 02c (Hub Foundation, parallel, separate repo). + +```bash +make install # one-time: deps + git hooks +make dev # bring local stack up +make seed # verify health + print demo credentials +``` ## Direction At A Glance diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index e5dab4e..dc58aed 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -8,9 +8,37 @@ true true latest - AllEnabledByDefault + + Recommended false $(NoWarn);CA1014;CS1591 + + direct true $(MSBuildProjectName) $(MSBuildProjectName) @@ -18,6 +46,25 @@ false + + $(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234 diff --git a/backend/src/LearnStack.Api/.editorconfig b/backend/src/LearnStack.Api/.editorconfig new file mode 100644 index 0000000..0e6ed36 --- /dev/null +++ b/backend/src/LearnStack.Api/.editorconfig @@ -0,0 +1,17 @@ +# LearnStack.Api scope override. +# +# Inherits from `backend/.editorconfig`. The single override below scopes +# CA1515 (types should not be public unless an external consumer needs +# them) to silent for `Program.cs` ONLY — the auto-generated `Program` +# type from top-level statements MUST be public so xunit's +# WebApplicationFactory in the test assemblies can resolve it +# (internal + InternalsVisibleTo is documented as unreliable for the +# test runner's discovery path). +# +# Phase 02a wires IExceptionHandler, the MediatR pipeline, +# IErrorTrackingProvider adapters et al. into THIS assembly (per +# ADR-0032 § Composition Root). Keeping the glob narrow to `Program.cs` +# means CA1515 still gates every other type that lands here. + +[Program.cs] +dotnet_diagnostic.CA1515.severity = none diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index af92b57..2beb310 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -25,4 +25,10 @@ app.Run(); +// `public partial class Program` is the top-level-statements escape hatch +// that lets WebApplicationFactory in the test assemblies resolve +// the entry-point type. CA1515 is downgraded to `none` for this project +// in `backend/src/LearnStack.Api/.editorconfig` — the test harness is the +// external consumer and it cannot see `internal` types without an +// InternalsVisibleTo dance that confuses Program-discovery. public partial class Program; diff --git a/backend/src/LearnStack.SharedKernel/Results/Error.cs b/backend/src/LearnStack.SharedKernel/Results/Error.cs index e884e03..7bb6019 100644 --- a/backend/src/LearnStack.SharedKernel/Results/Error.cs +++ b/backend/src/LearnStack.SharedKernel/Results/Error.cs @@ -1,5 +1,21 @@ +using System.Diagnostics.CodeAnalysis; + namespace LearnStack.SharedKernel.Results; +/// +/// Result-pattern error payload used by . +/// +/// +/// CA1716 (avoid reserved language keywords as type names) is intentionally +/// suppressed: the project's Result+Error pattern follows the FluentResults / +/// Ardalis.Result lineage where the type is canonically named Error. +/// LearnStack is C#-only — there is no VB consumer to which the "Error" +/// keyword collision would surface. Per ADR-0032 § Error Model. +/// +[SuppressMessage( + "Naming", + "CA1716:Identifiers should not match keywords", + Justification = "Result+Error pattern — C#-only codebase per ADR-0032; no VB consumer affected.")] public sealed record Error( string Code, string Message, diff --git a/backend/src/LearnStack.SharedKernel/Results/Result.cs b/backend/src/LearnStack.SharedKernel/Results/Result.cs index d856dfe..f64fd94 100644 --- a/backend/src/LearnStack.SharedKernel/Results/Result.cs +++ b/backend/src/LearnStack.SharedKernel/Results/Result.cs @@ -1,5 +1,25 @@ +using System.Diagnostics.CodeAnalysis; + namespace LearnStack.SharedKernel.Results; +/// +/// Result-pattern wrapper. Returned by every MediatR command/query handler +/// per ADR-0032 § Error Model. +/// +/// +/// CA1000 (do not declare static members on generic types) is intentionally +/// suppressed: Result<T>.Ok(value) / Result<T>.Fail(error) +/// are the canonical factory pattern for the Result type across the +/// FluentResults / Ardalis.Result ecosystem. The alternative (non-generic +/// Result.Ok<T>(value) helper) is awkward at call sites +/// because callers must repeat the type argument the inferrer already knows +/// from context. Per ADR-0032 § Error Model — every handler in the codebase +/// uses this shape. +/// +[SuppressMessage( + "Design", + "CA1000:Do not declare static members on generic types", + Justification = "Result+Error factory pattern per ADR-0032 — canonical shape across FluentResults / Ardalis.Result lineage.")] public sealed record Result(bool IsSuccess, T? Value, Error? Error) { public static Result Ok(T value) => new(true, value, null); diff --git a/backend/tests/.editorconfig b/backend/tests/.editorconfig new file mode 100644 index 0000000..b063d0c --- /dev/null +++ b/backend/tests/.editorconfig @@ -0,0 +1,24 @@ +# Test-scope analyzer overrides. +# +# Inherits from `backend/.editorconfig` (file-scoped namespaces, nullable- +# reference-type rules) — this file only DOWNGRADES rules that don't fit +# xunit test code, mirroring the `` block in `backend/Directory.Build.props` +# under `IsTestProject == true`. The MSBuild NoWarn applies to `dotnet build` +# diagnostics, but `dotnet format analyzers --verify-no-changes` invokes +# Roslyn directly and respects `.editorconfig` severity overrides rather +# than the project's NoWarn property. +# +# Real code-quality rules (CA1305 culture-invariant, CA1861 static-readonly +# array) STAY at default severity — fix violations in the code. + +[*.cs] +# xunit `Method_When_Returns` underscore naming is the BDD convention. +dotnet_diagnostic.CA1707.severity = none +# xunit instantiates fixtures via reflection; analyzer cannot see the call. +dotnet_diagnostic.CA1812.severity = none +# Public test classes are required for runner discovery. +dotnet_diagnostic.CA1515.severity = none +# Nested theory-data / fixture types are an accepted xunit pattern. +dotnet_diagnostic.CA1034.severity = none +# `HttpClient.GetAsync(string)` overload is fine in test assertions. +dotnet_diagnostic.CA2234.severity = none diff --git a/backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs b/backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs index ce2cf5c..2ccd6f1 100644 --- a/backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Reflection; using FluentAssertions; using NetArchTest.Rules; @@ -74,7 +75,7 @@ public void ModuleDomain_DoesNotDependOn_AnyApplicationOrInfrastructure(string m foreach (var prefixTemplate in forbiddenPrefixes) { - var prefix = string.Format(prefixTemplate, moduleName); + var prefix = string.Format(CultureInfo.InvariantCulture, prefixTemplate, moduleName); var result = Types.InAssembly(domainAssembly) .Should() diff --git a/backend/tests/LearnStack.Tests.Architecture/RepositoryLayoutTests.cs b/backend/tests/LearnStack.Tests.Architecture/RepositoryLayoutTests.cs index 78b5802..6922316 100644 --- a/backend/tests/LearnStack.Tests.Architecture/RepositoryLayoutTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/RepositoryLayoutTests.cs @@ -9,6 +9,10 @@ namespace LearnStack.Tests.Architecture; /// public sealed class RepositoryLayoutTests { + /// Cached `["web"]` so the `BeEquivalentTo` call below does not + /// allocate a fresh array on every test invocation (CA1861). + private static readonly string[] AllowedFrontendApps = ["web"]; + /// /// ADR-0018: domain-specific shapes live as tenant customization data, not code. /// A `Verticals/` source folder at any level under `backend/src` is forbidden. @@ -52,7 +56,7 @@ public void Frontend_Has_Only_The_Web_App() .ToArray(); appNames.Should().BeEquivalentTo( - new[] { "web" }, + AllowedFrontendApps, "ADR-0009 keeps the tenant-facing frontend as one Next.js app. " + "Add a new ADR before splitting (studio / portal extraction is mechanical, " + "but the decision must be recorded)."); diff --git a/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs b/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs new file mode 100644 index 0000000..c8e8d74 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Hosting; + +namespace LearnStack.Tests.Contract; + +/// +/// WebApplicationFactory inherits ASPNETCORE_ENVIRONMENT from the +/// test host process, which defaults to Production under +/// dotnet test (launchSettings.json is only read by +/// dotnet run). Program.cs gates MapOpenApi() on +/// IsDevelopment(), so without this override the endpoint would +/// 404 in CI. +/// +public sealed class DevelopmentWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment(Environments.Development); + } +} diff --git a/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.cs b/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.cs index 909581f..821282e 100644 --- a/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.cs +++ b/backend/tests/LearnStack.Tests.Contract/OpenApiContractTests.cs @@ -1,17 +1,16 @@ using System.Net; using FluentAssertions; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Hosting; using Xunit; namespace LearnStack.Tests.Contract; -public sealed class OpenApiContractTests : IClassFixture +public sealed class OpenApiContractTests : IClassFixture { - private readonly Factory _factory; + private static readonly Uri OpenApiDocumentPath = new("/openapi/v1.json", UriKind.Relative); - public OpenApiContractTests(Factory factory) + private readonly DevelopmentWebApplicationFactory _factory; + + public OpenApiContractTests(DevelopmentWebApplicationFactory factory) { _factory = factory; } @@ -21,23 +20,8 @@ public async Task OpenApi_WhenRequested_ReturnsDocument() { using var client = _factory.CreateClient(); - var response = await client.GetAsync("/openapi/v1.json"); + var response = await client.GetAsync(OpenApiDocumentPath); response.StatusCode.Should().Be(HttpStatusCode.OK); } - - /// - /// WebApplicationFactory inherits `ASPNETCORE_ENVIRONMENT` from the test - /// host process, which defaults to `Production` under `dotnet test` - /// (launchSettings.json is only read by `dotnet run`). `Program.cs` gates - /// `MapOpenApi()` on `IsDevelopment()`, so without this override the - /// endpoint would 404 in CI. - /// - public sealed class Factory : WebApplicationFactory - { - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment(Environments.Development); - } - } } diff --git a/backend/tests/LearnStack.Tests.Integration/SmokeTests.cs b/backend/tests/LearnStack.Tests.Integration/SmokeTests.cs index 609d186..e53dc60 100644 --- a/backend/tests/LearnStack.Tests.Integration/SmokeTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/SmokeTests.cs @@ -7,6 +7,8 @@ namespace LearnStack.Tests.Integration; public sealed class SmokeTests : IClassFixture> { + private static readonly Uri HealthzPath = new("/healthz", UriKind.Relative); + private readonly WebApplicationFactory _factory; public SmokeTests(WebApplicationFactory factory) @@ -19,7 +21,7 @@ public async Task Healthz_WhenCalled_ReturnsOk() { using var client = _factory.CreateClient(); - var response = await client.GetAsync("/healthz"); + var response = await client.GetAsync(HealthzPath); response.StatusCode.Should().Be(HttpStatusCode.OK); } diff --git a/docs/roadmap/phase-01-repository-tooling.md b/docs/roadmap/phase-01-repository-tooling.md index 0d07985..6a7d20c 100644 --- a/docs/roadmap/phase-01-repository-tooling.md +++ b/docs/roadmap/phase-01-repository-tooling.md @@ -1,7 +1,8 @@ # Phase 01: Repository, Tooling, and Local Infrastructure -> **In-progress status (2026-05-19).** The phase is implemented incrementally in -> packets. Packets 1-3 are shipped; packets 4-8 remain. +> **Status (2026-05-20).** Phase 01 complete. All eight packets shipped. +> The phase was implemented incrementally — each packet is independently +> reviewable in its own commit. > > **Packet 1 — Backend skeleton ✅** > .NET 10 solution scaffold, central package management, `LearnStack.slnx`, 7 core @@ -48,16 +49,42 @@ > `/api/internal/*` Phase-02c surface is documented as an SSL-object + > ip-restriction stub (mTLS in APISIX is not a route-level plugin). > -> **Packet 7 — Developer experience (pending)** -> `Makefile` (`make dev` / `test` / `lint` / `seed`), `.env.example` per app, -> pre-commit hook (dotnet-format + prettier), `infra/compose/e2e.yml` -> companion stack, optional `learnstack-hub` compose overlay. +> **Packet 7 — Developer experience ✅** +> Repo-root `Makefile` (`make dev` / `down` / `clean` / `logs` / `ps` / +> `e2e-up` / `e2e-down` / `build` / `test` / `lint` / `format` / `typecheck` +> / `seed` / `install` / `hooks`). `.env.example` at the repo root is the +> single source of truth for dev credentials; `infra/compose/dev.yml` reads +> via `${VAR:-default}` interpolation, and the Dapr Vault secret-store +> component resolves `vaultToken` via Dapr's `secretKeyRef` indirection +> against the local-env secret store (`secretstore-envvar.yaml`, +> `auth.secretStore: envvar-secrets`) so the prior two-file token +> duplication is closed. `.githooks/pre-commit` runs `dotnet format` + +> prettier + ESLint --fix + (when installed) `leakwatch scan fs ` +> on staged files (activated by `make install`; install instructions in +> [.github/CONTRIBUTING.md](../../.github/CONTRIBUTING.md)). +> `infra/compose/e2e.yml` +> overlay swaps named volumes for tmpfs for ephemeral e2e runs. The +> `learnstack-hub` compose overlay is **owned by the separate +> `learnstack-hub` repo's Phase 02c** per +> [ADR-0019](../decisions/0019-learnstack-hub.md); it never lives here. > -> **Packet 8 — CI baseline + seed (pending)** -> GitHub Actions workflow (backend build + unit + arch + contract + -> Testcontainers integration; frontend install + typecheck + build + lint + -> component; OpenAPI breaking-change check; Lighthouse budget), `make seed` -> with two demo tenants + platform admin, required status checks on `main`. +> **Packet 8 — CI baseline + seed ✅** +> `.github/workflows/ci.yml` with four required jobs — backend (build + +> dotnet format verify + unit + architecture + contract), frontend +> (typecheck + lint + build + Vitest), meta (broken-link sweep over +> changed Markdown + `docs/analysis/` residual scan), and secret-scan +> ([Leakwatch](https://github.com/cemililik/Leakwatch) v1.5.0 per +> Standards 12 § Secrets Management — MIT, verifier-equipped, hybrid Aho-Corasick +> + regex + entropy; configured via `.leakwatch.yaml` + `.leakwatchignore`). Three +> scaffolded-but-deferred jobs (`if: false`) wait for their owning phase: +> integration tests (02a), OpenAPI diff (03), Lighthouse budget (04). +> `scripts/seed.sh` verifies compose health + Keycloak realm readiness +> and prints the demo credentials; the application-level tenant seeding +> (two demo tenants + platform admin) is documented as a one-edit +> drop-in for Phase 02a when +> the Tenancy module's DbContext lands. Branch-protection rules +> (required-check names, approval count, signed-commits posture) live in +> `.github/CONTRIBUTING.md` so GitHub Settings matches the corpus. ## Goal @@ -174,8 +201,11 @@ Docker Compose under `infra/compose/`: - **Dapr sidecar** + placement service. - **APISIX** (file-driven standalone `data_plane` mode per ADR-0015 — no etcd, no Admin API, no dashboard companion). - Optional Jaeger or Tempo (for trace inspection). -- Optional `learnstack-hub` compose overlay for local Hub development (depends on - the same Keycloak / Postgres / Kafka / Vault / APISIX stack). +- Optional **external** `learnstack-hub` compose overlay (maintained in the + separate `learnstack-hub` repository per + [ADR-0019](../decisions/0019-learnstack-hub.md)) for local Hub development — + depends on the same Keycloak / Postgres / Kafka / Vault / APISIX stack but + never ships in this repo. Two compose files: @@ -194,10 +224,16 @@ Two compose files: - GitHub Actions workflow. - Backend build and unit + architecture + contract tests. -- Integration tests with Testcontainers PostgreSQL. +- Integration tests with Testcontainers PostgreSQL — *scaffolded as + `if: false` placeholder; activates in Phase 02a when the first + integration test lands (see Status note above).* - Frontend install, typecheck, build, lint, component tests. -- OpenAPI breaking-change check. -- Lighthouse budget check on representative public pages. +- OpenAPI breaking-change check — *scaffolded as `if: false` placeholder; + activates in Phase 03 when the first real `/api/v1/*` endpoint replaces + the `/healthz` placeholder.* +- Lighthouse budget check on representative public pages — *scaffolded as + `if: false` placeholder; activates in Phase 04 when the first content- + bearing public page ships.* - Required status checks on `main`. ## Deliverables diff --git a/docs/standards/12-infrastructure.md b/docs/standards/12-infrastructure.md index c002d21..7d39874 100644 --- a/docs/standards/12-infrastructure.md +++ b/docs/standards/12-infrastructure.md @@ -88,7 +88,14 @@ otel-collector # Phase 11 (Production hardening — observability stack ## Image Conventions -- Base images pinned by digest. +- **Production images pinned by digest** (`image: registry/foo@sha256:…`) + so a re-pushed tag cannot ship under us. +- **Dev compose images pinned by explicit version tag** (`image: registry/foo:1.2.3`, + never `:latest`). Dev-side digest pinning is operationally heavy + (every minor bump requires `docker pull && docker inspect`); the + re-push risk for the official images we use is vanishingly low. The + tag-pin policy is documented in `infra/compose/dev.yml` and enforced + by code review. - Non-root user. - Read-only filesystem where feasible. - Drop unneeded Linux capabilities. diff --git a/frontend/apps/web/.env.local.example b/frontend/apps/web/.env.local.example new file mode 100644 index 0000000..94fba33 --- /dev/null +++ b/frontend/apps/web/.env.local.example @@ -0,0 +1,23 @@ +# LearnStack — Next.js apps/web local environment. +# +# Copy to `.env.local` in this directory (NOT committed). Next.js reads +# `.env.local` from the **app directory**, not the repo root, so this is a +# narrow mirror of the values from the repo-root `.env.example` that the +# browser / SSR layer needs. +# +# Variables prefixed `NEXT_PUBLIC_` are inlined into the client bundle at +# build time — never put a secret behind that prefix. Server-only values +# (DB creds, service-account tokens) live without the prefix and are only +# readable inside Server Components / Route Handlers / Middleware. + +# Tenant-facing gateway (APISIX in dev). Production routes the same URL +# through the per-tenant custom-domain layer (ADR-0022). +NEXT_PUBLIC_API_BASE_URL=http://localhost:9080 + +# Keycloak OIDC discovery (Phase 02b wires the actual NextAuth / Auth.js +# integration; the var name is reserved now so Phase 02b only edits one file). +NEXT_PUBLIC_OIDC_ISSUER=http://localhost:8080/realms/learnstack +NEXT_PUBLIC_OIDC_CLIENT_ID=learnstack-web + +# LiveKit signaling (Phase 08c wires the actual classroom client). +NEXT_PUBLIC_LIVEKIT_WS_URL=ws://localhost:7880 diff --git a/frontend/apps/web/package.json b/frontend/apps/web/package.json index 10fc52a..d86e897 100644 --- a/frontend/apps/web/package.json +++ b/frontend/apps/web/package.json @@ -10,7 +10,7 @@ "start": "next start --port 3000", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run --passWithNoTests" }, "dependencies": { "@learnstack/sdk": "workspace:*", diff --git a/frontend/packages/config/tsconfig/base.json b/frontend/packages/config/tsconfig/base.json index d11d1cc..ae7e3f6 100644 --- a/frontend/packages/config/tsconfig/base.json +++ b/frontend/packages/config/tsconfig/base.json @@ -1,3 +1,24 @@ { - "extends": "../../../tsconfig.base.json" + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "jsx": "preserve", + "incremental": true + } } diff --git a/frontend/tsconfig.base.json b/frontend/tsconfig.base.json deleted file mode 100644 index ae7e3f6..0000000 --- a/frontend/tsconfig.base.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "resolveJsonModule": true, - "isolatedModules": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "skipLibCheck": true, - "verbatimModuleSyntax": true, - "jsx": "preserve", - "incremental": true - } -} diff --git a/infra/compose/README.md b/infra/compose/README.md index 2965e44..629d7e5 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -6,8 +6,12 @@ Compose stacks for local development. Operational rules live in ## `dev.yml` Services in order they appear in `dev.yml` (data plane → identity → media → -eventing → secrets → Dapr sidecar → gateway). Packets 1-6 shipped; packets -7-8 (DX orchestrator + CI) remain. +eventing → secrets → Dapr sidecar → gateway). + +Bring it up with `make dev` from the repo root (the orchestrator copies +`.env.example` → `.env` on first run, so every `${VAR:-default}` reference +in `dev.yml` resolves against the developer's copy). The end-to-end overlay +adds tmpfs volumes for ephemeral test runs — see `e2e.yml` below. ### Data plane (Phase 01 packet 3) @@ -95,12 +99,38 @@ reach the workstation-local `dotnet run` process. The `dev.yml` so Linux developers don't need a manual override. ```bash +# Repo-root orchestrator (preferred): +make dev # bring stack up +make ps # confirm healthchecks pass +make down # stop, keep volumes +make clean # stop, wipe local data + +# Raw compose (equivalent — useful when `make` is unavailable): docker compose -f infra/compose/dev.yml up -d -docker compose -f infra/compose/dev.yml ps # confirm healthchecks pass -docker compose -f infra/compose/dev.yml down # stop, keep volumes -docker compose -f infra/compose/dev.yml down -v # stop, wipe local data +docker compose -f infra/compose/dev.yml ps +docker compose -f infra/compose/dev.yml down +docker compose -f infra/compose/dev.yml down -v +``` + +## `e2e.yml` — end-to-end overlay + +Layered on top of `dev.yml` to swap durable named volumes for tmpfs, so +every run starts from a clean Postgres / SeaweedFS / Meilisearch / Kafka. +Images, ports, and credentials are identical to dev — only the +*operational posture* (data persistence + Mailpit retention) changes. + +```bash +make e2e-up # tmpfs-backed stack up +make e2e-down # stop; tmpfs evaporates + +# Raw equivalent: +docker compose -f infra/compose/dev.yml -f infra/compose/e2e.yml up -d ``` +Phase 06 Playwright + Phase 07 SDK contract tests run against this overlay +in CI; the Playwright project itself lives in `frontend/apps/web/e2e/` and +arrives in its owning phase. + ### Dev credentials are dev credentials The shared credentials above are checked into the repo intentionally — they are @@ -123,11 +153,16 @@ specific dev access surface (filer UI, S3 identity config, re-seed). ## What this file deliberately does NOT bring up yet -Per the [Phase 01 plan](../../docs/roadmap/phase-01-repository-tooling.md), -later packets land: - -- `Makefile` (`make dev` / `test` / `lint` / `seed`), `.env.example` per app, - pre-commit hook (dotnet-format + prettier), and `infra/compose/e2e.yml` - companion stack — Phase 01 packet 7. -- GitHub Actions CI workflow + `make seed` populating two demo tenants and a - platform admin — Phase 01 packet 8. +Phase 01 is complete; the remaining deferrals belong to later phases and +NOT to this compose stack: + +- The .NET API host (`LearnStack.Api`) runs **outside** the compose network + via `dotnet run` on the developer's workstation. Moving it inside compose + is a Phase 11 (production hardening) decision — the dapr-sidecar-api + service is already pointed at `host.docker.internal:5080` so the swap + is a one-line `upstream` change. +- `livekit-egress` (recording / consent) — Phase 08c. +- OpenTelemetry Collector — Phase 11. +- Application-level tenant seeding via `LearnStack.Tools.Seeder` — + Phase 02a (the `scripts/seed.sh` orchestrator stubs the activation point). +- Production-grade Vault (HA + auto-unseal + AppRole) — Phase 11. diff --git a/infra/compose/dev.yml b/infra/compose/dev.yml index 8b556b7..46531c4 100644 --- a/infra/compose/dev.yml +++ b/infra/compose/dev.yml @@ -6,8 +6,13 @@ # Kafka (KRaft) + kafka-ui, HashiCorp Vault (-dev mode), the Dapr sidecar + # placement (pub/sub + state + secrets building blocks per ADR-0014), and the # APISIX gateway in file-driven standalone mode per ADR-0015. The DX -# orchestrator (`make` targets, `.env.example`, e2e overlay) and CI workflow -# arrive in Phase-01 packets 7-8. +# orchestrator (`make` targets) and CI workflow arrive in Phase-01 packets 7-8. +# +# Env vars (POSTGRES_*, KEYCLOAK_*, VAULT_ROOT_TOKEN, …) come from the repo- +# root `.env` file (template: `.env.example`). Compose auto-loads it because +# the `make dev` target runs from the repo root; every `${VAR:-default}` +# reference below falls back to a dev-safe literal when `.env` is missing, +# so `docker compose -f infra/compose/dev.yml up` works even without `make`. # # Operational rules: Standards 12 § Local Infrastructure + Standards 20. # Tenant isolation in SeaweedFS S3 is enforced by key prefix @@ -31,9 +36,11 @@ services: container_name: learnstack-postgres restart: unless-stopped environment: - POSTGRES_USER: learnstack - POSTGRES_PASSWORD: learnstack - POSTGRES_DB: learnstack + # Defaults match `.env.example`. Compose's `${VAR:-default}` form keeps + # `dev.yml` self-bootstrapping even when no `.env` file is present. + POSTGRES_USER: ${POSTGRES_USER:-learnstack} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-learnstack} + POSTGRES_DB: ${POSTGRES_DB:-learnstack} # Dev-only. Production secrets come from Vault via ISecretProvider. ports: - "5432:5432" @@ -54,7 +61,7 @@ services: # the `postgres-init/*.sql` scripts to complete before pg_isready # starts counting failures against the retry budget. Re-boots # (data already in the volume) skip this — Postgres is ready in <2s. - test: ["CMD-SHELL", "pg_isready -U learnstack -d learnstack"] + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-learnstack} -d $${POSTGRES_DB:-learnstack}"] interval: 5s timeout: 5s retries: 10 @@ -186,13 +193,14 @@ services: restart: unless-stopped command: ["start-dev", "--import-realm"] environment: - # Dev-only. Production credentials come from Vault via ISecretProvider. - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: admin-dev-secret + # Defaults match `.env.example`. Production credentials come from Vault + # via ISecretProvider — `.env.example` is the local-dev parity only. + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin-dev-secret} KC_DB: postgres KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak - KC_DB_USERNAME: learnstack - KC_DB_PASSWORD: learnstack + KC_DB_USERNAME: ${POSTGRES_USER:-learnstack} + KC_DB_PASSWORD: ${POSTGRES_PASSWORD:-learnstack} KC_HOSTNAME_STRICT: "false" KC_HTTP_ENABLED: "true" KC_HEALTH_ENABLED: "true" @@ -288,8 +296,10 @@ services: # connect through the port mapping below but will receive `kafka:9092` as # the bootstrap address and fail to resolve it unless the developer adds # `127.0.0.1 kafka` to /etc/hosts (or routes them through the kafka-ui). - # Phase 07 (DX) ships either an explicit EXTERNAL listener or a - # documented kafka-ui-only workflow. + # Phase 01 packet 7 (DX) shipped the kafka-ui-only workflow as canonical + # (see infra/compose/README.md § Eventing); an EXTERNAL listener for + # workstation-side kcat / kafka-topics access is a Phase 11 production- + # hardening item, not in scope here. kafka: image: confluentinc/cp-kafka:8.2.1 container_name: learnstack-kafka @@ -359,19 +369,24 @@ services: # auto-unseal + AppRole / Kubernetes auth methods (Standards 12 § Secrets # Management). The root token is intentionally embedded here for dev only. # - # IMPORTANT: the literal `learnstack-dev-root-token` appears in TWO files: - # this service definition AND ../dapr/components/secretstore-vault.yaml. - # If you change one you MUST change the other. Phase 07 (DX) wires both - # to a single `.env.example` source so the duplication goes away. + # Single source of truth: `VAULT_ROOT_TOKEN` from `.env.example`. This + # service boots Vault `-dev` with the token via the `-dev-root-token-id` + # flag below; the Dapr Vault secret-store component + # (`infra/dapr/components/secretstore-vault.yaml`) reads the same env var + # through Dapr's `secretKeyRef` indirection against the local-env secret + # store (`secretstore-envvar.yaml`, `auth.secretStore: envvar-secrets`). + # Changing `VAULT_ROOT_TOKEN` in `.env` therefore propagates to both + # endpoints at the next `docker compose up`. See `infra/dapr/README.md` + # § Vault token for the full chain (Phase 01 packet 7 DX commitment). vault: image: hashicorp/vault:1.21.4 container_name: learnstack-vault restart: unless-stopped - command: ["server", "-dev", "-dev-root-token-id=learnstack-dev-root-token", "-dev-listen-address=0.0.0.0:8200"] + command: ["server", "-dev", "-dev-root-token-id=${VAULT_ROOT_TOKEN:-learnstack-dev-root-token}", "-dev-listen-address=0.0.0.0:8200"] environment: # Dev-only. Production loads tokens via Dapr `secretKeyRef` indirection # so the literal never appears in component YAML. - VAULT_DEV_ROOT_TOKEN_ID: learnstack-dev-root-token + VAULT_DEV_ROOT_TOKEN_ID: ${VAULT_ROOT_TOKEN:-learnstack-dev-root-token} VAULT_ADDR: http://0.0.0.0:8200 cap_add: - IPC_LOCK @@ -426,6 +441,13 @@ services: - /config/dapr-config.yaml - -log-level - info + environment: + # `secretstore-vault.yaml` resolves `vaultToken` via `secretKeyRef` + # against the local-env secret store (`secretstore-envvar.yaml`); + # passing the env var here is what makes that lookup find the token + # in daprd's process env, completing the single-source-of-truth + # chain from `.env.example` (Phase 01 packet 7 DX). + VAULT_ROOT_TOKEN: ${VAULT_ROOT_TOKEN:-learnstack-dev-root-token} volumes: - ../dapr/components:/components:ro - ../dapr/config:/config:ro diff --git a/infra/compose/e2e.yml b/infra/compose/e2e.yml new file mode 100644 index 0000000..40af506 --- /dev/null +++ b/infra/compose/e2e.yml @@ -0,0 +1,68 @@ +# LearnStack — end-to-end test compose overlay. +# +# Layered on top of `dev.yml`: +# docker compose -f infra/compose/dev.yml -f infra/compose/e2e.yml up +# (the canonical `make` target is `make COMPOSE=$(COMPOSE_E2E) ` — +# see the repo-root `Makefile`). +# +# Why an overlay instead of a separate file: the service definitions, ports, +# images, and credentials are identical to dev — only the *operational +# posture* changes. Keeping them as overlay tweaks means a Phase 11 image +# bump in `dev.yml` flows into e2e without a second edit. +# +# Phase 01 scope: declare the overlay shape so Phase 06+ Playwright / +# Phase 07 SDK contract tests have a stable target. The Playwright project, +# the SDK harness, and the seeded e2e tenant land in their own phases — +# this file is the orchestration slot, not the tests themselves. + +name: learnstack-e2e + +services: + # ─── Postgres — wipe between runs ─────────────────────────────────────── + # E2E suites must start from a clean schema every run. The `tmpfs` mount + # replaces the named `postgres-data` volume from dev so each `docker + # compose up` boots a fresh DB (init scripts re-run, no stale state from + # the prior run). The trade-off — losing data on container restart — is + # exactly what e2e wants. + postgres: + tmpfs: + - /var/lib/postgresql + # `tmpfs:` and `volumes:` are mutually exclusive for the same target; + # overlay null'ing the dev `volumes` list keeps Compose from merging + # the parent's named volume back in. + volumes: !reset [] + + # ─── Mailpit — clear inbox between runs ───────────────────────────────── + # Mailpit stores received messages in memory by default (no persistence), + # so it already wipes on restart — but the dev compose tunes retention + # to multi-day for the developer inbox use case. Override here. + mailpit: + environment: + MP_MAX_MESSAGES: "500" + MP_DATA_FILE: "" # in-memory only + MP_SMTP_AUTH_ACCEPT_ANY: "true" + MP_SMTP_AUTH_ALLOW_INSECURE: "true" + + # ─── SeaweedFS — ephemeral object storage ─────────────────────────────── + # Per Standards 12 § Object Storage Operations, e2e tenant isolation tests + # need an empty bucket prefix per test run. tmpfs makes that automatic. + seaweedfs: + tmpfs: + - /data + volumes: !reset [] + + # ─── Meilisearch — ephemeral search index ─────────────────────────────── + meilisearch: + tmpfs: + - /meili_data + volumes: !reset [] + + # ─── Kafka — fresh broker per run ─────────────────────────────────────── + # KRaft mode persists topic offsets + cluster id in `/var/lib/kafka/data`. + # E2E runs need a clean offset state so each test starts at the beginning + # of the topic; tmpfs accomplishes this without a one-off cluster-id + # regeneration script. + kafka: + tmpfs: + - /var/lib/kafka/data + volumes: !reset [] diff --git a/infra/dapr/README.md b/infra/dapr/README.md index 81506da..6787b51 100644 --- a/infra/dapr/README.md +++ b/infra/dapr/README.md @@ -101,18 +101,29 @@ All dev-only. Production wires Vault with AppRole / Kubernetes auth and loads the token through Dapr's `secretKeyRef` indirection so the literal token never appears in the component YAML. -### Vault token duplication - -The literal `learnstack-dev-root-token` appears in **two files**: - -- `infra/compose/dev.yml` — `vault` service command + env var (the token - Vault `-dev` mode boots with). -- `infra/dapr/components/secretstore-vault.yaml` — `vaultToken` metadata - (the token Dapr authenticates to Vault with). - -These MUST stay in lockstep. Phase 07 (DX) wires both to a single -`.env.example` source so the duplication goes away; until then, change -both places together. +### Vault token — single source of truth + +As of Phase 01 packet 7 (DX), the Vault root token lives in **one** place: +`VAULT_ROOT_TOKEN` in the repo-root `.env.example` (copied to `.env` per +workstation). The chain uses Dapr's `secretKeyRef` + local-env-secret-store +indirection (a `{{env.VAR}}` template would be silently substituted with the +literal — Dapr does not support that syntax in component metadata): + +1. `.env` — developer's actual value (gitignored). +2. `infra/compose/dev.yml` — both the `vault` service (boots `-dev` mode + with that token) and the `dapr-sidecar-api` service (passes it to + daprd's process env) read `${VAULT_ROOT_TOKEN:-learnstack-dev-root-token}`. +3. `infra/dapr/components/secretstore-envvar.yaml` registers a + `secretstores.local.env` component named `envvar-secrets` (loaded first + by daprd because no other component depends on it). +4. `infra/dapr/components/secretstore-vault.yaml` declares + `auth.secretStore: envvar-secrets` and a `vaultToken` metadata entry + resolved via `secretKeyRef: { name: VAULT_ROOT_TOKEN, key: VAULT_ROOT_TOKEN }`. + Dapr pulls the literal from the process env at component-load time. + +Changing `.env` therefore updates every consumer at the next `docker compose +up`. There is no two-file edit risk and no literal token in any committed +YAML metadata field. ## What does NOT live here diff --git a/infra/dapr/components/secretstore-envvar.yaml b/infra/dapr/components/secretstore-envvar.yaml new file mode 100644 index 0000000..9af93b3 --- /dev/null +++ b/infra/dapr/components/secretstore-envvar.yaml @@ -0,0 +1,20 @@ +# Dapr local environment-variable secret store. +# +# Lightweight indirection layer so other components can reference process +# env vars via `secretKeyRef` instead of carrying literal values in their +# metadata. The `{{env.VAR}}` template syntax some Dapr docs reference is +# NOT a stock Dapr feature — components substitute env vars only through +# this `secretstores.local.env` pattern (Dapr 1.0+) or through the +# `auth.secretStore` indirection. +# +# Dev-only. Production swaps `secretstores.hashicorp.vault` (with AppRole) +# in front of every component, so this env-secret-store does not ship +# outside `DeploymentMode == Development`. + +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: envvar-secrets +spec: + type: secretstores.local.env + version: v1 diff --git a/infra/dapr/components/secretstore-vault.yaml b/infra/dapr/components/secretstore-vault.yaml index c72489a..54af132 100644 --- a/infra/dapr/components/secretstore-vault.yaml +++ b/infra/dapr/components/secretstore-vault.yaml @@ -1,19 +1,31 @@ # Dapr secret store — HashiCorp Vault backend. -# Backs `ISecretProvider`. Dev compose runs Vault in -dev mode with the -# root token baked in; production replaces this with an authenticated -# Vault cluster + AppRole / Kubernetes auth method. +# Backs `ISecretProvider`. Dev compose runs Vault in -dev mode; production +# replaces this with an authenticated Vault cluster + AppRole / Kubernetes +# auth method. # -# Dev-only token. NEVER deploy a config carrying `vaultToken` literally — -# production wires the auth method via Vault's Dapr component metadata -# patterns documented at https://docs.dapr.io/. +# Single source of truth: `VAULT_ROOT_TOKEN` from the repo-root `.env.example`. +# Indirection chain (Dapr 1.x — verified syntax): +# 1. `.env` carries the token (gitignored). +# 2. `infra/compose/dev.yml` boots Vault with that token AND passes it +# into `dapr-sidecar-api` as a process env var (`VAULT_ROOT_TOKEN`). +# 3. `secretstore-envvar.yaml` registers a `secretstores.local.env` +# component named `envvar-secrets`. +# 4. THIS component's `auth.secretStore: envvar-secrets` makes Dapr +# resolve the `vaultToken` metadata via `secretKeyRef` — pulling the +# token from the process env at load time, never from the literal. +# Changing `.env` therefore updates every consumer at the next `docker +# compose up`. There is no two-file edit risk. # # KV ENGINE NOTE (Phase 02b): Vault -dev mounts `secret/` as KV v2; this -# component reads from that mount with `vaultKVPrefix: secret`. Some Dapr -# Vault-component versions need an explicit `engineType: kv-v2` metadata -# entry to detect v2's `/data/` indirection. Validate `GET -# /v1.0/secrets/secretstore/` against a known dev secret before the -# first ISecretProvider integration test in Phase 02b; if it returns 404 -# while the secret exists, add `engineType: kv-v2` here. +# component reads with `vaultKVPrefix: secret`. Some Dapr Vault-component +# versions need an explicit `engineType: kv-v2` metadata entry to detect +# v2's `/data/` indirection. Validate `GET /v1.0/secrets/secretstore/` +# before the first ISecretProvider integration test in Phase 02b; if it +# returns 404 while the secret exists, add `engineType: kv-v2` here. +# +# PRODUCTION NOTE: Never deploy a config carrying `vaultToken` literally +# in production — wire the auth method via AppRole / Kubernetes per Dapr's +# documented patterns at https://docs.dapr.io/. apiVersion: dapr.io/v1alpha1 kind: Component @@ -26,6 +38,10 @@ spec: - name: vaultAddr value: http://vault:8200 - name: vaultToken - value: learnstack-dev-root-token + secretKeyRef: + name: VAULT_ROOT_TOKEN + key: VAULT_ROOT_TOKEN - name: vaultKVPrefix value: secret +auth: + secretStore: envvar-secrets diff --git a/scripts/seed.sh b/scripts/seed.sh new file mode 100755 index 0000000..4c4fcb8 --- /dev/null +++ b/scripts/seed.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# LearnStack — local dev seed. +# +# Invoked by `make seed`. Idempotent: runs end-to-end every time, no +# destructive operations. +# +# Phase 01 scope (this file): verify the compose stack is healthy, confirm +# the two Keycloak realms are imported (`learnstack` + `learnstack-hub`), +# print a session summary with the demo credentials. +# +# Phase 02a scope (NOT YET WIRED): provision two application-level demo +# tenants + one platform-admin user via the `LearnStack.Tools.Seeder` +# console project against the real Tenancy module schema. The placeholder +# section at the bottom of this file lists the exact commands Phase 02a +# will swap the deferral notice for — leave it intact so the activation +# is a one-shot find-and-replace. + +set -eu -o pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +COMPOSE_FILE="infra/compose/dev.yml" +KEYCLOAK_REALM_TENANT="learnstack" +KEYCLOAK_REALM_HUB="learnstack-hub" +KEYCLOAK_URL="http://localhost:8080" +HEALTH_TIMEOUT_SECONDS=180 # Keycloak first boot + realm import can take ~90s on a cold cache. + +cyan() { printf "\033[36m%s\033[0m\n" "$*"; } +green() { printf "\033[32m%s\033[0m\n" "$*"; } +red() { printf "\033[31m%s\033[0m\n" "$*" >&2; } + +# ─── Step 1: compose health ────────────────────────────────────────────── +# `make seed` declares `: dev` as a prereq, so compose `up -d` has just +# returned and most services are in `starting` state. Poll until every +# service reports `healthy` or until the per-step timeout expires; only +# the "literally no services running" case is an immediate error (means +# the developer ran seed.sh directly without `make dev`). +cyan "▶ Step 1/3: wait for compose services to be healthy" + +# Distinguish "nothing running at all" from "still starting". +running=$(docker compose -f "$COMPOSE_FILE" ps --status running --quiet 2>/dev/null | wc -l | tr -d ' ') +if [[ "$running" == "0" ]]; then + red "No compose services running. Run \`make dev\` first." + exit 1 +fi + +elapsed=0 +while true; do + # Capture BOTH .State and .Health so we can distinguish: + # - service running + healthcheck reports `healthy` → ok + # - service running + healthcheck still `starting` → wait + # - service running + NO healthcheck defined → flag (every + # service in dev.yml carries one per Standards 12 § Local Infra; + # an empty Health column means a future regression) + # - service not running (exited, dead, restarting) → flag + not_healthy=$(docker compose -f "$COMPOSE_FILE" \ + ps --format '{{.Name}}\t{{.State}}\t{{.Health}}' \ + | awk -F'\t' ' + $2 != "running" { print $1 " (state=" $2 ")"; next } + $3 == "" { print $1 " (no healthcheck)"; next } + $3 != "healthy" { print $1 " (health=" $3 ")"; next } + ') + [[ -z "$not_healthy" ]] && break + if (( elapsed >= HEALTH_TIMEOUT_SECONDS )); then + red "Services still not healthy after ${HEALTH_TIMEOUT_SECONDS}s — inspect with:" + red " docker compose -f $COMPOSE_FILE ps" + red " docker compose -f $COMPOSE_FILE logs --tail=200" + red "Still pending:" + while IFS= read -r line; do red " - $line"; done <<<"$not_healthy" + exit 1 + fi + sleep 3 + elapsed=$(( elapsed + 3 )) +done +green " ✓ All compose services running + healthcheck-green." + +# ─── Step 2: Keycloak realm verification ───────────────────────────────── +# Realm import happens during Keycloak's first boot — even after the +# `keycloak` container reports healthy, the OIDC discovery endpoint can +# take a few more seconds to surface each realm. Both realms get the same +# bounded retry loop. +cyan "▶ Step 2/3: verify Keycloak realms imported" + +wait_for_realm() { + local realm="$1" + local elapsed=0 + while ! curl -sf "$KEYCLOAK_URL/realms/$realm/.well-known/openid-configuration" >/dev/null 2>&1; do + if (( elapsed >= HEALTH_TIMEOUT_SECONDS )); then + red "Keycloak realm '$realm' did not surface within ${HEALTH_TIMEOUT_SECONDS}s." + red " Was the realm JSON imported? → infra/keycloak/realms/${realm}.json" + red " Inspect with: docker compose -f $COMPOSE_FILE logs keycloak" + return 1 + fi + sleep 3 + elapsed=$(( elapsed + 3 )) + done + green " ✓ Realm '$realm' OIDC discovery responds." +} + +wait_for_realm "$KEYCLOAK_REALM_TENANT" || exit 1 +wait_for_realm "$KEYCLOAK_REALM_HUB" || exit 1 + +# ─── Step 3: Phase 02a deferral notice ─────────────────────────────────── +cyan "▶ Step 3/3: application-level tenant seeding (deferred to Phase 02a)" + +cat <<'NOTICE' + + The platform-level Tenant aggregate + Tenancy module DbContext do not + exist yet (they ship in Phase 02a per docs/roadmap/phase-02a-kernel-tenancy.md). + Phase 01 seeding therefore stops at: + + - Keycloak realms imported (done at compose boot, verified above) + - Demo users present in each realm (seeded by the realm JSON files) + + Phase 02a swaps this section for: + + dotnet run --project backend/src/LearnStack.Tools.Seeder -- \ + --tenants demo-platform,demo-vertical \ + --platform-admin demo-admin@learnstack.test \ + --connection-string "$ConnectionStrings__Default" + + The console project does not exist yet; reserve the path now so the + Phase 02a packet can drop the executable + edit this stub in one PR. + +NOTICE + +cyan "▶ Demo identities ready" +cat <<'IDENTITIES' + + Keycloak admin console: http://localhost:8080 (admin / admin-dev-secret) + + Realm: learnstack + demo-admin@tenant-a.test / demo-dev-secret (tenant-admin) + demo-learner@tenant-a.test / demo-dev-secret (tenant-learner) + + Realm: learnstack-hub + demo-operator@learnstack.test / demo-dev-secret (hub-operator; CONFIGURE_TOTP required-action) + +IDENTITIES + +green "✓ Seed complete (Phase 01 scope)."