From 7b38d1b95da6ec02c65456491ffde9306d92bcb1 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 13 Aug 2026 10:28:26 +0200 Subject: [PATCH 1/4] chore: prepare the repo for public visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five things that either break or mislead once this repo is public: 1. The `staleness` docs gate called a reusable workflow out of a private repo with `secrets: inherit` — a public repo cannot do that, so the job would have failed on every PR touching docs/handbuch/ or src/. Vendored the checker as .github/scripts/docs-staleness.mjs, a port of the canonical SourcesHasher. Verified: it reproduces all four existing sources_hash values (including the `src/permissions/**` glob), fails when a documented source changes, and re-signs with --sign. 2. Issue intake ran on a self-hosted runner. Self-hosted runners on public repos are a standing risk and this job only shells out to `gh issue edit` — moved to ubuntu-latest. 3. No LICENSE and no `license` field: public without one means nobody may legally use it. MIT. 4. Instance hostnames: swapped the arbitrary fixture hosts in tests/, docs/ and examples/ for `mychurch`. Deliberately kept where the hostname is provenance rather than a placeholder — api-coverage.md's live-verification notes, permissions/catalog.json's `capturedFrom`, the dynamic fixtures' README — since those record which instance and CT version a finding came from. 5. Dangling references to private sibling repos, which 404 for every visitor: generalised to "your config repo" / "the consuming Handbuch site". Left the provenance mentions in src/ and tests/ comments alone; rewording those would force a docs re-sign for cosmetic reasons. Added CONTRIBUTING.md and SECURITY.md, and gave the README contributing and license sections. Prettier reports 102 pre-existing unformatted files on main (format:check is not in the CI gate). Left alone — that is a separate cleanup. Verified this branch adds no new ones. Claude-Session: https://claude.ai/code/session_012vnLUFPTuDVZ7DZhpm7sNt --- .github/scripts/docs-staleness.mjs | 178 +++++++++++++++++++ .github/workflows/docs.yml | 32 ++-- .github/workflows/issue-intake.yml | 4 +- .gitignore | 2 +- CONTRIBUTING.md | 56 ++++++ LICENSE | 21 +++ README.md | 21 ++- SECURITY.md | 30 ++++ docs/README.md | 19 +- docs/ci.md | 2 +- docs/environments.md | 6 +- docs/handbuch/index.md | 6 +- docs/handbuch/mkdocs.yml | 9 +- docs/handbuch/requirements.txt | 2 +- docs/runbook-manual-surface.md | 9 +- examples/ct.envs.json | 4 +- package.json | 1 + src/config.ts | 2 +- src/env/envs.ts | 4 +- tests/adopt-grants-command.test.ts | 2 +- tests/adopt-group-command.test.ts | 2 +- tests/adopt.test.ts | 2 +- tests/apply-env-command.test.ts | 4 +- tests/apply-refresh-command.test.ts | 2 +- tests/ctClient.test.ts | 8 +- tests/destroy-command.test.ts | 2 +- tests/destroy-env-command.test.ts | 2 +- tests/env-context.test.ts | 8 +- tests/envs.test.ts | 10 +- tests/permission-adopt.test.ts | 2 +- tests/permission-bootstrap.test.ts | 2 +- tests/permission-pending-domain.test.ts | 2 +- tests/plan-detailed-exitcode-command.test.ts | 2 +- tests/plan-env-command.test.ts | 8 +- tests/plan.test.ts | 2 +- tests/registry.test.ts | 2 +- tests/state.test.ts | 2 +- tests/tokenStore-multihost.test.ts | 4 +- 38 files changed, 396 insertions(+), 80 deletions(-) create mode 100644 .github/scripts/docs-staleness.mjs create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/.github/scripts/docs-staleness.mjs b/.github/scripts/docs-staleness.mjs new file mode 100644 index 0000000..ad45a2d --- /dev/null +++ b/.github/scripts/docs-staleness.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// Staleness gate for docs/handbuch/ — the pages published into the Handbuch's +// "ChurchTools-Grundlagen" section. +// +// Each page declares the code it documents in `sources:` and signs it with +// `sources_hash`. Change a declared source without re-signing the page and this +// fails, so that someone re-reads the page against the new code. Bumping +// `reviewed:` alone does NOT satisfy the gate — that is the entire point. +// +// The signature is a sha256 over each resolved file's `\0\0`, +// files sorted by path, truncated to 16 hex chars. This is a port of +// `App\Services\Docs\SourcesHasher` from the estate-wide checker; it is inlined here +// because that implementation lives in a private repo and a public repo cannot call a +// reusable workflow out of one. Verified to reproduce the hashes the PHP signer wrote. +// +// node .github/scripts/docs-staleness.mjs # check (CI) +// node .github/scripts/docs-staleness.mjs --sign # re-sign every stale page + +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +const ROOT = process.cwd(); +const DOCS_DIR = "docs/handbuch"; +const SIGN = process.argv.includes("--sign"); + +/** Frontmatter fields this gate reads. The subset is deliberate — no YAML dependency. */ +function parseFrontmatter(text, page) { + if (!text.startsWith("---\n")) throw new Error(`${page}: no frontmatter`); + const end = text.indexOf("\n---\n", 4); + if (end === -1) throw new Error(`${page}: unterminated frontmatter`); + const body = text.slice(4, end + 1); + + const fm = { sources: [] }; + let inSources = false; + for (const line of body.split("\n")) { + if (!line.trim()) continue; + const item = line.match(/^\s+-\s+(.*)$/); + if (inSources && item) { + fm.sources.push(item[1].trim().replace(/^["']|["']$/g, "")); + continue; + } + inSources = false; + const kv = line.match(/^([a-z_]+):\s*(.*)$/); + if (!kv) continue; + const [, key, rawValue] = kv; + const value = rawValue.trim().replace(/^["']|["']$/g, ""); + if (key === "sources") { + // Either `sources: []` inline or a block list on the following lines. + if (value === "[]") fm.sources = []; + else inSources = true; + continue; + } + fm[key] = value; + } + return { fm, frontmatterEnd: end + 5 }; +} + +/** Walk a directory, returning repo-relative paths. */ +function walk(dir, out = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full, out); + else if (entry.isFile()) out.push(relative(ROOT, full).split(sep).join("/")); + } + return out; +} + +/** + * Resolve one `sources:` entry to repo-relative file paths. Supports a literal path, + * a `**` prefix-walk (`src/permissions/**`) and `*` within a single segment. + */ +function resolve(pattern) { + if (!pattern.includes("*")) { + try { + if (statSync(join(ROOT, pattern)).isFile()) return [pattern]; + return walk(join(ROOT, pattern)); + } catch { + throw new Error(`sources entry does not exist: ${pattern}`); + } + } + + const deep = pattern.indexOf("**"); + if (deep !== -1) { + const base = pattern.slice(0, deep).replace(/\/$/, ""); + return walk(join(ROOT, base)); + } + + const rx = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$"); + const dir = pattern.slice(0, pattern.lastIndexOf("/")); + return walk(join(ROOT, dir)).filter((f) => rx.test(f)); +} + +function signature(sources) { + const files = [...new Set(sources.flatMap(resolve))].sort(); + if (!files.length) throw new Error("sources resolved to no files"); + const h = createHash("sha256"); + for (const f of files) { + h.update(f); + h.update("\0"); + h.update(readFileSync(join(ROOT, f))); + h.update("\0"); + } + return h.digest("hex").slice(0, 16); +} + +const pages = readdirSync(join(ROOT, DOCS_DIR)) + .filter((f) => f.endsWith(".md")) + .map((f) => `${DOCS_DIR}/${f}`) + .sort(); + +const stale = []; +const errors = []; + +for (const page of pages) { + const text = readFileSync(join(ROOT, page), "utf8"); + let parsed; + try { + parsed = parseFrontmatter(text, page); + } catch (e) { + errors.push(e.message); + continue; + } + const { fm } = parsed; + + if (fm.sources_exempt_reason) { + if (fm.sources.length) errors.push(`${page}: exempt pages must declare \`sources: []\``); + console.log(` exempt ${page} — ${fm.sources_exempt_reason}`); + continue; + } + + if (!fm.sources.length) { + errors.push(`${page}: no \`sources:\` and no \`sources_exempt_reason:\``); + continue; + } + + let actual; + try { + actual = signature(fm.sources); + } catch (e) { + errors.push(`${page}: ${e.message}`); + continue; + } + + if (actual === fm.sources_hash) { + console.log(` ok ${page}`); + continue; + } + + stale.push({ page, expected: fm.sources_hash ?? "(unsigned)", actual }); + if (SIGN) { + const signed = fm.sources_hash + ? text.replace(`sources_hash: ${fm.sources_hash}`, `sources_hash: ${actual}`) + : text.replace(/^---\n/, `---\nsources_hash: ${actual}\n`); + writeFileSync(join(ROOT, page), signed); + console.log(` signed ${page} → ${actual}`); + } else { + console.log(` STALE ${page} — signed ${fm.sources_hash ?? "(unsigned)"}, now ${actual}`); + } +} + +if (errors.length) { + console.error("\nFrontmatter errors:"); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); +} + +if (stale.length && !SIGN) { + console.error( + `\n${stale.length} page(s) document code that changed since they were last read.\n` + + `Re-read each page against its sources, then re-sign:\n\n` + + ` node .github/scripts/docs-staleness.mjs --sign\n\n` + + `Bumping \`reviewed:\` alone does not satisfy this gate.`, + ); + process.exit(1); +} + +console.log(SIGN ? `\n${stale.length} page(s) re-signed.` : `\nAll ${pages.length} page(s) current.`); diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 724bc93..1847505 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,19 +1,23 @@ name: Docs # Two gates over docs/handbuch/ — the section published into the Handbuch's -# "ChurchTools-Grundlagen" (#89, publishing contract in eqrm/churchtools-connector#1114): +# "ChurchTools-Grundlagen" (#89): # # 1. `build` — mkdocs --strict, so a broken link or a page missing from the nav fails # the PR here rather than silently in the consuming site build. -# 2. `staleness` — the estate-wide reusable checker (eqrm/churchtools-connector#1120). -# It reads each page's `sources:` globs and fails a PR that changes documented code -# without re-signing the page. The single implementation lives in the connector repo -# on purpose: reimplementing it per language would give us four subtly different gates. +# 2. `staleness` — reads each page's `sources:` globs and fails a PR that changes +# documented code without re-signing the page. +# +# The staleness checker is vendored at .github/scripts/docs-staleness.mjs rather than +# called as the estate-wide reusable workflow: this repo is public and a public repo +# cannot call a reusable workflow out of a private one. It is a verified port of the +# canonical `App\Services\Docs\SourcesHasher` and reproduces the hashes that signer +# wrote, so pages stay signable from either side. # # Re-signing a page means recomputing `sources_hash` over its `sources:` — a sha256 over -# each resolved file's `\0\0`, sorted, truncated to 16 hex chars -# (see the connector's `App\Services\Docs\SourcesHasher`). Bumping `reviewed:` alone does -# NOT satisfy the gate; the point is that someone re-read the page against the new code. +# each resolved file's `\0\0`, sorted, truncated to 16 hex chars. +# Bumping `reviewed:` alone does NOT satisfy the gate; the point is that someone re-read +# the page against the new code. on: push: @@ -47,7 +51,11 @@ jobs: # `src/**` changed without the matching page re-signed → this fails. Signing is the # acknowledgement, not the file appearing in the diff. staleness: - uses: eqrm/churchtools-connector/.github/workflows/docs-staleness.yml@main - with: - docs-path: docs/handbuch - secrets: inherit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Check that documented sources are re-signed + run: node .github/scripts/docs-staleness.mjs diff --git a/.github/workflows/issue-intake.yml b/.github/workflows/issue-intake.yml index 6f12fda..6261ee4 100644 --- a/.github/workflows/issue-intake.yml +++ b/.github/workflows/issue-intake.yml @@ -18,7 +18,9 @@ permissions: jobs: triage: if: github.event.issue.milestone == null - runs-on: [self-hosted, eqrm-ci] + # GitHub-hosted on purpose: self-hosted runners on a public repo are a standing + # risk, and this job only shells out to `gh issue edit`. + runs-on: ubuntu-latest steps: - name: Mark as untriaged env: diff --git a/.gitignore b/.gitignore index 515e26f..83942b2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ dist/ .env.* !.env.example -# Local ChurchTools state / adopted config (belongs in eqrm/ct-structure, never here) +# Local ChurchTools state / adopted config (belongs in your config repo, never here) *.ctstate.json ct-state.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..217b53e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing + +This tool was built for one organisation's ChurchTools instance and then generalised, so +the most useful contributions are usually the ones that make it less specific to that +origin: another instance's API quirk, a field the engine does not manage yet, a +ChurchTools version where an endpoint behaves differently. + +## Getting set up + +```bash +npm ci +npm test # vitest — 570-odd unit tests, no network +npm run typecheck && npm run lint +npm run build # -> dist/index.js (the `ct` binary) +npm run dev -- get campuses # run from source against a live instance +``` + +Node ≥ 20; the repo pins 22 via `.nvmrc`. + +To run against a real instance, `ct auth login --host https://your.church.tools` stores +host + token in the OS keychain. Never commit a token — see [SECURITY.md](SECURITY.md). + +## Things worth knowing before you change engine code + +- **`plan` must stay honest.** A plan that under-reports a change is the worst bug this + tool can have. Anything touching diffing needs a test that pins the rendered plan. +- **A clean apply must round-trip to a no-op.** If applying a config and re-planning shows + a diff, the resource's `managedFields` are wrong — that is a bug, not a quirk. +- **People are out of scope, permanently.** Persons and memberships are guarded in code. + PRs that manage people will be declined regardless of quality; it is a boundary, not a + gap. +- **Live-API tests are opt-in.** Anything that writes is gated behind `CT_LIVE=1` and must + target a dev instance and clean up after itself. CI never runs them. + +## Documentation + +Pages under `docs/handbuch/` declare the code they document in `sources:` frontmatter and +are signed with `sources_hash`. Change a documented source and CI fails until the page is +re-read and re-signed: + +```bash +node .github/scripts/docs-staleness.mjs # what CI runs +node .github/scripts/docs-staleness.mjs --sign # after re-reading the page +``` + +## Commits and releases + +Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) — +`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`. This is load-bearing, not +cosmetic: every push to `main` runs semantic-release, which derives the version and cuts +the GitHub Release from the commit history. A `feat:` gets a minor, a `fix:` a patch, +`BREAKING CHANGE:` in the body a major. + +Open an issue before a large change so the design can be argued about before it is built. +Issues opened without a milestone are labelled `triage` automatically and picked up in the +weekly sweep; nothing is closed for being old. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..245b96c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Felix Kotschenreuther + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index caee814..9c6d1b8 100644 --- a/README.md +++ b/README.md @@ -169,9 +169,11 @@ ct apply --env prod # protected env: type the env name to confirm | Repo | Contents | | --- | --- | | **`eqrm/ct-cli`** (this repo) | The tool: CLI, API client, plan/apply engine. Generic, reusable. | -| **`eqrm/ct-structure`** | Equippers' actual desired-state config + state files. Depends on this tool. | +| *your config repo* (private) | Your instance's desired-state config + state files. Depends on this tool. | -Like Terraform, the tool never lives in the same repo as the infra config. +Like Terraform, the tool never lives in the same repo as the infra config — the config +repo holds an organisation's actual structure and stays private. This repo is the tool +only; it carries no instance data. ## Documentation @@ -207,3 +209,18 @@ guardrails → blueprints) are complete. Phase 6 — reproducible instances, environments, GitOps — is tracked in [#19](https://github.com/eqrm/ct-cli/issues/19); the overall design lives in [#1](https://github.com/eqrm/ct-cli/issues/1). + +Built against a real ChurchTools instance, so a fair amount of the value here is the +written-down API archaeology — see [`docs/api-coverage.md`](docs/api-coverage.md) for +which endpoints actually support which verbs, and +[`docs/runbook-manual-surface.md`](docs/runbook-manual-surface.md) for what ChurchTools +gives no API for at all. + +## Contributing + +Issues and PRs are welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md). Security reports +go through [private vulnerability reporting](SECURITY.md), not a public issue. + +## License + +[MIT](LICENSE). Not affiliated with or endorsed by ChurchTools. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6bb2e7e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security policy + +## Reporting a vulnerability + +Please report security issues through GitHub's +[private vulnerability reporting](https://github.com/eqrm/ct-cli/security/advisories/new) +rather than a public issue. Expect an initial response within a week. + +## What this tool touches + +`ct` authenticates to a ChurchTools instance with a **personal login token** that carries +your own permissions, and it performs writes (`ct apply`, `ct destroy`). When assessing +impact, the things worth knowing: + +- **Tokens are never written to the repo.** They live in the OS keychain (`ct auth login`, + keyed per host) or in `CT_LOGINTOKEN` for CI. `ct.envs.json` stores only the _name_ of a + token env var, never a value, which is why it is safe to commit. +- **People are never managed.** Persons and memberships are out of scope by design and + guarded in code — the tool manages the overarching, rights-bearing structure only. +- **Writes are opt-in and ordered.** `plan` is the default; `apply` needs explicit + confirmation, protected environments additionally require the env name to be typed (or + `--confirm-env`), and deletions never happen implicitly. +- **Grants are real permissions.** A config that declares permission grants can widen + access on the target instance. Review a `plan` before applying it, the same way you + would a Terraform plan. + +## Scope + +Vulnerabilities in this CLI are in scope. Vulnerabilities in ChurchTools itself are not — +report those to [ChurchTools](https://church.tools) directly. diff --git a/docs/README.md b/docs/README.md index f0b72a3..92d1566 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,9 +6,8 @@ Two audiences, kept apart on purpose (#89). The **generic ChurchTools reference**: how ChurchTools' own permission model, dynamic groups and field definitions behave, independent of any one instance. -These pages are published into the Handbuch's "ChurchTools-Grundlagen" section -(eqrm/churchtools-connector#1114), so they carry `sources:` frontmatter and are -gated by the estate-wide staleness checker. +These pages are published into a wider ChurchTools Handbuch, so they carry +`sources:` frontmatter and are gated by the staleness checker (#89). | Page | About | | --- | --- | @@ -36,10 +35,16 @@ fails the `Docs / staleness` check — the point being that someone re-read the page against the new code. Bumping `reviewed:` alone does **not** satisfy it. The signature is a sha256 over each resolved file's -`\0\0`, files sorted, truncated to 16 hex chars — -the canonical implementation is `App\Services\Docs\SourcesHasher` in -eqrm/churchtools-connector, and `php artisan docs:sign ` in a checkout of -that repo writes it. +`\0\0`, files sorted, truncated to 16 hex chars. +Check and re-sign locally: + +```bash +node .github/scripts/docs-staleness.mjs # what CI runs +node .github/scripts/docs-staleness.mjs --sign # re-sign, after re-reading +``` + +A page with no code behaviour to track declares `sources: []` plus a +`sources_exempt_reason:` instead. ## Everything else — developer- and operator-facing, unpublished diff --git a/docs/ci.md b/docs/ci.md index 8ad55b5..d6af451 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -20,7 +20,7 @@ Skip the keychain with two env vars (see [`src/auth/tokenStore.ts`](https://github.com/eqrm/ct-cli/blob/main/src/auth/tokenStore.ts)): ```bash -export CT_HOST=https://eqrm.church.tools +export CT_HOST=https://mychurch.church.tools export CT_LOGINTOKEN= ct plan ``` diff --git a/docs/environments.md b/docs/environments.md index b4f53e7..1997f38 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -1,6 +1,6 @@ # Environments (dev → prod promotion) -One config repo can drive several ChurchTools instances — e.g. an `eqrm-dev` +One config repo can drive several ChurchTools instances — e.g. a `dev` rehearsal env and the real `prod` — Terraform-workspace-style, with **no file edits** when switching. @@ -12,9 +12,9 @@ path; override with `CT_ENVS`): ```json { "environments": { - "dev": { "host": "https://eqrm-dev.church.tools" }, + "dev": { "host": "https://mychurch-dev.church.tools" }, "prod": { - "host": "https://eqrm.church.tools", + "host": "https://mychurch.church.tools", "state": "ct-state.prod.json", "protected": true, "tokenEnv": "CT_PROD_TOKEN" diff --git a/docs/handbuch/index.md b/docs/handbuch/index.md index 1347b3e..5a1c46c 100644 --- a/docs/handbuch/index.md +++ b/docs/handbuch/index.md @@ -22,9 +22,9 @@ each page marks explicitly what was **verified** and what is still an | Layer | Repo | Example | | --- | --- | --- | -| **Generic ChurchTools behaviour** | `eqrm/ct-cli` `docs/handbuch/` (these pages) | "A `group_type_role` grant reaches every group of that type" | -| **This instance's structure** | `eqrm/ct-structure` `docs/` | "Which Bereiche exist at Equippers and why" | -| **Connector-specific how-tos** | `eqrm/churchtools-connector` Handbuch | "How to run check-in" | +| **Generic ChurchTools behaviour** | `ct-cli` `docs/handbuch/` (these pages) | "A `group_type_role` grant reaches every group of that type" | +| **One instance's structure** | that organisation's private config repo, `docs/` | "Which Bereiche exist here and why" | +| **Tool-specific how-tos** | the relevant tool's own Handbuch | "How to run check-in" | ## The pages diff --git a/docs/handbuch/mkdocs.yml b/docs/handbuch/mkdocs.yml index 3e7ab0f..8fa7ae7 100644 --- a/docs/handbuch/mkdocs.yml +++ b/docs/handbuch/mkdocs.yml @@ -1,5 +1,4 @@ -# Mini config for ct-cli's section of the Handbuch (#89, publishing contract in -# eqrm/churchtools-connector#1114). +# Mini config for ct-cli's section of a wider ChurchTools Handbuch (#89). # # A page publishes only if it lives under docs/handbuch/ and is reachable from this # nav — everything else in docs/ (api-coverage.md, group-field-decisions.md, @@ -7,7 +6,7 @@ # developer- and operator-facing material, not reader-facing reference. # # These pages are the GENERIC ChurchTools reference — how ChurchTools' own group, -# permission and dynamic-group model actually works — as opposed to eqrm/ct-structure's +# permission and dynamic-group model actually works — as opposed to a config repo's # docs/, which describe one specific instance. That is why they belong in the Handbuch's # "ChurchTools-Grundlagen" section rather than in this repo's README. # @@ -17,8 +16,8 @@ # least match the page text while the mismatch stands. # # Built standalone via `mkdocs build -f docs/handbuch/mkdocs.yml --strict`; -# eqrm/churchtools-connector#1115 replaces that with an `!include` from the root config -# once mkdocs-monorepo-plugin exists. +# the consuming site replaces that with an `!include` from its root config once +# mkdocs-monorepo-plugin exists. site_name: ct-cli — ChurchTools-Grundlagen site_description: Generic ChurchTools reference — permissions, dynamic groups, field definitions, blueprints docs_dir: . diff --git a/docs/handbuch/requirements.txt b/docs/handbuch/requirements.txt index be750bc..6c64526 100644 --- a/docs/handbuch/requirements.txt +++ b/docs/handbuch/requirements.txt @@ -1,5 +1,5 @@ # Pinned toolchain for this section's standalone MkDocs build (`.github/workflows/docs.yml`). -# Kept in lockstep with eqrm/churchtools-connector's `requirements-docs.txt` so the section +# Kept in lockstep with the consuming Handbuch site's docs requirements so the section # renders identically here and in the site that consumes it — bump both together, and # deliberately (`pip index versions `), never via `pip install -U`. mkdocs==1.6.1 diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index 0901903..56def4a 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -23,11 +23,10 @@ Every item below falls into exactly one of three buckets: | Meeting points (Treffpunkte) | A group's meeting-location master data | No endpoint at all — zero matches for `treffpunkt`/`meetingpoint` anywhere in the OpenAPI spec ([`docs/api-coverage.md`](api-coverage.md) #11) | Group admin → meeting point field on a group | No API verification possible; visually confirm in the UI. (Do not confuse with _meeting templates_ `/group/meetingtemplates` or _group meetings_ `/groups/{id}/meetings`, both full CRUD but different concepts — confirm with product if "meeting point" was meant to be one of those instead) | | Permission name↔authId catalog | The mapping from human-readable `module:right` names to the numeric `authId` the API actually writes | Not exposed by the REST API at all; only servable via the legacy `POST /index.php?q=churchauth/ajax&func=getMasterData` call ([`src/permissions/README.md`](../src/permissions/README.md)) | Permission editor (any role's right-picker enumerates the live set) | Regeneration procedure below (**Permission catalog lifecycle**) | -**Expected values for our instance(s):** left blank here deliberately — this +**Expected values for a given instance:** left blank here deliberately — this runbook is generic (part of `ct-cli`, the tool repo). The per-instance expected/desired values (which member statuses, which meeting points) belong -in `eqrm/ct-structure`'s own runbook once #23 creates that repo, following -this doc's structure. +in that instance's own config repo, in a runbook following this doc's structure. ## Not yet implemented — API supports it, `ct` doesn't drive it yet @@ -153,8 +152,8 @@ in where they'd otherwise be silently skipped: - The exact expected/desired **values** (which member statuses, which meeting points, which campus-per-group assignments) are instance-specific - and deliberately not enumerated here — they belong in `eqrm/ct-structure` - once #23 creates that repo. This runbook only tracks _what category_ of + and deliberately not enumerated here — they belong in the instance's own + private config repo. This runbook only tracks _what category_ of manual step exists and _how to verify_ it, not the target values for any particular ChurchTools instance. - "Meeting point" (Treffpunkt) has no confirmed CT concept mapping — `docs/api-coverage.md` diff --git a/examples/ct.envs.json b/examples/ct.envs.json index d295a35..b2ca585 100644 --- a/examples/ct.envs.json +++ b/examples/ct.envs.json @@ -1,10 +1,10 @@ { "environments": { "dev": { - "host": "https://eqrm-dev.church.tools" + "host": "https://mychurch-dev.church.tools" }, "prod": { - "host": "https://eqrm.church.tools", + "host": "https://mychurch.church.tools", "state": "ct-state.prod.json", "protected": true, "tokenEnv": "CT_PROD_TOKEN" diff --git a/package.json b/package.json index 687b832..c37f8f7 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@eqrm/ct-cli", "version": "0.1.0", "description": "ChurchTools structure-as-code CLI — declarative plan/apply for the overarching, rights-bearing structure (campuses, groups, hierarchies, permissions, auto-groups). Never manages people.", + "license": "MIT", "type": "module", "repository": { "type": "git", diff --git a/src/config.ts b/src/config.ts index 035c03a..ffff560 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,7 +11,7 @@ import { readStoredHost } from "./auth/tokenStore.js"; export interface CtConfig { - /** Base host, e.g. `https://eqrm.church.tools` (no trailing slash, no `/api`). */ + /** Base host, e.g. `https://mychurch.church.tools` (no trailing slash, no `/api`). */ host: string; } diff --git a/src/env/envs.ts b/src/env/envs.ts index 5ac3841..9f8338c 100644 --- a/src/env/envs.ts +++ b/src/env/envs.ts @@ -10,8 +10,8 @@ * ```json * { * "environments": { - * "dev": { "host": "https://eqrm-dev.church.tools" }, - * "prod": { "host": "https://eqrm.church.tools", "protected": true, "tokenEnv": "CT_PROD_TOKEN" } + * "dev": { "host": "https://mychurch-dev.church.tools" }, + * "prod": { "host": "https://mychurch.church.tools", "protected": true, "tokenEnv": "CT_PROD_TOKEN" } * } * } * ``` diff --git a/tests/adopt-grants-command.test.ts b/tests/adopt-grants-command.test.ts index 829d3e8..afb2f9a 100644 --- a/tests/adopt-grants-command.test.ts +++ b/tests/adopt-grants-command.test.ts @@ -16,7 +16,7 @@ vi.mock("../src/api/session.js", () => ({ const { adoptCommand } = await import("../src/commands/adopt.js"); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const originalHost = process.env.CT_HOST; async function run(args: string[]): Promise { diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index bbd269c..8f04353 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -113,7 +113,7 @@ const { adoptCommand } = await import("../src/commands/adopt.js"); const { loadState } = await import("../src/state/state.js"); const { loadConfig } = await import("../src/config/load.js"); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const originalHost = process.env.CT_HOST; const originalCwd = process.cwd(); diff --git a/tests/adopt.test.ts b/tests/adopt.test.ts index 14ba3bf..4c47173 100644 --- a/tests/adopt.test.ts +++ b/tests/adopt.test.ts @@ -14,7 +14,7 @@ const { adoptCommand } = await import("../src/commands/adopt.js"); const { loadState } = await import("../src/state/state.js"); const statePath = join(tmpdir(), `ct-cli-adopt-${process.pid}.json`); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; async function runAdopt(args: string[]): Promise { await adoptCommand().parseAsync(args, { from: "user" }); diff --git a/tests/apply-env-command.test.ts b/tests/apply-env-command.test.ts index 70a44c5..117aa0f 100644 --- a/tests/apply-env-command.test.ts +++ b/tests/apply-env-command.test.ts @@ -43,8 +43,8 @@ vi.mock("../src/engine/backup.js", () => ({ writeBackup: vi.fn(async () => "back const { applyCommand } = await import("../src/commands/apply.js"); const { saveState, emptyState } = await import("../src/state/state.js"); -const DEV = "https://eqrm-dev.church.tools"; -const PROD = "https://eqrm.church.tools"; +const DEV = "https://mychurch-dev.church.tools"; +const PROD = "https://mychurch.church.tools"; const envsPath = join(tmpdir(), `ct-cli-applyenv-envs-${process.pid}.json`); const devState = join(tmpdir(), `ct-cli-applyenv-dev-${process.pid}.json`); const prodState = join(tmpdir(), `ct-cli-applyenv-prod-${process.pid}.json`); diff --git a/tests/apply-refresh-command.test.ts b/tests/apply-refresh-command.test.ts index 05e859d..e1ebbf6 100644 --- a/tests/apply-refresh-command.test.ts +++ b/tests/apply-refresh-command.test.ts @@ -63,7 +63,7 @@ const { applyCommand } = await import("../src/commands/apply.js"); const { loadState, saveState, emptyState } = await import("../src/state/state.js"); const statePath = join(tmpdir(), `ct-cli-apply-refresh-${process.pid}.json`); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; async function runApply(args: string[]): Promise { await applyCommand().parseAsync(args, { from: "user" }); diff --git a/tests/ctClient.test.ts b/tests/ctClient.test.ts index 26e6f5b..d15e87e 100644 --- a/tests/ctClient.test.ts +++ b/tests/ctClient.test.ts @@ -23,7 +23,7 @@ describe("CtClient", () => { .mockResolvedValueOnce(jsonResponse({ data: "csrf-123" })); vi.stubGlobal("fetch", fetchMock); - const client = new CtClient({ host: "https://eqrm.church.tools" }); + const client = new CtClient({ host: "https://mychurch.church.tools" }); const me = await client.authenticate("tok"); expect(me).toEqual({ id: 7, firstName: "Ada" }); @@ -33,7 +33,7 @@ describe("CtClient", () => { }); it("refuses requests before authentication", async () => { - const client = new CtClient({ host: "https://eqrm.church.tools" }); + const client = new CtClient({ host: "https://mychurch.church.tools" }); await expect(client.get("/campuses")).rejects.toBeInstanceOf(CtApiError); }); @@ -45,7 +45,7 @@ describe("CtClient", () => { .mockResolvedValueOnce(jsonResponse({ data: { id: 99 } })); vi.stubGlobal("fetch", fetchMock); - const client = new CtClient({ host: "https://eqrm.church.tools" }); + const client = new CtClient({ host: "https://mychurch.church.tools" }); await client.authenticate("tok"); await client.request("POST", "/campuses", { name: "Mainz" }); @@ -61,7 +61,7 @@ describe("CtClient", () => { .mockResolvedValueOnce(jsonResponse({ data: { id: 1 } }, { setCookie: "s=1; Path=/" })) .mockResolvedValueOnce(jsonResponse({ data: "csrf" })); vi.stubGlobal("fetch", fetchMock); - const client = new CtClient({ host: "https://eqrm.church.tools" }); + const client = new CtClient({ host: "https://mychurch.church.tools" }); await client.authenticate("tok"); return { client, fetchMock }; } diff --git a/tests/destroy-command.test.ts b/tests/destroy-command.test.ts index 216703e..c919be8 100644 --- a/tests/destroy-command.test.ts +++ b/tests/destroy-command.test.ts @@ -44,7 +44,7 @@ const { destroyCommand } = await import("../src/commands/destroy.js"); const { saveState, loadState, emptyState } = await import("../src/state/state.js"); const statePath = join(tmpdir(), `ct-cli-destroy-cmd-${process.pid}.json`); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const originalHost = process.env.CT_HOST; function group(key: string, id: number, extra: Record = {}) { diff --git a/tests/destroy-env-command.test.ts b/tests/destroy-env-command.test.ts index 5f95fe5..b16915e 100644 --- a/tests/destroy-env-command.test.ts +++ b/tests/destroy-env-command.test.ts @@ -23,7 +23,7 @@ vi.mock("../src/engine/backup.js", () => ({ writeBackup: vi.fn(async () => "back const { destroyCommand } = await import("../src/commands/destroy.js"); const { saveState, loadState, emptyState } = await import("../src/state/state.js"); -const PROD = "https://eqrm.church.tools"; +const PROD = "https://mychurch.church.tools"; const envsPath = join(tmpdir(), `ct-cli-destroyenv-envs-${process.pid}.json`); const prodState = join(tmpdir(), `ct-cli-destroyenv-prod-${process.pid}.json`); const backupDir = join(tmpdir(), `ct-cli-destroyenv-backups-${process.pid}`); diff --git a/tests/env-context.test.ts b/tests/env-context.test.ts index 9a83701..2b5d949 100644 --- a/tests/env-context.test.ts +++ b/tests/env-context.test.ts @@ -11,9 +11,9 @@ async function writeEnvs(): Promise { envsPath, JSON.stringify({ environments: { - dev: { host: "https://eqrm-dev.church.tools" }, + dev: { host: "https://mychurch-dev.church.tools" }, prod: { - host: "https://eqrm.church.tools", + host: "https://mychurch.church.tools", state: "custom-prod-state.json", protected: true, tokenEnv: "CT_PROD_TOKEN", @@ -49,7 +49,7 @@ describe("prepareEnv with --env", () => { const env: NodeJS.ProcessEnv = { CT_ENVS: envsPath }; const result = await prepareEnv({ env: "dev" }, env); expect(result).toEqual({ name: "dev", protected: false, statePath: "ct-state.dev.json" }); - expect(env.CT_HOST).toBe("https://eqrm-dev.church.tools"); + expect(env.CT_HOST).toBe("https://mychurch-dev.church.tools"); }); it("uses the profile state override and surfaces the protected flag", async () => { @@ -61,7 +61,7 @@ describe("prepareEnv with --env", () => { protected: true, statePath: "custom-prod-state.json", }); - expect(env.CT_HOST).toBe("https://eqrm.church.tools"); + expect(env.CT_HOST).toBe("https://mychurch.church.tools"); }); it("copies the profile's tokenEnv value into CT_LOGINTOKEN (CI path) when present", async () => { diff --git a/tests/envs.test.ts b/tests/envs.test.ts index a6a611a..4a49c60 100644 --- a/tests/envs.test.ts +++ b/tests/envs.test.ts @@ -38,14 +38,14 @@ describe("loadEnvProfile", () => { it("resolves a named profile with a normalized host and defaulted state path", async () => { await writeEnvs({ environments: { - dev: { host: "https://eqrm-dev.church.tools/" }, - prod: { host: "https://eqrm.church.tools", state: "ct-state.prod.json", protected: true }, + dev: { host: "https://mychurch-dev.church.tools/" }, + prod: { host: "https://mychurch.church.tools", state: "ct-state.prod.json", protected: true }, }, }); const dev = await loadEnvProfile("dev", envsPath); expect(dev).toEqual({ name: "dev", - host: "https://eqrm-dev.church.tools", // trailing slash stripped + host: "https://mychurch-dev.church.tools", // trailing slash stripped statePath: "ct-state.dev.json", // convention default protected: false, tokenEnv: undefined, @@ -53,7 +53,7 @@ describe("loadEnvProfile", () => { const prod = await loadEnvProfile("prod", envsPath); expect(prod).toMatchObject({ name: "prod", - host: "https://eqrm.church.tools", + host: "https://mychurch.church.tools", statePath: "ct-state.prod.json", protected: true, }); @@ -61,7 +61,7 @@ describe("loadEnvProfile", () => { it("carries a tokenEnv reference when present", async () => { await writeEnvs({ - environments: { prod: { host: "https://eqrm.church.tools", tokenEnv: "CT_PROD_TOKEN" } }, + environments: { prod: { host: "https://mychurch.church.tools", tokenEnv: "CT_PROD_TOKEN" } }, }); expect((await loadEnvProfile("prod", envsPath)).tokenEnv).toBe("CT_PROD_TOKEN"); }); diff --git a/tests/permission-adopt.test.ts b/tests/permission-adopt.test.ts index 7c8b0e7..745ad98 100644 --- a/tests/permission-adopt.test.ts +++ b/tests/permission-adopt.test.ts @@ -5,7 +5,7 @@ import { desiredTuples } from "../src/permissions/plan.js"; import type { Grant } from "../src/permissions/types.js"; import type { State } from "../src/state/state.js"; -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; /** A state file with one managed group "kids" at id 99. */ function stateWithKids(): State { diff --git a/tests/permission-bootstrap.test.ts b/tests/permission-bootstrap.test.ts index 6ee8c2e..db6155a 100644 --- a/tests/permission-bootstrap.test.ts +++ b/tests/permission-bootstrap.test.ts @@ -15,7 +15,7 @@ import type { Plan, DesiredResource } from "../src/engine/types.js"; import type { DesiredPermission } from "../src/permissions/types.js"; import type { CtClient } from "../src/api/ctClient.js"; -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; // churchgroup:view group — authId 1104, scoped (scopeField cdb_gruppe). const scopedPerm: DesiredPermission = { diff --git a/tests/permission-pending-domain.test.ts b/tests/permission-pending-domain.test.ts index a67e9b4..7210498 100644 --- a/tests/permission-pending-domain.test.ts +++ b/tests/permission-pending-domain.test.ts @@ -18,7 +18,7 @@ import type { Plan, DesiredResource } from "../src/engine/types.js"; import type { DesiredPermission } from "../src/permissions/types.js"; import type { CtClient } from "../src/api/ctClient.js"; -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const STRUKTUR_TYPE_ID = 9; // The #23 config, in miniature: declare a group type AND a group_type_role permission domain that diff --git a/tests/plan-detailed-exitcode-command.test.ts b/tests/plan-detailed-exitcode-command.test.ts index 49c74ab..a551d4a 100644 --- a/tests/plan-detailed-exitcode-command.test.ts +++ b/tests/plan-detailed-exitcode-command.test.ts @@ -46,7 +46,7 @@ vi.mock("../src/permissions/plan.js", () => ({ const { planCommand } = await import("../src/commands/plan.js"); const { saveState, emptyState } = await import("../src/state/state.js"); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const statePath = join(tmpdir(), `ct-cli-plan-detailed-exitcode-${process.pid}.json`); const originalHost = process.env.CT_HOST; diff --git a/tests/plan-env-command.test.ts b/tests/plan-env-command.test.ts index a16ce9e..24dd76e 100644 --- a/tests/plan-env-command.test.ts +++ b/tests/plan-env-command.test.ts @@ -16,8 +16,8 @@ import type { Plan } from "../src/engine/types.js"; const plan: Plan = { items: [] }; const versionByHost: Record = { - "https://eqrm-dev.church.tools": "3.100.0", - "https://eqrm.church.tools": "3.123.0", + "https://mychurch-dev.church.tools": "3.100.0", + "https://mychurch.church.tools": "3.123.0", }; // authedSession is mocked, but resolveConfig (real) has already read CT_HOST that prepareEnv wired, @@ -46,8 +46,8 @@ vi.mock("../src/permissions/plan.js", () => ({ const { planCommand } = await import("../src/commands/plan.js"); const { saveState, emptyState } = await import("../src/state/state.js"); -const DEV = "https://eqrm-dev.church.tools"; -const PROD = "https://eqrm.church.tools"; +const DEV = "https://mychurch-dev.church.tools"; +const PROD = "https://mychurch.church.tools"; const envsPath = join(tmpdir(), `ct-cli-planenv-envs-${process.pid}.json`); const devState = join(tmpdir(), `ct-cli-planenv-dev-${process.pid}.json`); const prodState = join(tmpdir(), `ct-cli-planenv-prod-${process.pid}.json`); diff --git a/tests/plan.test.ts b/tests/plan.test.ts index 7bfe94d..2f9f316 100644 --- a/tests/plan.test.ts +++ b/tests/plan.test.ts @@ -3,7 +3,7 @@ import { computePlan } from "../src/engine/plan.js"; import type { DesiredResource } from "../src/engine/types.js"; import type { State, ManagedResource } from "../src/state/state.js"; -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; function desired( key: string, diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 32abf7e..fa0c4ff 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -172,7 +172,7 @@ describe("write specs", () => { expect(RESOURCES["age-group"]?.itemPath(3)).toBe("/group/agegroups/3"); }); - // Field sets verified live against eqrm.church.tools (2026-07-08). + // Field sets verified live against mychurch.church.tools (2026-07-08). it("snapshots real relationship-type degree fields (degreeNameA/B, not degreeForward/Reverse)", () => { const raw = { name: "relationship.couple", diff --git a/tests/state.test.ts b/tests/state.test.ts index 9f3ca21..4aff229 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { emptyState, loadState, saveState, upsert, findByTypeId, isManaged } from "../src/state/state.js"; -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const NOW = "2026-07-07T00:00:00.000Z"; const LATER = "2026-07-08T00:00:00.000Z"; diff --git a/tests/tokenStore-multihost.test.ts b/tests/tokenStore-multihost.test.ts index c9ee69e..f6d11a9 100644 --- a/tests/tokenStore-multihost.test.ts +++ b/tests/tokenStore-multihost.test.ts @@ -14,8 +14,8 @@ import { resetKeychainCache, } from "../src/auth/tokenStore.js"; -const DEV = "https://eqrm-dev.church.tools"; -const PROD = "https://eqrm.church.tools"; +const DEV = "https://mychurch-dev.church.tools"; +const PROD = "https://mychurch.church.tools"; /** Model a keychain as account → stored blob; wire the `security` CLI mock to read/write it. */ function mockKeychain(store: Map): void { From a360e34610e92020d715986ac095594ada135d9b Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 13 Aug 2026 11:03:56 +0200 Subject: [PATCH 2/4] fix(docs-gate): close three silent-pass paths found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the vendored staleness checker found bugs in the direction that matters — the gate going green while the docs are stale. All three reproduced before fixing, and each repro re-run after: 1. A `#` comment inside a `sources:` list silently truncated the list. The comment did not match the list-item regex, so the parser left list mode and dropped every remaining entry. `--sign` then baked the truncated set in, and mutating a dropped source left the gate green forever. The parser is now strict: comments are skipped without ending the block, and any line it does not positively understand is an error rather than a skip. 2. `sources_exempt_reason: >` (a valid YAML block scalar) parsed to the literal ">", which is truthy, silently dropping the page from the gate entirely. Block scalars are now rejected with a message naming the key. 3. `--sign` on a quoted `sources_hash: "..."` reported success while writing nothing: the parser strips quotes, so the string-replace target never matched and String.replace returned the input unchanged. It now rewrites the whole line and errors if the content did not change. Also tightened `**` to a trailing prefix-walk only. Mid-pattern `src/**/*.ts` resolved to every file under src/, and a leading `**` walked the repo root, pulling .git/ and node_modules/ into the hash and making it depend on untracked local state. Both are now rejected rather than approximated. The four real pages still pass with their existing hashes unchanged. Other review findings: - ci.yml had no `permissions:` block, inheriting the org default (often read-write). Pinned to `contents: read`. - docs.yml did not watch .github/scripts/, so a PR weakening the checker did not run the gate. Added to both path filters. - `npm run lint` — which CONTRIBUTING.md and README.md tell contributors to run — failed with 1436 errors once docs/README.md's mkdocs build had been run, because site/ was not ignored. Added to eslint and prettier ignores. - tests/ctClient.test.ts:21 kept the instance slug in a `ChurchTools_ct_eqrm` cookie name that the hostname scrub missed. - Corrected prose that overstated the code: the keychain is macOS-only (no Linux/Windows backend), `assertNotPeople` guards writes but not reads (`ct get raw /persons` returns person records), live writes are gated by CT_LIVE + CT_LIVE_WRITE + a CT_LIVE_WRITE_HOST match rather than CT_LIVE alone, and SECURITY.md now owns the `security -w` argv/ps exposure that tokenStore.ts already documents. Claude-Session: https://claude.ai/code/session_012vnLUFPTuDVZ7DZhpm7sNt --- .github/scripts/docs-staleness.mjs | 64 ++++++++++++++++++++++++------ .github/workflows/ci.yml | 5 +++ .github/workflows/docs.yml | 2 + .prettierignore | 1 + CONTRIBUTING.md | 12 ++++-- SECURITY.md | 19 ++++++--- eslint.config.js | 4 +- tests/ctClient.test.ts | 2 +- 8 files changed, 86 insertions(+), 23 deletions(-) diff --git a/.github/scripts/docs-staleness.mjs b/.github/scripts/docs-staleness.mjs index ad45a2d..e0bcf64 100644 --- a/.github/scripts/docs-staleness.mjs +++ b/.github/scripts/docs-staleness.mjs @@ -24,7 +24,15 @@ const ROOT = process.cwd(); const DOCS_DIR = "docs/handbuch"; const SIGN = process.argv.includes("--sign"); -/** Frontmatter fields this gate reads. The subset is deliberate — no YAML dependency. */ +/** + * Frontmatter fields this gate reads. The subset is deliberate — no YAML dependency. + * + * This parser is strict on purpose: ANY line it does not positively understand is an + * error, never a skip. A lenient parser here fails in the one direction that matters — + * a dropped `sources:` entry means the gate passes while the docs are stale, which is + * exactly the outcome it exists to prevent, and `--sign` would then bake the truncated + * set in permanently. + */ function parseFrontmatter(text, page) { if (!text.startsWith("---\n")) throw new Error(`${page}: no frontmatter`); const end = text.indexOf("\n---\n", 4); @@ -33,22 +41,38 @@ function parseFrontmatter(text, page) { const fm = { sources: [] }; let inSources = false; - for (const line of body.split("\n")) { + for (const [i, line] of body.split("\n").entries()) { + const where = `${page}:${i + 2}`; if (!line.trim()) continue; - const item = line.match(/^\s+-\s+(.*)$/); - if (inSources && item) { - fm.sources.push(item[1].trim().replace(/^["']|["']$/g, "")); - continue; + // A comment must not terminate a `sources:` block — that would silently truncate it. + if (/^\s*#/.test(line)) continue; + + if (inSources) { + const item = line.match(/^\s+-\s+(.*)$/); + if (item) { + fm.sources.push(item[1].trim().replace(/^["']|["']$/g, "")); + continue; + } + // Not a list item and not a comment: the block ended. Only a new top-level key + // may do that; anything else (a stray indent, a wrapped line) is an error below. + inSources = false; } - inSources = false; + const kv = line.match(/^([a-z_]+):\s*(.*)$/); - if (!kv) continue; + if (!kv) throw new Error(`${where}: cannot parse frontmatter line: ${line.trim()}`); const [, key, rawValue] = kv; const value = rawValue.trim().replace(/^["']|["']$/g, ""); + + // `>` / `|` block scalars would parse as the literal value ">" — truthy, and for + // sources_exempt_reason that silently drops the page from the gate. + if (/^[>|][0-9+-]*$/.test(value)) { + throw new Error(`${where}: YAML block scalars are not supported; put \`${key}\` on one line`); + } + if (key === "sources") { - // Either `sources: []` inline or a block list on the following lines. if (value === "[]") fm.sources = []; - else inSources = true; + else if (value === "") inSources = true; + else throw new Error(`${where}: \`sources:\` must be \`[]\` or a block list`); continue; } fm[key] = value; @@ -68,7 +92,12 @@ function walk(dir, out = []) { /** * Resolve one `sources:` entry to repo-relative file paths. Supports a literal path, - * a `**` prefix-walk (`src/permissions/**`) and `*` within a single segment. + * a TRAILING `**` prefix-walk (`src/permissions/**`) and `*` within a single segment. + * + * `**` anywhere but the end is rejected rather than approximated: the obvious reading + * of `src/**\/*.ts` is "the .ts files", but a prefix-walk returns every file under + * `src/`, and a bare `**` prefix would walk the repo root and pull in .git/ and + * node_modules/ — making the hash depend on untracked local state. */ function resolve(pattern) { if (!pattern.includes("*")) { @@ -82,7 +111,11 @@ function resolve(pattern) { const deep = pattern.indexOf("**"); if (deep !== -1) { + if (!pattern.endsWith("/**") || pattern.indexOf("**") !== pattern.lastIndexOf("**")) { + throw new Error(`\`**\` is only supported as a trailing prefix-walk (\`dir/**\`): ${pattern}`); + } const base = pattern.slice(0, deep).replace(/\/$/, ""); + if (!base) throw new Error(`\`**\` needs a directory prefix: ${pattern}`); return walk(join(ROOT, base)); } @@ -149,9 +182,16 @@ for (const page of pages) { stale.push({ page, expected: fm.sources_hash ?? "(unsigned)", actual }); if (SIGN) { + // Rewrite the whole line rather than string-matching the parsed value: the parser + // strips quotes, so `sources_hash: "abc"` would never match `sources_hash: abc` and + // the write would silently no-op while reporting success. const signed = fm.sources_hash - ? text.replace(`sources_hash: ${fm.sources_hash}`, `sources_hash: ${actual}`) + ? text.replace(/^sources_hash:.*$/m, `sources_hash: ${actual}`) : text.replace(/^---\n/, `---\nsources_hash: ${actual}\n`); + if (signed === text) { + errors.push(`${page}: could not write sources_hash — the frontmatter is not in the expected shape`); + continue; + } writeFileSync(join(ROOT, page), signed); console.log(` signed ${page} → ${actual}`); } else { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc873b6..43f28bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: +# Least privilege: this workflow only ever reads the checkout. Without an explicit +# block the token inherits the org default, which is often read-write. +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1847505..51e437e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,11 +26,13 @@ on: - "docs/handbuch/**" - "src/**" - ".github/workflows/docs.yml" + - ".github/scripts/docs-staleness.mjs" pull_request: paths: - "docs/handbuch/**" - "src/**" - ".github/workflows/docs.yml" + - ".github/scripts/docs-staleness.mjs" permissions: contents: read diff --git a/.prettierignore b/.prettierignore index b591866..818ce87 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ dist/ node_modules/ +site/ src/api/schema.d.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 217b53e..fc17be2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,9 @@ npm run dev -- get campuses # run from source against a live instance Node ≥ 20; the repo pins 22 via `.nvmrc`. To run against a real instance, `ct auth login --host https://your.church.tools` stores -host + token in the OS keychain. Never commit a token — see [SECURITY.md](SECURITY.md). +host + token in the **macOS Keychain**. There is no keychain backend on Linux or Windows — +export `CT_LOGINTOKEN` (and `CT_HOST`) instead. Never commit a token — see +[SECURITY.md](SECURITY.md). ## Things worth knowing before you change engine code @@ -26,11 +28,13 @@ host + token in the OS keychain. Never commit a token — see [SECURITY.md](SECU tool can have. Anything touching diffing needs a test that pins the rendered plan. - **A clean apply must round-trip to a no-op.** If applying a config and re-planning shows a diff, the resource's `managedFields` are wrong — that is a bug, not a quirk. -- **People are out of scope, permanently.** Persons and memberships are guarded in code. +- **People are out of scope, permanently.** `assertNotPeople` guards every write path. PRs that manage people will be declined regardless of quality; it is a boundary, not a gap. -- **Live-API tests are opt-in.** Anything that writes is gated behind `CT_LIVE=1` and must - target a dev instance and clean up after itself. CI never runs them. +- **Live-API tests are opt-in, and writes are triple-gated.** Reads need `CT_LIVE=1`. + Writes additionally need `CT_LIVE_WRITE=1` **and** `CT_LIVE_WRITE_HOST` set to exactly + the authenticated host — a deliberate barrier against a live write landing on prod. + They must target a dev instance and clean up after themselves. CI never runs them. ## Documentation diff --git a/SECURITY.md b/SECURITY.md index 6bb2e7e..f22b55f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,11 +12,20 @@ rather than a public issue. Expect an initial response within a week. your own permissions, and it performs writes (`ct apply`, `ct destroy`). When assessing impact, the things worth knowing: -- **Tokens are never written to the repo.** They live in the OS keychain (`ct auth login`, - keyed per host) or in `CT_LOGINTOKEN` for CI. `ct.envs.json` stores only the _name_ of a - token env var, never a value, which is why it is safe to commit. -- **People are never managed.** Persons and memberships are out of scope by design and - guarded in code — the tool manages the overarching, rights-bearing structure only. +- **Tokens are never written to the repo.** They live in the **macOS Keychain** + (`ct auth login`, keyed per host) or in `CT_LOGINTOKEN` for CI. There is no keychain + backend on Linux or Windows — those platforms must use `CT_LOGINTOKEN`. `ct.envs.json` + stores only the _name_ of a token env var, never a value, which is why it is safe to + commit. +- **Storing a token exposes it briefly to `ps`.** `ct auth login` shells out to + `security add-generic-password -w `, so the value sits in that process's argv for + the duration of the call. On a shared machine, assume another local user could observe + it at that moment. +- **People are never _written_.** Persons and memberships are out of scope by design, and + `assertNotPeople` guards every write path. Reads are not guarded: `ct get raw /persons` + returns person records, because `ct get raw` is a deliberate general-purpose API + passthrough. The guarantee is that `ct` never creates, edits or deletes people — not + that it cannot display them. - **Writes are opt-in and ordered.** `plan` is the default; `apply` needs explicit confirmation, protected environments additionally require the env name to be typed (or `--confirm-env`), and deletions never happen implicitly. diff --git a/eslint.config.js b/eslint.config.js index dbd55b8..f2d419f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,7 +2,9 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist/", "node_modules/", "src/api/schema.d.ts"] }, + // `site/` is the mkdocs build output (docs/README.md tells contributors to build it + // locally); without this, `npm run lint` drowns in minified vendor bundles. + { ignores: ["dist/", "node_modules/", "site/", "src/api/schema.d.ts"] }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/tests/ctClient.test.ts b/tests/ctClient.test.ts index d15e87e..a985fb6 100644 --- a/tests/ctClient.test.ts +++ b/tests/ctClient.test.ts @@ -18,7 +18,7 @@ describe("CtClient", () => { const fetchMock = vi .fn() .mockResolvedValueOnce( - jsonResponse({ data: { id: 7, firstName: "Ada" } }, { setCookie: "ChurchTools_ct_eqrm=abc; Path=/" }), + jsonResponse({ data: { id: 7, firstName: "Ada" } }, { setCookie: "ChurchTools_ct_mychurch=abc; Path=/" }), ) .mockResolvedValueOnce(jsonResponse({ data: "csrf-123" })); vi.stubGlobal("fetch", fetchMock); From 301fce8098e35004b897533ef67793c608dc6912 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 13 Aug 2026 12:14:18 +0200 Subject: [PATCH 3/4] chore: drop admin person ids from comments, re-sign permissions.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two comments citing `modifiedPid 1/3891` named the ChurchTools person ids of the admins who authored those grants on prod. Unresolvable from outside the instance, so not PII in practice, but nothing in the explanation depends on the numbers — the point is that the rows carry a real admin's pid rather than the system baseline's -1. Comment-only, so the page content is unaffected; permissions.md is re-signed because `src/permissions/**` is one of its declared sources. The gate flagged this on its own, which is the behaviour it exists for. Claude-Session: https://claude.ai/code/session_012vnLUFPTuDVZ7DZhpm7sNt --- docs/handbuch/permissions.md | 4 ++-- src/permissions/grants.ts | 2 +- tests/permission-adopt.test.ts | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 9e4e854..d10b596 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -5,8 +5,8 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: 49ef267c4843eeb4 -reviewed: 2026-08-10 +sources_hash: ffcfe8dc0516ccf3 +reviewed: 2026-08-13 --- # Permissions (`ct.groupRole` / `ct.groupTypeRole` / `ct.status`) diff --git a/src/permissions/grants.ts b/src/permissions/grants.ts index d075346..518b865 100644 --- a/src/permissions/grants.ts +++ b/src/permissions/grants.ts @@ -18,7 +18,7 @@ export type DomainType = "group_role" | "group_type_role" | "status"; // gated declaration/adoption/diff on the authId. That was too broad — it also excluded the // admin-authored `authId >= 10000` group-member rights (e.g. "Add group members" 10107) that CT DOES // let you write on a group_type_role domain and that Equippers prod actually curates. Verified live -// on eqrm prod `group_type_role/9`: 24 such rows carry `isInherited:false` and `modifiedPid` 1/3891. +// on prod `group_type_role/9`: 24 such rows carry `isInherited:false` and a real admin's `modifiedPid`. // The real, verified boundary is inheritance + system-baseline, which normalizeActual already // enforces on every path that reads actuals — the #65 no-op guarantee holds without the authId cutoff.) diff --git a/tests/permission-adopt.test.ts b/tests/permission-adopt.test.ts index 745ad98..e9583dc 100644 --- a/tests/permission-adopt.test.ts +++ b/tests/permission-adopt.test.ts @@ -244,9 +244,10 @@ describe("emitAdoptedGrants", () => { }); it("admin-authored member rights (authId >= 10000) are managed; system/inherited rows excluded (#65)", () => { - // eqrm prod, group_type_role 9 (/Struktur): admin-authored churchdb:+… MEMBER rights (authId >= + // prod, group_type_role 9 (/Struktur): admin-authored churchdb:+… MEMBER rights (authId >= // 10000, isInherited:false, modifiedPid != -1) that CT lets you write — verified live (24 such - // rows, modifiedPid 1/3891). They must be adopted as ACTIVE grants and round-trip to a no-op. + // rows, each with a real admin's modifiedPid). They must be adopted as ACTIVE grants and + // round-trip to a no-op. // The self-re-adding system baseline (modifiedPid === -1) and truly-inherited rows must be // EXCLUDED by normalizeActual and NEVER show up as `toDelete` (the #65 bug was "0 grant, 24 remove"). // The reconciliation boundary is inheritance + system-baseline, NOT the authId — hence the From 55aa14397c8eea90b3b7ce73195066611ace883b Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 13 Aug 2026 12:30:42 +0200 Subject: [PATCH 4/4] fix(docs-gate): accept foreign frontmatter keys, tolerate CRLF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up review findings. None was a silent pass — the gate erred loudly in the safe direction in every case — but all three would have surprised someone. The strict parser rejected any block list or nested mapping under a key it does not read, so standard mkdocs-material frontmatter failed: hide: - navigation → cannot parse frontmatter line: - navigation That matters here because docs/handbuch/ is built with mkdocs-material, where `hide:` is the normal per-page directive. Indented continuations under keys other than `sources:` are now skipped — they are not this gate's business — while `sources:` keeps its any-line-not-understood-is-an-error rule, which is where the silent-truncation risk actually lives. Verified the two do not interact: a page with `hide:`/`extra:` blocks still captures both `sources:` entries (hash matches the computed ground truth for that file set), and mutating a declared source still goes stale. Key names may now be capitalised or hyphenated, and the parse error names the shape it expects. CRLF: `startsWith("---\n")` failed on `---\r\n`, so a Windows checkout with the default autocrlf reported "no frontmatter" on all five pages. The parser now normalises line endings, and .gitattributes pins text to LF so the situation does not arise — the real fix, since the gate hashes file bytes and a CRLF checkout would otherwise compute different sources_hash values than CI. `---\n---\n` reported "unterminated frontmatter" because the search started past its own closing delimiter; it now reports the accurate "no `sources:`". Also reworded SECURITY.md's people guarantee: the load-bearing control is the resource registry (there is no person resource kind), with assertNotPeople as a second check on top. The reviewer noted the previous wording leaned on the guard by name when the registry is what actually holds. Claude-Session: https://claude.ai/code/session_012vnLUFPTuDVZ7DZhpm7sNt --- .gitattributes | 10 ++++++++ .github/scripts/docs-staleness.mjs | 41 ++++++++++++++++++++++-------- SECURITY.md | 12 +++++---- 3 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1d3a761 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Keep text files LF in the working tree on every platform. The docs staleness gate +# hashes source-file bytes, so a CRLF checkout would otherwise produce different +# `sources_hash` values on Windows than the ones CI computes on Linux. +* text=auto eol=lf + +# Never touched by line-ending normalisation. +*.png binary +*.jpg binary +*.gif binary +*.ico binary diff --git a/.github/scripts/docs-staleness.mjs b/.github/scripts/docs-staleness.mjs index e0bcf64..8f22a88 100644 --- a/.github/scripts/docs-staleness.mjs +++ b/.github/scripts/docs-staleness.mjs @@ -27,20 +27,29 @@ const SIGN = process.argv.includes("--sign"); /** * Frontmatter fields this gate reads. The subset is deliberate — no YAML dependency. * - * This parser is strict on purpose: ANY line it does not positively understand is an - * error, never a skip. A lenient parser here fails in the one direction that matters — - * a dropped `sources:` entry means the gate passes while the docs are stale, which is - * exactly the outcome it exists to prevent, and `--sign` would then bake the truncated - * set in permanently. + * Strictness is asymmetric on purpose, because the two directions are not equally bad: + * + * - Inside `sources:`, ANY line not positively understood is an error, never a skip. + * A dropped entry means the gate passes while the docs are stale — exactly what it + * exists to prevent — and `--sign` would then bake the truncation in permanently. + * - Under any OTHER key, indented continuation lines are skipped. Those are foreign + * to this gate (`hide:` / `extra:` are ordinary mkdocs-material directives), and + * rejecting them would fail a page for frontmatter that is none of our business. */ function parseFrontmatter(text, page) { - if (!text.startsWith("---\n")) throw new Error(`${page}: no frontmatter`); - const end = text.indexOf("\n---\n", 4); + // Tolerate CRLF so a Windows checkout does not fail every page; `.gitattributes` + // pins these files to LF, so this is a belt-and-braces path. + const normalized = text.replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) throw new Error(`${page}: no frontmatter`); + // Search from 3, not 4: `---\n---\n` is empty-but-terminated frontmatter, and starting + // at 4 would step over its closing delimiter and misreport it as unterminated. + const end = normalized.indexOf("\n---\n", 3); if (end === -1) throw new Error(`${page}: unterminated frontmatter`); - const body = text.slice(4, end + 1); + const body = normalized.slice(4, end + 1); const fm = { sources: [] }; let inSources = false; + let inForeignBlock = false; for (const [i, line] of body.split("\n").entries()) { const where = `${page}:${i + 2}`; if (!line.trim()) continue; @@ -58,8 +67,18 @@ function parseFrontmatter(text, page) { inSources = false; } - const kv = line.match(/^([a-z_]+):\s*(.*)$/); - if (!kv) throw new Error(`${where}: cannot parse frontmatter line: ${line.trim()}`); + // A key we do not read opened a block list or nested mapping; its indented body is + // not ours to validate. `sources:` never lands here — it sets inSources instead. + if (inForeignBlock && /^\s/.test(line)) continue; + inForeignBlock = false; + + const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/); + if (!kv) { + throw new Error( + `${where}: cannot parse frontmatter line: ${line.trim()}\n` + + ` (this gate reads flat \`key: value\` pairs plus a \`sources:\` block list)`, + ); + } const [, key, rawValue] = kv; const value = rawValue.trim().replace(/^["']|["']$/g, ""); @@ -75,6 +94,8 @@ function parseFrontmatter(text, page) { else throw new Error(`${where}: \`sources:\` must be \`[]\` or a block list`); continue; } + // An empty value on a key we do not read opens a block we should ignore, not police. + if (value === "") inForeignBlock = true; fm[key] = value; } return { fm, frontmatterEnd: end + 5 }; diff --git a/SECURITY.md b/SECURITY.md index f22b55f..f6a3ccc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,11 +21,13 @@ impact, the things worth knowing: `security add-generic-password -w `, so the value sits in that process's argv for the duration of the call. On a shared machine, assume another local user could observe it at that moment. -- **People are never _written_.** Persons and memberships are out of scope by design, and - `assertNotPeople` guards every write path. Reads are not guarded: `ct get raw /persons` - returns person records, because `ct get raw` is a deliberate general-purpose API - passthrough. The guarantee is that `ct` never creates, edits or deletes people — not - that it cannot display them. +- **People are never _written_.** Persons and memberships are out of scope by design. The + load-bearing control is the resource registry: writes can only target the structural + resource kinds it defines, and there is no person kind. `assertNotPeople` sits on top of + every write path as a belt-and-braces second check. Reads are not guarded: + `ct get raw /persons` returns person records, because `ct get raw` is a deliberate + general-purpose API passthrough. The guarantee is that `ct` never creates, edits or + deletes people — not that it cannot display them. - **Writes are opt-in and ordered.** `plan` is the default; `apply` needs explicit confirmation, protected environments additionally require the env name to be typed (or `--confirm-env`), and deletions never happen implicitly.