Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
6e843a3
feat(infra): Phase 01 packet 7 — DX orchestrator (Makefile + .env + h…
cemililik May 19, 2026
03ddffb
feat(ci): Phase 01 packet 8 — CI baseline + seed orchestrator
cemililik May 19, 2026
e239ecf
fix: address phase-01 packets 7+8 review (2 blockers, 5 majors, 6 min…
cemililik May 20, 2026
e0b7ae7
chore(vscode): declare Compose !reset / !override tags for YAML exten…
cemililik May 20, 2026
a604c10
fix: PR #3 round 5 — 3 CI failures + 11 review comments
cemililik May 20, 2026
f45cb04
feat(ci): swap gitleaks for Leakwatch — project's own scanner
cemililik May 20, 2026
aea18f0
fix: address CI analyzer + vitest violations in scaffold
cemililik May 20, 2026
b69cad6
fix: CA1812 on xunit fixture + leakwatch self-flag + TLS doc PEM blocks
cemililik May 20, 2026
1438f68
fix: suppress CA1716+CA1000 on Result-pattern primitives (per ADR-0032)
cemililik May 20, 2026
f85aacf
fix: revert round-6 over-correction + NoWarn test-inappropriate CAs +…
cemililik May 20, 2026
3a70788
fix: scope CA1515 + test CAs via .editorconfig (dotnet format ignores…
cemililik May 20, 2026
547c3ca
chore(backend): switch AnalysisMode AllEnabledByDefault → Recommended
cemililik May 20, 2026
1a5f245
docs/chore: PR #3 review-2 — 3 Majors (stale narratives) + 7 Minors
cemililik May 20, 2026
c503fd9
fix(ci): link audit handles sibling-relative + repo-relative links
cemililik May 20, 2026
e13aaa2
fix: PR #3 review-3 — seed health-check + pre-commit output + roadmap…
cemililik May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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) ───
168 changes: 168 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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 <staged-file>` 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
105 changes: 105 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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) <noreply@anthropic.com>`
(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 <staged-file>` 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:<detector-id>` 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.
Loading
Loading