diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5658b97..c943da2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 22 cache: npm diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 51e437e..df29633 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -41,7 +41,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -55,8 +55,8 @@ jobs: staleness: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 22 - name: Check that documented sources are re-signed diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 005b5ea..cb9590d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,9 +30,9 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 cache: npm @@ -102,7 +102,7 @@ jobs: needs: build runs-on: macos-14 # Apple Silicon steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v4 with: name: release-assets @@ -113,7 +113,7 @@ jobs: needs: build runs-on: macos-15 # Apple Silicon; executes the x64 binary via Rosetta 2 (macos-13 Intel runners are retired and queue forever) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v4 with: name: release-assets @@ -124,7 +124,7 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v4 with: name: release-assets @@ -135,13 +135,13 @@ jobs: needs: [smoke-darwin-arm64, smoke-darwin-x64, smoke-linux-x64] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # semantic-release's commit-analyzer needs the full commit history since # the last tag to compute the next version — a shallow checkout breaks it. fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc17be2..e1d297d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,17 @@ # Contributing +## Operation projection rule + +User-facing behavior starts in the transport-neutral operation catalog. Define canonical parameter +and result schemas once, then provide both CLI and HTTP bindings in the same change. The Commander +tree, router, capabilities and OpenAPI enumerate that catalog; do not maintain an independent route +or command list. + +Adapters may map canonical input to flags/arguments or HTTP path/query/body fields and may render +results differently. They must not duplicate handlers, reconciliation, defaults, summaries, +confirmation policy or safety checks. Adapter-only mechanics such as pairing and terminal prompts +must be marked explicitly. Add or update catalog parity tests for every operation change. + 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 diff --git a/README.md b/README.md index 718462b..d18a831 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ code, and reconcile it against the ChurchTools API with Terraform-style **`plan` / `apply`**. > **People are never managed.** This tool touches only the scaffold, and only -> resources that are _explicitly_ declared or adopted. Everything else is -> invisible: never shown, never changed, never proposed for deletion. +> ct-cli resources that are _explicitly_ declared or adopted. External +> bindings are read-only prerequisites: they may be resolved and validated, +> but are never changed or proposed for deletion. ## Why @@ -213,9 +214,14 @@ ct auth status # who am I? (`--env ` asks on another instan ct get groups # JSON to stdout — pipe into jq (every page, not just the first) ct adopt campus 0 # bring ONE existing resource under management +ct unadopt campus mainz --env prod # stop managing it; keep the live object +ct use group 4711 --key shared_group # bind an existing object read-only +ct unuse group shared_group --env prod # remove the binding; keep the live object ct coverage # what the instance has that the config does not manage -ct state list # what is managed -ct state rm campus mainz # un-adopt: drop it from state. Never touches ChurchTools. +ct state list # managed and external entries, explicitly labelled +ct state rm campus mainz # low-level repair escape hatch; typed confirmation required +ct state rekey group old new # rename a logical key; update every ref.* use too +ct ownership check .. --env prod # validate visible owner/consumer projects ct plan # diff the config against ChurchTools (read-only) ct plan --format markdown # plain-language review report (German by default) ct apply # create + update in dependency order (confirm + backup first) @@ -231,14 +237,19 @@ printed — only the login token and its host reach the Keychain. There is delib Windows there is no Keychain to store anything in, so the prompt is not offered at all: export `CT_HOST` and `CT_LOGINTOKEN` there. -`state rm` is the inverse of `adopt`, and only of `adopt`: it removes the entry -from the state file, makes no HTTP call, and leaves the resource in place in -ChurchTools, now unmanaged. It refuses a key the config still declares — that -would make the next plan propose creating a resource that already exists — so -delete the declaration first, or pass `--force` to do both in one change. -"Declares" covers permission declarations too, not only resources: a key named -by a `ct.groupRole` domain or a group scope is just as broken to remove, and the -refusal is what keeps that from surfacing one command later as a plan error. +Use `unuse` for external bindings and `unadopt` for managed ownership. Both +commands make no HTTP call, leave the ChurchTools object in place, fail closed +when the config cannot be inspected, and refuse a key that remains declared or +referenced. They show a preview and require the environment name to be typed; +automation must pass an exactly matching `--confirm-env `. `--force` +overrides only the config-reference guard, never the typed confirmation. + +`state rm` remains a low-level repair escape hatch for either state partition. +It performs the same typed confirmation and a best-effort config-reference +check, warning when a broken config cannot be inspected, and points at `unuse` +or `unadopt` as the normal lifecycle command. A project without a named +environment types the logical key instead (or supplies `--confirm-key ` in +automation). `--dry-run` never needs confirmation and writes nothing. `apply` reconciles **creates and updates** only, saving state after each action (crash-safe / resumable). It **never deletes**: a resource dropped from the @@ -274,6 +285,19 @@ another instance. config does not manage, and which of it could be declared today (per group _and_ role, with the blocking scope dimension named). `--json` makes it a CI gate. +## Extension REST API + +Run ct-cli as the trusted execution service for a paired ChurchTools Extension: + +```bash +ct server --workspace . --allow-origin chrome-extension:// +``` + +The API is versioned below `/api/v1`, binds to loopback by default, publishes generated OpenAPI, +and projects the same typed application operations as Commander. See +[docs/rest-api.md](docs/rest-api.md) for pairing, process-input snapshots, trusted generators, +prepared apply, progress and secure reverse-proxy operation. + ## Environments and CI One config repo, several ChurchTools instances, no file edits when switching: @@ -290,6 +314,9 @@ ct apply --env prod # protected env: type the env name to confirm - [**CI usage**](docs/ci.md) — the auth model and token-from-secret setup, `--detailed-exitcode`, Markdown/JSON plan projections, deterministic sidecar names, drift-vs-config attribution, and copy-pasteable PR artifacts. +- [**External ct-cli resources**](docs/external-resources.md) — the terminology + boundary, read-only `ct use` workflow, identity checks, state operations, and + cross-project ownership analysis. ## Guardrails (by design) diff --git a/docs/README.md b/docs/README.md index d6a750c..7c87e69 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,7 +51,9 @@ A page with no code behaviour to track declares `sources: []` plus a | Page | About | | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | [`adoption-contract.md`](adoption-contract.md) | What else comes along when `ct adopt` adopts one resource — the five categories and their defaults | +| [`external-resources.md`](external-resources.md) | Read-only cross-project bindings, `ct use`, identity validation, state, and ownership checks | | [`api-coverage.md`](api-coverage.md) | Which ChurchTools endpoints support which CRUD verbs | | [`group-field-decisions.md`](group-field-decisions.md) | Which group fields are managed vs. left to the CT UI, and why | | [`runbook-manual-surface.md`](runbook-manual-surface.md) | What `ct` cannot automate today — where the write path is missing, and the manual steps around it | +| [`rest-api.md`](rest-api.md) | Versioned Extension API, pairing, generators, secure deployment and threat model | | `superpowers/` | Historical implementation plans; kept as a record, never published | diff --git a/docs/adoption-contract.md b/docs/adoption-contract.md index e28bee4..c71686b 100644 --- a/docs/adoption-contract.md +++ b/docs/adoption-contract.md @@ -127,8 +127,7 @@ ruleset, a calendar or a bookable room on an event. the thing that references it, at any depth, under any flag.** Adopting a resource booking does not adopt the room. -What is emitted instead, in order of preference — all of it already -implemented: +What is emitted instead, in order of preference: - **already managed** → a portable logical reference. `ReverseResolver.sugarFields` rewrites `campusId` → `campus: "…"`; `portablizeRuleset` rewrites entity ids @@ -138,6 +137,10 @@ implemented: `unmanaged`. `--strict-rulesets` already turns that from a warning into a refusal for rulesets; the same escalation applies to any category-4 reference. + The consumer can now replace that interim numeric form with an explicit + external binding (`ct use --key `) and the normal logical + `ref.*` form. See [External ct-cli resources](external-resources.md). + #### Why category 4 has no opt-in A `--with-referenced` flag would look harmless and would be the single most @@ -153,12 +156,19 @@ There are two supported remedies, and both make the ownership claim explicit: 1. `ct adopt ` on the referenced object — a deliberate, separate, visible act of taking ownership; -2. once #143 lands, declaring it as an **external / read-only prerequisite** — +2. declare it as an **external / read-only prerequisite** with `ct use` — resolvable in `ref` positions, never created, updated, deleted, or written. -Remedy 2 is the one this contract expects to become normal. Until it exists, -the numeric-id + `TODO` output is the honest interim: it says "this reference -is not portable yet" rather than pretending it is. +Remedy 2 is the normal consumer workflow. Numeric-id + `TODO` output remains an +honest signal from adoption that the reference is not portable until explicitly +bound; adoption never claims or writes the external object as a side effect. + +Lifecycle release is equally explicit and never means deletion. `ct unuse + --env ` removes only a consumer binding; `ct unadopt + --env ` relinquishes only managed ownership. Both leave the live +ChurchTools object untouched, refuse config references by default, and require +typed environment confirmation. Actual deletion remains exclusively `ct +destroy` and is restricted to managed state. ### 5. Person-related data — permanently excluded @@ -390,11 +400,11 @@ This issue decides only. The work the contract implies, roughly in order: one adopted without its member fields. 5. **The five-verb summary** replacing today's per-command ad-hoc output, and a test pinning the rendered summary (the same discipline `plan` output has). -6. **External/read-only references** — [#143](https://github.com/eqrm/ct-cli/issues/143). - Until it lands, category 4 falls back to numeric id + `TODO`; after it lands, - the emitted form for an unmanaged reference should become an external - declaration, which is what makes category 4's "no opt-in" rule comfortable - rather than merely correct. +6. **External/read-only references** — implemented by + [#143](https://github.com/eqrm/ct-cli/issues/143). Category 4 still emits a + numeric id + `TODO` until the consumer explicitly runs `ct use`; the persisted + external binding then makes its logical `ref.*` form portable without + transferring lifecycle ownership. ### How #135 applies this contract diff --git a/docs/api-coverage.md b/docs/api-coverage.md index d85a98c..ac9073e 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -24,12 +24,12 @@ Methods marked only if they actually exist on the matched path. "Update" = PUT o | 5 | `ct_group_role` | `/group/roles`, `/group/roles/{roleId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** (master-data roles). Per-group assignment lives separately at `/groups/{groupId}/roles` GET + `/groups/{groupId}/roles/{roleId}` PATCH. | | 6 | `ct_dynamic_group` | `/dynamicgroups` (GET), `/dynamicgroups/{groupId}/ruleset` (GET/PUT/DELETE), `/dynamicgroups/{groupId}/status` (GET/PUT), `/dynamicgroups/refresh` & `/dynamicgroups/{groupId}/refresh` (POST) | list ✅ / ruleset & status by-id ✅ | ⚠️ POST only triggers _refresh_, not create | ✅ PUT ruleset & status | ✅ ruleset DELETE | **Partial** — ruleset is fully updatable/deletable; the group entity itself is created via `/groups` (POST) then given a ruleset. No dedicated create/delete of the dynamic-group record. | | 7 | `ct_permission` | `/permissions/global` (GET), `/permissions/{domainType}` (GET), `/permissions/{domainType}/{domainId}` (GET/PUT/DELETE); `/permissions/internal/...` (GET) | list ✅ / by-id ✅ | — (no collection POST) | ✅ PUT sets permission | ✅ | **Writable** — assign/revoke via PUT/DELETE on `/{domainType}/{domainId}` | -| 8 | `ct_group_status` | none — see note | ❌ | ❌ | ❌ | ❌ | **No REST endpoint at all → manual, permanently (#67).** `/group/memberstatus` looks like a match but is a DIFFERENT dimension — **member** statuses (`{id: "active", name: "Active"}`, STRING ids, assignable per-membership), not group statuses (`groupStatusId`, numeric, e.g. 1 = active / 4 = archived). Live-verified 2026-07-10 on eqrm prod: `/groups/statuses` parses as `/groups/{groupId}` (400), `/group/statuses` and `/groupstatuses` both 404 — no read OR write endpoint for group statuses exists. (`/statuses` + `/statuses/{id}` DO offer full CRUD, but that is the person/community **Status** master data, tag `Status`, a third, unrelated dimension — do not conflate any of the three.) `groupStatusId` stays a plain numeric field on `ct_group`, authored directly — never resolved by name. | +| 8 | `ct_group_status` | `/person/masterdata` → `groupStatuses` | ✅ nested read-only catalog | ❌ | ❌ | ❌ | Technical `name` is the stable logical key; numeric IDs are host-local. Live-verified 2026-08-27. Distinct from `/group/memberstatus` (membership statuses) and `/statuses` (person/community statuses). Numeric `groupStatusId` remains an escape hatch. | | 9 | `ct_age_group` | `/group/agegroups`, `/group/agegroups/{ageGroupId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | | 10 | `ct_target_group` | `/group/targetgroups`, `/group/targetgroups/{targetGroupId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | | 11 | `ct_meeting_point` | — none — | ❌ | ❌ | ❌ | ❌ | **Not in API → fully manual.** Zero matches for `treffpunkt`/`meetingpoint`/`meeting point` anywhere in the spec. Closest neighbours are _meeting templates_ (`/group/meetingtemplates`, full CRUD) and _group meetings_ (`/groups/{groupId}/meetings`, CRUD) — different concepts; confirm with product whether "meeting point" was meant to be one of those. | | 12 | `ct_relationship_type` | `/person/relationshiptypes`, `/person/relationshiptypes/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | -| 13 | `ct_person_status` | `/statuses`, `/statuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD, live-verified 2026-08-13** (eqrm prod, CT 3.135.2, read from the instance OpenAPI spec): `/statuses` → GET, POST; `/statuses/{id}` → GET, PUT, DELETE. `POST` requires `name`, `shorty`, `isMember`; `PUT` requires ALL of `name`, `shorty`, `isMember`, `isSearchable`, `sortKey`, `securityLevelId` — uniquely strict among managed types (every other managed PUT declares no required fields), and since PUT is a full replace the registry manages all six rather than a subset. The person/community **Status** master data (tag `Status`): "0 - First", "3 - Group Active", …, the domain a `ct.status` permission grant hangs off. Adoptable since #96, which is what makes a config using that domain self-sufficient across hosts. Do NOT conflate with `ct_group_status` (row 8, no endpoint at all) or `/group/memberstatus` (member statuses, string ids). Master data — never a person record; the people guard is unaffected. | +| 13 | `ct_person_status` | `/statuses`, `/statuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD, live-verified 2026-08-13** (eqrm prod, CT 3.135.2, read from the instance OpenAPI spec): `/statuses` → GET, POST; `/statuses/{id}` → GET, PUT, DELETE. `POST` requires `name`, `shorty`, `isMember`; `PUT` requires ALL of `name`, `shorty`, `isMember`, `isSearchable`, `sortKey`, `securityLevelId` — uniquely strict among managed types (every other managed PUT declares no required fields), and since PUT is a full replace the registry manages all six rather than a subset. The person/community **Status** master data (tag `Status`): "0 - First", "3 - Group Active", …, the domain a `ct.status` permission grant hangs off. Adoptable since #96, which is what makes a config using that domain self-sufficient across hosts. Do NOT conflate with `ct_group_status` (row 8, read-only nested catalog) or `/group/memberstatus` (member statuses, string ids). Master data — never a person record; the people guard is unaffected. | | 14 | `ct_department` | `/departments` | list ✅ / by-id ❌ | ❌ | ❌ | ❌ | **No REST write path — a ref catalog here, not a managed resource.** Live-probed 2026-08-13 (eqrm prod, CT 3.135.2): `GET /departments` returns `[{id, name, nameTranslated, sortKey, shorty}]`; no `POST`/`PUT`/`DELETE` on `/departments` exists in the spec, and there is no `/departments/{id}` path at all. Bereiche are the `cdb_bereich` permission scope dimension (`churchdb:view alldata`), so `ct` resolves them BY NAME for a `scope: [{ department: "…" }]` reference (#98) and surfaces them via `ct get departments` — but `ct.department` declares one and `ct adopt department ` adopts one. **Managed since #108** — `ct` writes Bereiche through the legacy `POST /index.php?q=churchdb/ajax` `func=saveMasterData` interface the admin UI uses (create/update/delete all verified live on eqrm-dev 2026-08-14). It appears in no OpenAPI spec, which is why an OpenAPI-only audit could never have found it (#111). Note there is no `/departments/{id}` at all, so a single Bereich is read by filtering the collection. | | 15 | `ct_group_member_field` | `/groups/{groupId}/memberfields` (GET), `/groups/{groupId}/memberfields/group` (POST), `/groups/{groupId}/memberfields/group/{groupMemberFieldId}` (PATCH/PUT/DELETE) | list ✅ (per group) / by-id ❌ | ✅ | ✅ PATCH (PUT fallback) | ✅ | **Full CRUD, but GROUP-SCOPED — not a standalone resource (#135).** A member field belongs to exactly one group and is not globally reusable, so it has no collection path of its own and no entry in the resource registry: it is a _synthetic owned sub-resource_ of `ct_group`, declared inside `ct.group({ memberFields: [...] })`, exactly like hierarchy `parents` and the dynamic ruleset. Its portable identity is the managed group key plus a local field key (`ojbp_2026_27_praktikum_1::wahl`); a ChurchTools field id never reaches authored config or an adopted blueprint. Reads are narrowed to rows the `/memberfields/group` endpoints own — the same GET also returns fields sourced from person master data / group-type defaults, which are neither adoptable nor writable here. `apply` NEVER deletes one (a field dropped from config produces no desired diff key at all); removal is the explicit `ct destroy --member-field ::`. Update uses `PATCH` (partial, so unmanaged siblings survive) and falls back to `PUT` on a 405/501. Definitions only — never a per-person value; `assertNotPeople` still guards every path. Distinct from the group CUSTOM fields of #48/#60 (`/dbfields`, `fieldCategory.table == "cdb_gruppe"`), which describe the group RECORD rather than what its members are asked. | @@ -71,7 +71,7 @@ No collection POST; state is set/removed through PUT/DELETE on the item path. Mo ### Read-only / not in API → keep manual for now — 2 resources -- `ct_group_status` — **no REST endpoint at all, read or write** (#67; corrected 2026-07-10 — a prior version of this table wrongly matched `GET /group/memberstatus`, which is actually **member** statuses, a different dimension with string ids). `groupStatusId` remains a plain numeric field. (Do not substitute `/statuses` either — that's person-status master data, a third dimension.) +- `ct_group_status` — read-only nested catalog at `GET /person/masterdata` → `groupStatuses` (#157). `/group/memberstatus` is a different membership-status dimension; `/statuses` is person-status master data. No group-status write endpoint is known. - `ct_meeting_point` — **no endpoint at all**; cannot be automated until CT ships one (or until "meeting point" is redefined onto meeting-templates/meetings, both of which are full CRUD). ### Version gate diff --git a/docs/configuration.md b/docs/configuration.md index 0358b0e..3f12b70 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,11 +50,11 @@ where it was used. Raw numeric ids remain a valid escape hatch everywhere; see [`examples/portable.config.ts`](https://github.com/eqrm/ct-cli/blob/main/examples/portable.config.ts) for a zero-numeric-id config. -**Exception: `groupStatusId` (a group's lifecycle status) is numeric-only, -always.** ChurchTools exposes no REST catalog to resolve a status by name -(`/group/memberstatus` is a different dimension, member statuses; #67), so a -`status:` declaration fails fast at eval time rather than resolving against the -wrong dimension. +For a group's lifecycle status, prefer the stable technical name, for example +`status: "active"`. ct resolves it through +`/person/masterdata.groupStatuses`. Numeric `groupStatusId` remains supported as +a backward-compatible escape hatch. This catalog is distinct from +`/group/memberstatus` (membership statuses) and `/statuses` (person statuses). ## Campus assignment diff --git a/docs/external-resources.md b/docs/external-resources.md new file mode 100644 index 0000000..9dbe7c7 --- /dev/null +++ b/docs/external-resources.md @@ -0,0 +1,192 @@ +# External ct-cli resources + +Independent ct projects can share one ChurchTools object without sharing its +lifecycle. The owner project declares or adopts it. Each consumer records a +host-specific, read-only binding with `ct use` and keeps using the normal +portable `ref.*` form in config. + +```bash +# owner project: lifecycle ownership +ct adopt group 4711 --key ojahr_fuzzies --env prod + +# consumer project: read-only consumption +ct use group 4711 --key ojahr_fuzzies --owner shared-masterdata --env prod +``` + +```ts +ref.group("ojahr_fuzzies"); +``` + +There is deliberately no `ct.external.*` config DSL. The config stays portable; +state binds its logical keys to ids separately on every ChurchTools host. + +## Terminology boundary + +ChurchTools does not use one official umbrella term for the objects in the +ct-cli registry, and it uses “Resource” for other product concepts. In this +documentation: + +- **ct project** means one config plus its environment-specific state files and + therefore one lifecycle boundary. +- **ct-cli resource** means an independently addressable top-level object in + the resource registry. It does not mean a ChurchTools “Resource” feature. +- **managed** means this ct project owns lifecycle responsibility. Its plan, + apply, and an explicit destroy may act on the object. +- **external** means this ct project can resolve the object read-only but does + not own it. This is ct-cli state only; no ChurchTools flag is written. +- **owner project** is the project with the managed state entry; a **consumer + project** has an external binding. +- A **logical key** is the portable, user-controlled name used by `ref.*`. It is + never stored in ChurchTools. +- A **host binding** maps `(ct-cli resource type, logical key)` to a ChurchTools + id for one host. +- An **identity snapshot** is the minimal live identity used to detect a changed + binding meaning. It is validated, never reconciled by the consumer. +- A **coordination scope** is only the explicit directory tree inspected by + `ct ownership check`; it is not a ChurchTools boundary. + +## Creating a binding + +The deterministic form is suitable for scripts and CI. Both id and key are +mandatory outside a terminal: + +```bash +ct use group 4711 --key ojahr_fuzzies --env prod +``` + +In a terminal, a string selector performs fuzzy discovery within the explicit +type and shows every match with its id, exact name, and disambiguating fields: + +```bash +ct use group "OJAHR Fuzzies" --env prod +``` + +The command never guesses among multiple matches. It proposes an existing +consumer key when the id is already bound; otherwise it proposes a one-time +slug that can be edited. A logical key is never recalculated later. + +`ct use` reads ChurchTools to validate the selected object but never writes it. +It is byte-idempotent: the same key, id, owner metadata, and hard identity is a +successful no-op and does not update `boundAt`. A hard identity change or a +replacement id requires interactive confirmation or `--yes`; the command shows +the changed fields or old/new targets first. Display-only changes need no +confirmation. Managed keys and ids cannot be rebound as external, and neither a +key nor `(type, id)` may have a second external alias. + +Supported top-level types come directly from the registry: `campus`, `group`, +`group-type`, `age-group`, `target-group`, `relationship-type`, +`person-status`, `department`, `security-level`, `comment-viewer`, and +`group-role`. Permissions, relationship edges, owned child structures, and all +person-related data are not independent external types. + +## State and identity + +State version 2 keeps lifecycle ownership and consumption separate: + +```json +{ + "version": 2, + "host": "https://example.church.tools", + "resources": {}, + "externals": { + "ojahr_fuzzies": { + "type": "group", + "key": "ojahr_fuzzies", + "id": 4711, + "owner": "shared-masterdata", + "identity": { "name": "OJAHR Fuzzies", "groupTypeId": 17 }, + "boundAt": "2026-08-27T12:00:00.000Z" + } + } +} +``` + +Version-1 files load in memory as version 2 with an empty `externals` map and +are written as version 2 on the next state mutation. Managed entries remain in +`resources`; external entries never carry managed fields or lifecycle flags. + +The registry defines hard identity: name for every type, plus group type id for +groups and group roles. Short names, translated names, campus/status, sort +order, relationship labels, member-status meaning, numeric security level, and +leader/participant role type are selection display only. Thus moving a group to +another campus does not block a consumer plan; renaming it or changing its group +type does. + +Inspect or maintain either state partition with the shared commands: + +```bash +ct state list --env prod # managed and external, with explicit kind +ct state list --managed --env prod +ct state list --external --env prod +ct unuse group ojahr_fuzzies --env prod +ct unadopt group owned_group --env prod +ct state rekey group old_key new_key --env prod +``` + +`unuse` removes only external bindings; `unadopt` removes only managed ownership. +Neither contacts ChurchTools or deletes the live object. Both first check every +known config declaration and `ref.*` position, show the exact entry and state +file, then require the environment name to be typed. A referenced key blocks by +default; `--force` overrides that check only when the config and state changes +are deliberately made together. `--dry-run` previews without confirmation or a +write. In non-interactive use, confirmation remains explicit: + +```bash +ct unuse group ojahr_fuzzies --env prod --confirm-env prod +``` + +`ct state rm` remains the low-level repair escape hatch and carries the same +typed confirmation plus a best-effort reference check; unlike the public +lifecycle commands it can proceed with a warning when a broken config cannot be +inspected. Prefer `unuse`/`unadopt`. Rekeying requires every config and `ref.*` +use to be changed consistently. + +## Planning and safety boundary + +Resolution checks managed state first, same-run managed declarations second, +and persisted external bindings third. A bound external is read live by id and +its hard identity is verified before use in supported positions such as +permissions, hierarchy parents, and dynamic rulesets. + +An external is never a desired or pending resource. It therefore cannot emit a +create, update, or delete action; consumer `apply` and `destroy` enumerate only +managed state. If an external prerequisite is missing, stale, ambiguous, or has +changed identity, plan fails before writes. Discovery may provide complete +`ct use` commands, but plan never persists or temporarily consumes a candidate. +The consumer never applies or repairs the owner project. + +Blocking diagnostics include a stable reason code and structured context, +evidence, consequence, numbered remedies, and an exact verification command. +Typical recovery is one of: + +```bash +# create/repair the object from its owner project first +cd ../shared-masterdata && ct plan --env prod + +# bind the verified live object in the consumer +ct use group 4711 --key ojahr_fuzzies --env prod +ct plan --env prod +``` + +Do not bind an id that returns 404. Repair the owner's stale state or restore the +object first. + +## Checking ownership across projects + +Run the check with an explicit complete directory scope: + +```bash +ct ownership check .. --env prod +``` + +It recursively finds ct projects below that root, ignores `.git`, +`node_modules`, and build output, groups results by ChurchTools host, and makes +no network calls. It reports duplicate managed owners, missing or mismatching +owner hints, different keys for the same `(type, id)`, conflicting bindings, +and incompatible identity snapshots. Conflicts return a non-zero exit code for +CI and include `ct state rekey`, `ct unuse`, `ct unadopt`, or broader-scope +remediation. + +The guarantee is intentionally scope-limited. Projects outside the supplied +root remain unknowable; global atomic ownership would require a separate shared +registry. diff --git a/docs/handbuch/blueprints.md b/docs/handbuch/blueprints.md index b0aef90..d262773 100644 --- a/docs/handbuch/blueprints.md +++ b/docs/handbuch/blueprints.md @@ -4,8 +4,8 @@ sources: - src/config/context.ts - src/engine/graph.ts - src/engine/hierarchy.ts -sources_hash: 6f4be8d3a93113ce -reviewed: 2026-08-28 +sources_hash: 56acba8415249650 +reviewed: 2026-08-29 --- # Blueprints (parametrized, reusable config) @@ -54,7 +54,13 @@ freshly-created id at apply time (tier ordering creates the campus first). `ct plan` renders it as `campusId = `. The same portability applies to the group type: `groupType: "ministry_team"` -resolves against the live catalog per host, no hardcoded `groupTypeId`. +resolves through that host's managed or explicitly external state, with no +hardcoded `groupTypeId`. If another ct project owns it, bind it once per host +with `ct use group-type --key ministry_team`; plan never guesses from the +live catalog. +The group's lifecycle status is the read-only exception: `status: "active"` resolves +through `/person/masterdata.groupStatuses`, whose numeric ids may differ by +host. `groupStatusId` remains the numeric escape hatch (#157). ```ts function kidsArea(ct: ConfigContext, campus: string): void { @@ -69,8 +75,8 @@ The **numeric escape hatch** stays available: pass `campusId: ` `groupTypeId: 2` to target one instance's id directly. `ct plan` diffs a campus assign/move/clear as a normal field update — see [`docs/group-field-decisions.md`](https://github.com/eqrm/ct-cli/blob/main/docs/group-field-decisions.md). Declaring both the -logical and the numeric form for one field (`campus` + `campusId`) is a conflict -and throws at eval time. +logical and the numeric form for one field (`campus` + `campusId`, `status` + +`groupStatusId`) is a conflict and throws at eval time. ## The loop-over-campuses pattern and `${campus}_`-prefixed keys @@ -225,20 +231,22 @@ per-group declaration order) → permission grants, for as many campuses as the loop instantiates, with no manual `parent:`/`dependsOn` bookkeeping beyond the `parents: [lead]` you'd write anyway. -## The managed-parent typo guard +## Parent-reference validation -`parents` references are validated **at config-evaluation time**, before -any plan or diff is computed (`validateReferences` in -[`src/config/context.ts`](https://github.com/eqrm/ct-cli/blob/main/src/config/context.ts), run by -`evaluateConfig`). Every key listed in a `parents` array must resolve to a -`group` declared _somewhere in the same config_ — including inside a -blueprint function called from the top-level export. A typo, a forgotten -`kidsArea(ct, campus)` call, or a `parents` key pointing at a non-group -resource throws immediately: +`parents` references are checked in two stages. Config evaluation +(`validateReferences` in +[`src/config/context.ts`](https://github.com/eqrm/ct-cli/blob/main/src/config/context.ts)) immediately rejects a key +that is declared as a non-group resource. A key not declared in this config is +allowed to continue because it may name an external parent recorded in this +host's state. Plan then resolves it as a group and validates a bound external's +live hard identity before any write. A key in neither managed nor external state +blocks plan with a copyable `ct use group --key ` remedy when discovery +finds a candidate. ``` -Group "berlin_kids_0_3" declares hierarchy parent "berlin_kids_laed", which is not declared in this config. -Managed parents must reference a group by its key (omit unmanaged parents entirely). +External prerequisite is not available +resource: group "berlin_kids_laed" +Consequence: Consumer plan/apply is blocked before writes. ``` The same pass validates **group member field references** (#135): a @@ -255,12 +263,13 @@ example `key: "stand_bewerbung"` plus `referenceName: "stand-bewerbung"`. Ruleset resolution follows that mapping; it never treats `-` and `_` as the same API identity. -This matters more in a blueprint than in a hand-written flat config, +This staged check matters more in a blueprint than in a hand-written flat config, because the `${campus}_`-prefixed key is itself computed (`` `${campus}_kids_lead` ``, not a literal string) — a copy-paste slip in one branch of a blueprint (e.g. reusing `mainz`'s lead key inside the `berlin` iteration) is exactly the kind of mistake this guard exists to -catch before it ever reaches `ct plan` against a live instance. +catch before any apply write. Locally declared wrong types fail offline; unknown +keys are checked against host-bound state and ChurchTools during plan. ## Full example diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index edff036..ede392e 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,8 +6,8 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/application/operations/adopt-group.ts -sources_hash: e38b8c0f6032d5cc -reviewed: 2026-08-28 +sources_hash: 10156bf646d5b7a6 +reviewed: 2026-08-29 --- # Auto-groups (dynamic groups) @@ -157,19 +157,23 @@ no-op — it does not re-`PUT` on every apply). Two equivalent ways to author it { "==": [{ "var": "ctgroup.campusId" }, { "__ctRef": true, "kind": "campus", "key": "mainz" }] } ``` - Simple marker `kind`s carry a single `key` (the logical key / slug): - `campus`, `group`, `group-type`. A **role** (`role.id`) uses the compound + Simple marker `kind`s carry a single logical `key`, including `campus`, + `group`, `group-type`, and `group-status`. Registry-backed keys resolve from + managed state or a persisted external binding; an unbound live catalog match + is diagnostic only and blocks plan until `ct use` records the binding. A group + status is the read-only exception and resolves directly from + `/person/masterdata.groupStatuses` (#157). A **role** (`role.id`) uses the compound `group-type-role` marker instead — `{ "__ctRef": true, "kind": "group-type-role", "groupType": "", "role": "" }` — because a ruleset's `role.id` is a **groupTypeRoleId** (a role scoped to a group type), and role names are not globally unique (see the table note below). See `ref` in `src/resolve/refs.ts`. -**Escape hatch — raw numeric ids pass through untouched.** A query that -references an _operational_ group outside the managed scaffold (no logical key to -resolve against) can keep the plain number; you then own its per-environment -correctness. This mirrors the permission scope escape hatch (#49): prefer a -reference, fall back to a number where no managed key exists. +**Escape hatch — raw numeric ids pass through untouched.** A query can keep a +plain number, but you then own its per-environment correctness. For a shared +top-level object, prefer `ct use --key ` plus a logical +reference: the consumer can resolve it without gaining create/update/delete +authority. This mirrors the permission scope escape hatch (#49). #### Auto-rewrite on capture (default since #101; was `--portable-rulesets`, #76) @@ -210,14 +214,15 @@ position that maps to a **managed** logical key is rewritten to its `{ __ctRef } marker; every other id is left numeric. The `var → RefKind` catalog it keys off (`VAR_REF_KINDS`) is: -| ChurchQuery `var` | marker `kind` | source catalog / state | -| --------------------- | ----------------- | -------------------------------------------------- | -| `ctgroup.id` | `group` | managed state (no REST catalog) | -| `ctgroup.campusId` | `campus` | `/campuses` | -| `person.campusId` | `campus` | `/campuses` | -| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` | -| `role.id` | `group-type-role` | `/group/roles` (by `groupTypeId` + name) | -| `role.id` | `role-def` | managed state — only when the pair collides (#125) | +| ChurchQuery `var` | marker `kind` | source catalog / state | +| ----------------------- | ----------------- | -------------------------------------------------- | +| `ctgroup.id` | `group` | managed state (no REST catalog) | +| `ctgroup.campusId` | `campus` | `/campuses` | +| `person.campusId` | `campus` | `/campuses` | +| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` | +| `ctgroup.groupStatusId` | `group-status` | `/person/masterdata` → `groupStatuses` | +| `role.id` | `group-type-role` | `/group/roles` (by `groupTypeId` + name) | +| `role.id` | `role-def` | managed state — only when the pair collides (#125) | The same `group-type-role` rewrite also covers the **out-of-query** integer field `process.*.handleMembership.groupTypeRoleId` (the target role a @@ -305,7 +310,7 @@ At **capture** time (`ct adopt … --with-dynamic`) the state file and the ```text ! rulesets/jugend.json keeps 5 host-specific id(s) — NOT portable to another host: ctgroup.id: 1246 left numeric — not under management — `ct adopt group ` for each (then re-adopt) makes them portable - ctgroup.groupStatusId: 1, 2 left numeric — group statuses have no REST catalog (#67) — no logical form exists + ctgroup.groupStatusId: 99 left numeric — no group-status catalog row on this host carries these ids person.id: 5703, 4389 left numeric — person ids are NEVER portable — ct does not manage people, so this ruleset names DIFFERENT people on another host. Remove the clause or accept the divergence ``` @@ -345,9 +350,9 @@ reason it never checked: The capture-time reasons are distinct because the fixes are: an **unmanaged** target (adopt it), a **role unknown to `/group/roles`**, a role whose **group -type is unmanaged**, or a dimension with **no logical form at all** -(`ctgroup.groupStatusId` — group statuses have no REST catalog, #67; this one -needs no lookup, so the plan-time scan reports it too). +type is unmanaged**, or an **unknown group-status id**. Group statuses normally +resolve through `/person/masterdata.groupStatuses`; an id absent from that live +catalog stays numeric and is reported. **`--strict-rulesets`** turns the warning into a refusal: adopt writes nothing if the ruleset would still contain a host-specific id. Use it in a repo that has diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index 1da01b5..c71869b 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,5 +1,5 @@ --- -sources_hash: c18b710bff24503d +sources_hash: 8a568579911223ed title: Group member fields sources: - src/engine/member-fields.ts @@ -7,7 +7,7 @@ sources: - src/config/context.ts - src/application/operations/adopt-group.ts - src/application/operations/destroy.ts -reviewed: 2026-08-28 +reviewed: 2026-08-29 --- # Group member fields (#135, #158) diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 4b2c46a..7a856cd 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -7,8 +7,8 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: c3a6740c4d4bc134 -reviewed: 2026-08-28 +sources_hash: 31250c52e97dfbaf +reviewed: 2026-08-29 --- # Permissions (`ct.groupRole` / `ct.groupTypeRole` / `ct.status`) @@ -80,7 +80,7 @@ person-status rights — as code, and reconcile them idempotently with the same export default (ct) => { ct.groupTypeRole({ key: "leiter_tpl", // logical key (unique across the whole config) - groupType: "ministry_team", // domain BY NAME — resolved to the domainId per host (#20) + groupType: "ministry_team", // logical key — resolved from managed/external state per host grants: [ "churchgroup:view group", // unscoped { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped @@ -97,7 +97,7 @@ export default (ct) => { ct.status({ key: "core_external_login", - personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90) + personStatus: "core", // logical person-status key, managed or explicitly external // -1 is ChurchTools' "all values of this dimension" sentinel (here: every external system). grants: [{ right: "churchcore:login to external system", scope: [-1] }], }); @@ -111,24 +111,24 @@ reference or a numeric `id`: namespace with every other resource type). - **domain** — the permission domain object. Declare it **by reference** (the portable form, #20) or **by numeric `id`** (the escape hatch): - - `ct.groupTypeRole` — `groupType: ""` resolves against the live - group-type catalog per host, or `id: ` targets one directly. + - `ct.groupTypeRole` — `groupType: ""` resolves from managed or external + host state, or `id: ` targets one directly. - `ct.groupRole` — `group: "", role: ""` resolves the (group, role) pair to its pairing domainId per host (#25), or `id: ` - targets one directly. The group must be **managed** (declared via `ct.group` - or adopted into state); it need not exist on the host yet — a group declared + targets one directly. The group may be managed (declared via `ct.group` or + adopted into state) or explicitly external via `ct use group`; a group declared in the same config plans as a pending domain and is granted later in that same `ct apply`, once it exists (#106). Declaring both a logical form and a numeric `id` is a conflict and throws. See "domainId semantics" for how the pairing id is resolved. - - `ct.status` — `personStatus: ""` resolves against the live - `/statuses` catalog per host, or `id: ` targets one directly. + - `ct.status` — `personStatus: ""` resolves from managed or external + host state, or `id: ` targets one directly. **Person** statuses ("0 - First", "3 - Group Active", …), not group statuses — see "domainId semantics". - **`grants`** — an array of `Grant`s, each either: - a bare string, `"module:right"` — an **unscoped** grant, or - an object `{ right: "module:right", scope: [...] }` — a **scoped** grant, - where each `scope` entry is a logical key of a managed group, a typed + where each `scope` entry is a logical key of a managed or external group, a typed logical reference such as `{ campus: "koblenz" }` (#98), or a raw numeric `dataId` (the escape hatch, #49). See "Scope resolution" below. @@ -220,7 +220,7 @@ The two DSL functions manage two different ChurchTools "domain types," and - **`group_type_role`** (`ct.groupTypeRole`) — the domain is the **group type's own id** (the same id you'd pass as `groupTypeId` on `ct.group`). It scopes the grant to "every role holder of this group type." Declare it portably as - `groupType: ""` (resolved per host, #20) or directly as `id: `. + `groupType: ""` (resolved per host from state, #20/#143) or directly as `id: `. - **`group_role`** (`ct.groupRole`) — the domain is the **internal (group, role) pairing's own id** — a ChurchTools-internal id for one specific group's specific role, _not_ the group's id and _not_ the role's @@ -236,10 +236,12 @@ The two DSL functions manage two different ChurchTools "domain types," and check, not a truthiness one. > **Person status ≠ group status.** `groupStatusId` (`ct.group`) is a - > different dimension with **no** REST catalog at all (#67) and must always be - > written as a number. Person statuses do have one (`GET /statuses`, flat - > array of `{id, name}` — live-verified 2026-08-10 on eqrm prod), so they - > resolve by name like campuses and group types. + > different dimension. Its read-only catalog is nested under + > `GET /person/masterdata` → `groupStatuses`, so `status: "active"` resolves + > by technical name (#157); numeric `groupStatusId` remains an escape hatch. + > Person statuses instead use `GET /statuses` (flat array of `{id, name}` — + > live-verified 2026-08-10 on eqrm prod) and use the same logical-key binding + > model as campuses and group types. Since #96 the status itself is also **declarable**, via `ct.personStatus`: @@ -248,14 +250,11 @@ The two DSL functions manage two different ChurchTools "domain types," and ct.status({ key: "3_group_active_login", personStatus: "3_group_active", grants: [...] }); ``` - **Key it as `slug(name)`.** A `personStatus:` reference resolves against - managed state first and the live `/statuses` catalog second, and the catalog - matches by `slug(name)` — so a key that does not slug from the name (`"core"` - for `"5 - Core"`) can only ever match the declaration. On a host that already - has that status but has not adopted it, the plan then **creates a second, - identically-named status** and grants on the new one, leaving the real one - untouched. `ct adopt person-status ` emits `slug(name)` for this reason; - match it. + The logical key is user-controlled and need not equal `slug(name)`. `ct adopt` + derives a readable initial key; `ct use person-status --key ` records + a read-only host binding when another project owns the status. A declaration + still means lifecycle ownership and may create the status if it is absent from + this project's managed state. > **Teardown caveat.** A person status is the one managed type whose deletion > reaches person _records_: dropping the declaration and running `ct destroy` @@ -266,12 +265,10 @@ The two DSL functions manage two different ChurchTools "domain types," and > status is load-bearing. That is what makes a config using the `status` domain self-sufficient across - hosts. Before it, `personStatus: "…"` could only resolve against statuses that - already existed on the target instance, so a config that planned to a clean - no-op on prod died on dev with _"no managed resource and no live person-status - at /statuses matches key …"_ — whose own advice ("Declare/adopt it") was not - actually possible. A status declared in the same config resolves to a pending - domain and converges in one `ct apply`, exactly like a same-run group type. + hosts. A status declared in the same config resolves to a pending domain and + converges in one `ct apply`, exactly like a same-run group type. A shared status + owned elsewhere is never pending: the consumer must bind the already-existing + object with `ct use`, and missing/stale bindings block before writes. > **VERIFIED LIVE (2026-08-13, CT 3.135.2).** The reference form resolves by > reading the group's own role list (`GET /groups/{groupId}/roles`) and taking @@ -291,8 +288,8 @@ The two DSL functions manage two different ChurchTools "domain types," and > hardcode it like any other domainId. Resolution runs in `buildPermissionPlan` (`src/permissions/plan.ts`): a numeric -`id` passes straight through; a `groupType` reference resolves against the live -catalog, and a `group` + `role` pair against the group's role list. After +`id` passes straight through; a `groupType` reference resolves from managed or +external state, and a `group` + `role` pair against the group's role list. After resolution, two declarations that resolve to the **same** `(domainType, domainId)` are rejected (they would otherwise diff against each other's grants forever) — even if one used a name and the other a raw id. @@ -313,7 +310,8 @@ handled as a **pending domain** rather than aborting the plan: re-resolution machinery as resource pending refs. - The hard error (`references a resource created in the same run` → now only a genuine unresolvable) is reserved for references that resolve to **nothing**: - a key absent from the config, state, and the live catalog (a typo). + a key absent from the config and both managed/external state partitions. Live + discovery can explain a candidate but never supplies an ephemeral id. **`group_role` behaves the same way since #106.** A `group_role` domain id is the (group, role) **pairing** id, which only exists on @@ -417,7 +415,8 @@ forms (`src/permissions/scope.ts`): | **Typed logical reference** (#98) | `scope: [{ campus: "koblenz" }]` | campuses, group types — see below | | **Raw numeric `dataId`** (escape hatch, #49) | `scope: [1, 2, 3]` | any dimension | -String entries are resolved against **desired ∪ state**: +String entries are resolved against **desired managed resources ∪ managed state +∪ external state**: - A key already in state resolves to that group's `dataId`. - A key **declared in this config but not yet created** resolves to a _pending_ @@ -427,9 +426,10 @@ String entries are resolved against **desired ∪ state**: - A key that is neither in state nor declared throws: ``` - Scope key "kids_area" does not resolve to a managed group. Declare/adopt it, - use a group already under management, or pass a raw numeric dataId if this - right's scope is not a group (see the catalog's scopeField). + External prerequisite is not available: group "kids_area". + Bind the verified live group with `ct use group --key kids_area`, + declare/adopt it in its owner project, or pass a raw numeric dataId if this + right's scope is not a group. ``` The requirement that scope targets be tool-visible is deliberate: so `ct plan` @@ -463,21 +463,21 @@ ct.groupRole({ `{ campus: "koblenz" }` is sugar for `ref.campus("koblenz")` — the same `Ref` the rest of the DSL uses — so both spellings are interchangeable. -| `scopeField` | Reference form | Resolved against | -| -------------------- | ----------------------------------------- | ----------------------------------------------------------------- | -| `cdb_gruppe` | `{ group: "" }` (or the bare string) | managed groups | -| `cdb_station` | `{ campus: "" }` | managed campuses, then `GET /campuses` | -| `cdb_gruppentyp` | `{ groupType: "" }` | managed group types, then `GET /group/grouptypes` | -| `cdb_bereich` | `{ department: "" }` | managed Bereiche, then `GET /departments` (#108) | -| `cc_securitylevel` | `{ securityLevel: "" }` | managed security levels, then `GET /securitylevels` (#110) | -| `cdb_comment_viewer` | `{ commentViewer: "" }` | managed comment viewers, then `GET /person/commentviewers` (#151) | +| `scopeField` | Reference form | Resolved against | +| -------------------- | ----------------------------------------- | ----------------------------------- | +| `cdb_gruppe` | `{ group: "" }` (or the bare string) | managed or external groups | +| `cdb_station` | `{ campus: "" }` | managed or external campuses | +| `cdb_gruppentyp` | `{ groupType: "" }` | managed or external group types | +| `cdb_bereich` | `{ department: "" }` | managed or external Bereiche | +| `cc_securitylevel` | `{ securityLevel: "" }` | managed or external security levels | +| `cdb_comment_viewer` | `{ commentViewer: "" }` | managed or external comment viewers | Two `Ref` kinds are deliberately **not** in that table because no permission dimension scopes by them: `group-type-role` (a `groupTypeRoleId`, addressed by its `(group type, role name)` pair — #76) and `group-member-field` (a group-scoped member-field definition, addressed by its portable `(group key, local field key)` pair — #135). They share this file's resolver and -its "managed state first, then live lookup, else a hard error at plan time" +its "managed state first, then external binding, else a hard error at plan time" rules, but they are referenced from **dynamic-group rulesets**, not from grant scopes. For `group-member-field`, the logical pair selects a declaration; that declaration's exact ChurchTools `referenceName` selects the live row (#158). @@ -489,14 +489,12 @@ therefore a cross-environment misgrant, and because declaring a domain makes `ct` _own_ it, the wrong-scope grant also revokes whatever is really there on the other host. The typed reference makes one config plan clean on both. -Resolution mirrors the domain-reference rules: managed state first, the live -master-data catalog second, and a target **declared in this same config** -resolves to a _pending_ scope re-resolved at apply time. A reference resolved -through the catalog (not under management) carries an already-final id and is -not re-resolved. Catalogs are read **paginated** — ChurchTools returns only a -first page (10 rows) for a plain list read, so an instance with more campuses, -group types or departments than that would otherwise report a perfectly real -name as unresolvable. +Resolution mirrors the domain-reference rules: managed state first, a target +**declared in this same config** as pending, then a persisted external binding. +Every external is read live by id and its registry-defined hard identity is +validated. An unbound object always blocks plan; paginated live discovery may +list candidates and complete `ct use` commands, but never lends the plan an id +that was not persisted. Three things are hard errors at **plan** time, never a guessed `dataId`: @@ -563,15 +561,23 @@ something guarantees the row is there. `nameTranslated` is derived from `name`, not independently writable, so the managed set (`name`, `sortKey`) is complete and a `PUT` cannot blank a sibling. -- **Resolution is managed-state first, live catalog second.** An existing - reference to a viewer your config does not own keeps resolving exactly as it - did in #102, and a name that matches nothing anywhere is still a hard error - rather than a create: +- **Resolution is managed-state first, explicit external binding second.** A + viewer owned by another project is consumed without lifecycle authority by + binding it once per host: + +```bash +ct use comment-viewer 12 --key dienstbereich --env prod +``` + +Plan reads `GET /person/commentviewers/12`, validates the stored hard identity +(`name`), and uses the id only after it matches. An unbound name is still a +hard error; catalog discovery can suggest this command but cannot silently +resolve the reference: ``` -Cannot resolve comment-viewer:nope referenced at … : no managed resource and -no live comment-viewer at /person/commentviewers matches key "nope". -Declare/adopt it, fix the key/name, or use a numeric id. +External prerequisite is not available +resource: comment-viewer "nope" +Consequence: Consumer plan/apply is blocked before writes. ``` - **`ct apply` never deletes**, as everywhere else. `ct destroy` can, and warns: @@ -585,11 +591,9 @@ Declare/adopt it, fix the key/name, or use a numeric id. `ct.commentViewer({ key: "alle", name: "Alle" })` finds no state entry on a second host, so `ct apply` creates a **second** "Alle" with a fresh id and scopes the grant to the duplicate. Worse, that host never complains: - resolution is managed-state-first, so `{ commentViewer: "alle" }` quietly - resolves to the config's own duplicate. The two identically named rows only - hard-error for a config reading the catalog _without_ a state entry — a third - host, or the same one after a state reset — so the damage surfaces somewhere - other than where it was caused. The adopter therefore + the declaration authorises creating the managed object and resolves as pending, + so live discovery is not an ownership guard. An external binding would avoid + creating it, but is unnecessary for this documented cross-host constant. The adopter therefore treats it like the `-1` sentinel — emitted as the bare number with a comment saying what it is, never with an adoption hint. It stays a **number** rather than a name reference on purpose: an admin can rename the row, and the id is @@ -646,10 +650,10 @@ adding or reordering a level on one host silently changes what a hard-coded Two things address that, and you can use either: -**1. Reference a level by name.** `{ securityLevel: "stufe_3_hoch" }` resolves -against managed levels first, then `GET /securitylevels`. The trade-off: names -are localised German strings (`"Stufe 3 (Hoch)"` → `stufe_3_hoch`), so a -**rename** breaks a reference where a number would have survived. +**1. Reference a level by logical key.** `{ securityLevel: "stufe_3_hoch" }` +resolves against managed state or an explicit external binding. The external +binding validates the live name, so a rename intentionally blocks until +`ct use security-level --key stufe_3_hoch` accepts the changed identity. **2. Declare the levels themselves**, which makes the numeric form portable too, because the config now owns the ids: diff --git a/docs/rest-api.md b/docs/rest-api.md new file mode 100644 index 0000000..392e474 --- /dev/null +++ b/docs/rest-api.md @@ -0,0 +1,193 @@ +# Versioned REST API + +`ct server` exposes the same application operations as Commander below `/api/v1`. The operation +catalog in `src/operations/catalog.ts` is the single source for the Commander tree, HTTP router, +capability discovery and OpenAPI. Adding an application operation requires both projections; the +catalog parity test rejects a missing projection. + +## Local start and pairing + +```bash +ct server --port 8765 \ + --workspace /path/to/process \ + --allow-origin chrome-extension:// +``` + +The server binds to `127.0.0.1` by default and prints a short-lived, single-use pairing code. The +Extension exchanges it at `POST /api/v1/pair` and receives a capability-scoped session. A bearer +session is suitable for an Extension; an `HttpOnly`, `SameSite=Strict` cookie is also issued for +same-site local clients. Pairing codes, passwords, 2FA codes, ChurchTools tokens and request bodies +are never logged. + +Useful discovery endpoints: + +- `GET /api/docs` — interactive Scalar API reference (loads its pinned renderer from jsDelivr) +- `GET /api/v1/health` +- `GET /api/v1/capabilities` +- `GET /api/v1/openapi.json` + +The documentation page is only a projection of the generated OpenAPI contract. It does not carry +a second, manually maintained endpoint definition. Loading the Scalar renderer requires internet +access; the API and its OpenAPI document remain available without it. + +Except for the OpenAPI document itself, successful JSON responses use +`{ apiVersion, requestId, operation, result }`. Within `v1`, fields and operations may be added but +existing meanings are not changed or removed. A breaking request, result, error or security change +requires a new `/api/v2` surface; old major versions remain independent adapters over the same +application operations during their documented support window. + +All other endpoints require a paired session. Browser origins are matched exactly against +`--allow-origin`; wildcards and ambient cross-origin credentials are not enabled. + +## Workspaces and process input + +API clients select only a workspace ID returned by `GET /api/v1/workspaces`. Config, state and +initialization paths are contained below roots configured with `--workspace`; `..` and absolute +escape attempts are rejected. + +Process input is JSON with a versioned envelope: + +```json +{ + "schemaVersion": "1", + "clientRevision": "checkout-form@42", + "payload": {} +} +``` + +### What an input snapshot is for + +If the desired structure is written directly in `ct.config.ts`, input snapshots are not needed. +The normal workflow stays: + +```bash +ct plan +ct apply +``` + +Snapshots support a different workflow in which a browser UI collects form data. For example, a +UI could ask for a campus name and whether a kids group should be created: + +```json +{ + "schemaVersion": "1", + "clientRevision": "campus-form@42", + "payload": { + "campus": "Mainz", + "withKidsGroup": true + } +} +``` + +The flow is: + +```text +browser form → immutable JSON snapshot → trusted generator → normal ct desired model → plan/apply +``` + +Creating the snapshot stores exactly these form values and returns a SHA-256 `digest`, which is its +content-based identifier. Passing that digest as `snapshotDigest` in REST or as +`--input-snapshot ` in the CLI selects this exact version. It prevents form data from being +silently changed between reviewing a plan and executing an apply. + +The snapshot itself does not contain a `ct.config.ts` and cannot execute code. A trusted generator +translates its JSON payload into the same resources and permissions that a normal config would +produce. The usual planner and apply engine then take over; snapshots do not introduce a second +reconciliation implementation. + +Snapshots are content-addressed by a canonical SHA-256 digest and persisted immutably under +`.ct/process-input/snapshots/`. Input is data, never JavaScript supplied by the browser. + +To turn input into the normal ct-cli desired model, the operator may install a trusted local +generator when starting the server: + +```bash +ct server --workspace . --generator ./blueprint/process-generator.ts +``` + +The module exports an object with `id`, `supportedSchemaVersions`, `validate(document)` and +`generate(document)`. `generate` returns `{ resources, permissions }`. The module path is server +configuration and cannot be selected or uploaded through the API. A plan or prepared apply can +name `snapshotDigest`; both then use the same generator and application operation as the CLI. + +For REST, the browser sends only data and the snapshot digest. It never sends a generator path; the +server operator fixes the trusted generator with `ct server --generator ...`. For the local CLI, +`--input-snapshot` and `--generator` must be supplied together: + +```bash +ct input snapshot process.json +ct plan --input-snapshot --generator ./blueprint/process-generator.ts +ct apply --input-snapshot --generator ./blueprint/process-generator.ts +``` + +The equivalent CLI projection is available through `ct input`, `ct plan --input-snapshot ... +--generator ...` and `ct apply --input-snapshot ... --generator ...`. + +## Mutations, progress and retries + +Mutating requests accept `Idempotency-Key`. A repeated key in the same session and operation returns +the original response with `Idempotency-Replayed: true`. + +Apply, destroy, `unadopt` and `unuse` are two-stage operations: prepare first, review the returned +canonical proposal and confirmation requirement, then execute the opaque operation ID with the +required proof. Apply is bound to the exact environment, config digest, state digest, plan digest +and optional immutable input snapshot. Release operations retain the exact state entry that was +reviewed and reject execution if it changed in the meantime. Prepared operations expire, are +single-use, and state-file mutations are serialized. + +External-reference operations from #143 use the same catalog as their CLI commands: + +- `POST /api/v1/workspaces/{workspaceId}/external-bindings` projects `ct use`; +- the `/releases/managed/...` prepare/execute pair projects `ct unadopt`; +- the `/releases/external/...` prepare/execute pair projects `ct unuse`; +- `POST /api/v1/workspaces/{workspaceId}/ownership/check` projects `ct ownership check`. + +`use` validates the exact live ChurchTools object before recording a read-only binding and never +claims lifecycle ownership. `unuse` and `unadopt` only change the local state file; they never delete +or otherwise modify the ChurchTools object. Ownership checks are offline and are confined to an +explicit directory below the selected workspace root. + +Long-running routes return `X-Operation-Run-Id`. Send `Prefer: respond-async` to receive `202` and +poll `GET /api/v1/runs/{runId}`. Progress is also available as shared application events at +`GET /api/v1/runs/{runId}/events` using Server-Sent Events. `DELETE /api/v1/runs/{runId}` requests +cooperative cancellation; operations stop at their next shared progress boundary. + +Errors use stable `application/problem+json` envelopes with `code`, `status`, `detail` and a +request ID. Secret values are never included. + +## LAN, container and reverse proxy + +Listening beyond loopback is intentionally refused unless all of these are supplied: + +```bash +ct server \ + --host 0.0.0.0 \ + --port 8765 \ + --trusted-proxy \ + --public-url https://ct-api.internal.example \ + --allow-origin https://extension.internal.example \ + --workspace /srv/ct/process +``` + +The declared reverse proxy must terminate TLS and forward only the advertised API. Do not publish +the raw HTTP listener. Remote credential submission is refused without a secure transport mode. +Use container firewall rules so only the proxy can reach the listener. + +## Threat model + +The API assumes the local machine or declared reverse proxy is trusted, but browser pages and API +clients are not. Controls include: + +- short-lived one-time pairing and capability-scoped expiring sessions; +- exact Origin checks, restrictive CORS, CSP, no-store responses and strict cookies; +- configured workspace roots and path containment; +- request-size and per-address rate limits; +- server-held ChurchTools tokens, login continuations and prepared-operation internals; +- idempotency and per-state mutation locks; +- prepared apply binding to reviewed inputs and digests; +- no shell endpoint, no CLI subprocess and no browser-provided executable config; +- shared core guardrails for protected environments, backups, confirmation and people boundaries. + +The API is designed for a trusted single-user execution service. Multi-user deployments must put +separate instances or an authorization-aware gateway in front; sharing one ct-cli process between +untrusted users is not supported. diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index f1e28cf..703292c 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -40,16 +40,16 @@ 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 -| Item | What it is | Tracking issue | Manual workaround today | -| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Group/group-type field decision table | Fields deliberately left unmanaged (decided out of scope): visibility, note, `autoAccept`/open-for-members, chat status, sort key. The triage **shipped** as a committed decision table ([`docs/group-field-decisions.md`](group-field-decisions.md)) | [#21](https://github.com/eqrm/ct-cli/issues/21) (decided) | Set by hand; these fields are intentionally not diffed — `ct` will neither preserve nor revert them. Promote one later only with its own registry entry + tests | -| Portable/logical references | **Shipped (#20, #25).** Configs reference master data by name/key — `campus`/`groupType` on a group, `ref.campus(...)` in ruleset `var` values, `groupType: ""` for a `group_type_role` domain, and now `group: "", role: ""` for a `group_role` domain (#25) — and the per-host resolver maps each to that instance's id at plan time (managed resources ∪ live catalogs). A same-run campus resolves at apply time. Numeric ids still work as an escape hatch. **`status` (group status) is NOT part of this** (#67) — group statuses have no REST catalog, so `status:` fails fast at eval time; declare the numeric `groupStatusId` directly | [#20](https://github.com/eqrm/ct-cli/issues/20) (done), [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | None needed for the shipped surface. Write logical names; run `ct plan`. The `group_role` pairing-id resolution is verified live (row below) | -| Environments (dev → prod promotion) | Named `(host, token, state file)` profiles and a `--env` flag; today one config + one state file = one host | [#22](https://github.com/eqrm/ct-cli/issues/22) | Point `CT_HOST`/state file manually at each target and re-run; keep dev and prod state files apart yourself, and be careful — nothing stops you from applying a dev-shaped config against prod today | -| Permission `group_role` domain by reference **(shipped, verified live)** | `ct.groupRole({ group, role })` now resolves the (group, role) pair to its pairing domainId at plan time (#25). **Confirmed live 2026-08-13 (CT 3.135.2):** it reads the group's role list (`GET /groups/{groupId}/roles`) and takes the matched role row's `id` as the pairing domainId. Two anchors on different group types: each row's `id` is a live `group_role` domainId carrying that role's grants, while its type-level `groupTypeRoleId` appears nowhere in the domainId set | [#25](https://github.com/eqrm/ct-cli/issues/25) (done, verified) | None needed. Works by reference for managed, already-created groups; numeric `id:` remains a supported escape hatch ([`docs/handbuch/permissions.md`](handbuch/permissions.md) "domainId semantics") | -| ~~Grant adoption~~ **(shipped)** | ~~existing rights structures must be hand-transcribed~~ — **`ct adopt grants ` ships this** (#25): it reads the live rows, applies the planner's normalization, and prints a paste-ready `ct.groupRole` / `ct.groupTypeRole` block (baseline/inherited excluded, denies noted-and-preserved, scope dataIds mapped back to managed-group keys). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Adopting existing grants" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run `ct adopt grants group_role ` (or `group_type_role`), review the `WARNING`/`NOTE` comments, paste into config | -| ~~Permission catalog lifecycle~~ **(shipped)** | ~~`catalog.json` is a one-off HAR-trace snapshot with no staleness detection~~ — **shipped (#25):** `npm run regenerate:permission-catalog` rewrites it from a live instance (records the CT version in `$meta`), and `ct plan` now warns on a version mismatch or an unknown-authId live grant (which it leaves untouched, never revoking a right it cannot name). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Catalog lifecycle & staleness" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run the command; heed the `ct plan` warnings | -| Field definitions & security levels (person + group custom fields) **(read-only, shipped #47/#48)** | The person master-data model, the security-level enumeration, and the data-field DEFINITIONS ("Datenfelder") for persons and groups — structural schema, not per-record values | [#47](https://github.com/eqrm/ct-cli/issues/47), [#48](https://github.com/eqrm/ct-cli/issues/48) (read shipped; write is an API gap — see note) | Read with `ct get person-masterdata` (model + security levels) and `ct get data-fields` (all field definitions, person + group, discriminated by `fieldCategory`). **Mutation stays manual:** field definitions have no REST write endpoint — only the legacy churchdb admin AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) — so create/edit/delete them by hand in the master-data admin UI. Decision + evidence: [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md) | -| API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a member-status write, or — separately — a first-ever group-status list/write endpoint, #67) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) | +| Item | What it is | Tracking issue | Manual workaround today | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Group/group-type field decision table | Fields deliberately left unmanaged (decided out of scope): visibility, note, `autoAccept`/open-for-members, chat status, sort key. The triage **shipped** as a committed decision table ([`docs/group-field-decisions.md`](group-field-decisions.md)) | [#21](https://github.com/eqrm/ct-cli/issues/21) (decided) | Set by hand; these fields are intentionally not diffed — `ct` will neither preserve nor revert them. Promote one later only with its own registry entry + tests | +| Portable/logical references | **Shipped (#20, #25, #157).** Configs reference master data by name/key, including `campus`/`groupType`/`status` on a group and `ref.*` in rulesets. Group lifecycle statuses resolve through `/person/masterdata.groupStatuses`; numeric `groupStatusId` remains an escape hatch. This is distinct from `/group/memberstatus` and person `/statuses`. | [#20](https://github.com/eqrm/ct-cli/issues/20), [#25](https://github.com/eqrm/ct-cli/issues/25), [#157](https://github.com/eqrm/ct-cli/issues/157) | Write logical names; run `ct plan`. | +| Environments (dev → prod promotion) | Named `(host, token, state file)` profiles and a `--env` flag; today one config + one state file = one host | [#22](https://github.com/eqrm/ct-cli/issues/22) | Point `CT_HOST`/state file manually at each target and re-run; keep dev and prod state files apart yourself, and be careful — nothing stops you from applying a dev-shaped config against prod today | +| Permission `group_role` domain by reference **(shipped, verified live)** | `ct.groupRole({ group, role })` now resolves the (group, role) pair to its pairing domainId at plan time (#25). **Confirmed live 2026-08-13 (CT 3.135.2):** it reads the group's role list (`GET /groups/{groupId}/roles`) and takes the matched role row's `id` as the pairing domainId. Two anchors on different group types: each row's `id` is a live `group_role` domainId carrying that role's grants, while its type-level `groupTypeRoleId` appears nowhere in the domainId set | [#25](https://github.com/eqrm/ct-cli/issues/25) (done, verified) | None needed. Works by reference for managed, already-created groups; numeric `id:` remains a supported escape hatch ([`docs/handbuch/permissions.md`](handbuch/permissions.md) "domainId semantics") | +| ~~Grant adoption~~ **(shipped)** | ~~existing rights structures must be hand-transcribed~~ — **`ct adopt grants ` ships this** (#25): it reads the live rows, applies the planner's normalization, and prints a paste-ready `ct.groupRole` / `ct.groupTypeRole` block (baseline/inherited excluded, denies noted-and-preserved, scope dataIds mapped back to managed-group keys). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Adopting existing grants" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run `ct adopt grants group_role ` (or `group_type_role`), review the `WARNING`/`NOTE` comments, paste into config | +| ~~Permission catalog lifecycle~~ **(shipped)** | ~~`catalog.json` is a one-off HAR-trace snapshot with no staleness detection~~ — **shipped (#25):** `npm run regenerate:permission-catalog` rewrites it from a live instance (records the CT version in `$meta`), and `ct plan` now warns on a version mismatch or an unknown-authId live grant (which it leaves untouched, never revoking a right it cannot name). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Catalog lifecycle & staleness" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run the command; heed the `ct plan` warnings | +| Field definitions & security levels (person + group custom fields) **(read-only, shipped #47/#48)** | The person master-data model, the security-level enumeration, and the data-field DEFINITIONS ("Datenfelder") for persons and groups — structural schema, not per-record values | [#47](https://github.com/eqrm/ct-cli/issues/47), [#48](https://github.com/eqrm/ct-cli/issues/48) (read shipped; write is an API gap — see note) | Read with `ct get person-masterdata` (model + security levels) and `ct get data-fields` (all field definitions, person + group, discriminated by `fieldCategory`). **Mutation stays manual:** field definitions have no REST write endpoint — only the legacy churchdb admin AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) — so create/edit/delete them by hand in the master-data admin UI. Decision + evidence: [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md) | +| API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a member-status or group-status write endpoint) or a dedicated group-status collection replacing the nested `/person/masterdata.groupStatuses` catalog (#157) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) | ## Out of tool scope — deliberate, not a gap diff --git a/docs/superpowers/specs/2026-08-27-external-resource-references-design.md b/docs/superpowers/specs/2026-08-27-external-resource-references-design.md new file mode 100644 index 0000000..3cc867a --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-external-resource-references-design.md @@ -0,0 +1,441 @@ +# External resource references across ct projects (#143) + +**Status:** Design decisions complete; implementation not started. This +document records the agreed design for +[#143](https://github.com/eqrm/ct-cli/issues/143). + +## Context + +Independently managed ct projects may use the same ChurchTools resource. One +project owns its lifecycle; the others need its host-specific id in portable +references without gaining permission to create, update, or delete it. + +The [adoption contract](../../adoption-contract.md) already establishes that a +shared referenced resource is never adopted transitively. This design defines +how a deliberately unmanaged resource can nevertheless be resolved. + +## Terminology + +A **ct project** is one lifecycle boundary consisting of a config and its +environment-specific state files. A project may be a directory in a monorepo or +its own repository. It does not need to be a separate Git repository. + +Example: + +```text +processes/ +├── cafeplan/ +│ ├── ct.config.ts +│ └── ct-state.prod.json +└── ojbp/ + ├── ct.config.ts + └── ct-state.prod.json +``` + +## Glossary and terminology boundary + +ct-cli spans several unrelated ChurchTools modules, APIs, and master-data +tables. ChurchTools has no single official umbrella term that exactly denotes +the set of things ct-cli can manage. In particular, neither unqualified +"resource" nor "domain object" is safe shorthand. + +| Term in this design | Meaning in ct-cli | ChurchTools distinction | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **ct project** | One config plus its environment-specific state files; one lifecycle boundary. | Not a ChurchTools project or module. | +| **ct-cli resource** | An independently addressable top-level object represented by one entry in ct-cli's resource registry and, when managed, one state entry. Prefer the qualified form in user documentation. | ChurchTools uses "Resource" for its own product/API concepts; that term is not the umbrella category meant here. | +| **resource type** | A ct-cli registry discriminator such as `group`, `campus`, or `group-role`. | Not necessarily a ChurchTools domain, module, endpoint family, or UI object type. | +| **managed** | The ct project owns lifecycle responsibility; `plan`, `apply`, and explicit `destroy` may act on the object. | Does not mean a ChurchTools permission or UI ownership flag. | +| **external** | The ct project may resolve and consume the object read-only but does not own its lifecycle. | No corresponding ChurchTools flag is implied or written. | +| **owner project** | The one ct project whose managed state claims lifecycle responsibility for an object. | Not necessarily the ChurchTools creator, administrator, or permission owner. | +| **consumer project** | A ct project whose external state binds and references an owner project's object. | Does not describe ChurchTools memberships or participants. | +| **logical key** | Portable, user-controlled identity used by `ref.*`, independent of ChurchTools ids. | Not stored in ChurchTools unless a separate future design explicitly does so. | +| **host binding** | Mapping from `(resource type, logical key)` to one numeric ChurchTools id for one host. | The numeric id remains ChurchTools' record id; the binding itself belongs to ct-cli state. | +| **identity snapshot** | Minimal read-only live properties retained to detect that a binding's meaning changed. | It is not a managed-field snapshot and never causes reconciliation. | +| **owned structural child** | A non-person record whose lifecycle belongs to a top-level ct-cli resource, such as a group member-field definition. | It may live behind a separate ChurchTools endpoint but is not an independent ct-cli resource binding. | +| **relationship** | An edge between top-level objects, such as group hierarchy. | It may not be a standalone ChurchTools record or ct-cli state entry. | +| **coordination scope** | The explicit directory tree inspected by `ct ownership check`. | Not a ChurchTools environment or organisational boundary. | + +When prose could be read in the ChurchTools-specific sense, use the qualified +form **ct-cli resource** or name the concrete type (`group`, `campus`, and so +on). Do not introduce "domain object" as a replacement umbrella term: the +managed set crosses multiple ChurchTools domains and the phrase would suggest a +uniform ChurchTools abstraction that does not exist. + +## Decisions so far + +### 1. Separate portable identity, discovery, and host binding + +An external resource has three distinct identities: + +1. A **logical key** used by portable config references. +2. A minimal **live identity snapshot** used to validate a bound object, plus + non-validating display properties used during candidate selection. +3. A **host binding** from that key to the concrete ChurchTools id on one host. + +The logical `(resource type, key)` pair is the primary portable identity. It is +user-controlled, unique across managed and external entries within one ct +project, and never re-derived after initial creation. Live identity properties +validate the host binding; they do not replace the logical key. + +When `ct use` needs to propose a key, it uses this priority: + +1. If a visible owner project manages the same `(type, id)`, reuse the owner's + key. +2. If the consumer already binds the `(type, id)`, retain that existing key. +3. Otherwise derive a one-time proposal with the registry's `slug()` function + and let the user accept or edit it. + +When the owner is visible, owner and consumers must use the same key for the +same `(type, id)`. `ct ownership check` treats differing aliases as an error and +prints a repair command. A deliberate key change is a separate explicit state +operation: + +```bash +ct state rekey +``` + +It works for managed and external entries, rejects collisions, and warns that +all `ref.*` uses in config must be changed consistently. Neither a live rename +nor a changed ChurchTools id ever changes the logical key automatically. + +`name + groupType` may help discover a group, but it is not guaranteed to stay +unique. It must not be the only permanent identity mechanism. A user needs an +explicit way to select the intended live object when discovery is ambiguous. + +No additional `ct.external.*` config DSL is introduced. `ct use` records the +external declaration and host binding in state; portable config consumes the +logical key through the existing reference DSL: + +```ts +ref.group("vl_ojahr_teilnehmer_aktuell"); +``` + +### 2. External is generic for every managed resource type + +External binding is not a group-only feature. Every top-level resource type +that can receive its own entry in the managed `resources` state map must also be +bindable as external/read-only. At the time of this decision the registry +contains: + +- `campus` +- `group` +- `group-type` +- `age-group` +- `target-group` +- `relationship-type` +- `person-status` +- `department` +- `security-level` +- `comment-viewer` +- `group-role` (the shared role definition) + +The resource registry is the source of truth. Adding a future top-level managed +resource type must either provide the generic external contract or explicitly +explain why the type cannot be referenced externally; external support must not +grow as a second hand-maintained type list. + +Each registry entry supplies or derives external behaviour for: + +- collection and item reads; +- interactive search and candidate display; +- key derivation; +- identity capture and validation; +- useful disambiguating fields; +- logical reference kind and resolution. + +Identity fields are type-specific. They must not blindly reuse every managed +field: a consumer should not be blocked by an unrelated mutable property merely +because the owner manages it. The registry needs an explicit external identity +adapter. Version 1 has no user-defined `match`, `assert`, or optional identity +field selection. Each registry type fixes its hard identity and its +non-validating candidate display: + +| Resource type | Hard identity snapshot | Candidate display only | +| ------------------- | ---------------------- | --------------------------------- | +| `campus` | name | short name | +| `group` | name, group-type id | campus, group status | +| `group-type` | name | translated name | +| `age-group` | name | translated name, sort order | +| `target-group` | name | translated name, sort order | +| `relationship-type` | name | labels for both directions | +| `person-status` | name | short name, member-status meaning | +| `department` | name | short name | +| `security-level` | name | numeric level/id | +| `comment-viewer` | name | sort order | +| `group-role` | name, group-type id | leader/participant type | + +Only hard identity changes block a consumer plan. Display-only changes never +do. Thus moving a group to another campus or changing its status does not block +consumers, while renaming it or changing its group type does. A hard identity +change is accepted explicitly and idempotently by rerunning `ct use` with the +bound id and key; ct shows the field-level identity diff before confirmation. + +This decision covers independently addressable top-level resources. It does not +turn the following into separate external resources: + +- owned structural children such as group member-field definitions; +- synthetic fields such as dynamic-group configuration; +- relationship edges such as group hierarchy; +- permission declarations or grants; +- person-related data, which remains permanently excluded. + +Those categories may contain references to an external top-level resource, but +they do not acquire independent external bindings of their own. + +### 3. Persist external identity separately from managed state + +The existing `resources` map remains exclusively lifecycle-owned resources. +External bindings live in a structurally separate top-level map, for example: + +```json +{ + "version": 2, + "host": "https://example.church.tools", + "resources": {}, + "externals": { + "vl_ojahr_teilnehmer_aktuell": { + "type": "group", + "key": "vl_ojahr_teilnehmer_aktuell", + "id": 4711, + "owner": "shared-masterdata", + "identity": { + "name": "VL OJAHR Teilnehmer aktuell", + "groupTypeId": 17 + }, + "boundAt": "2026-08-27T12:00:00Z" + } + } +} +``` + +An external entry carries no managed-field snapshot and no lifecycle flags such +as `preventDestroy`. `apply` and `destroy` enumerate only `resources`; external +bindings are therefore outside write paths by construction, not merely by a +late conditional check. + +An existing binding is authoritative for that host. Live discovery must never +silently replace it. Changing the binding requires an explicit `use` +command that names the new id and confirms the replacement. + +Binding state is keyed by the portable logical key. The CLI exposes an +interactive discovery form and an explicit, scriptable form: + +```bash +ct use group "OJAHR Fuzzies" +ct use group 4711 --key ojahr_fuzzies +ct state list +ct state rm group ojahr_fuzzies +``` + +`use` is the consumer operation, symmetric with the owner operation `adopt`: + +```text +ct adopt --key # own and manage +ct use --key # consume read-only +``` + +The resource type is mandatory. The two `use` forms are therefore +`ct use ""` and `ct use --key `. + +The search form performs a live fuzzy search within the named resource type and +presents every matching candidate with its id, exact name, and useful +type-specific disambiguators such as group type and campus. It never guesses +when several candidates match. After the user selects one, it proposes a +logical key derived with the existing `slug()` rule (`"OJAHR Fuzzies"` → +`"ojahr_fuzzies"`). The user may accept or edit that key before confirming the +binding. + +The derived key is a one-time proposal, not a live derivation. A later rename of +the ChurchTools resource never changes it. An already-bound `(type, id)` keeps +its existing key, and a derived key colliding with any managed or external key +must be replaced explicitly. + +The search form requires an interactive terminal. In non-interactive use no +candidate or key is inferred; scripts use the deterministic form +`ct use --key `. + +`use` is strictly idempotent: + +- no existing binding for the key or `(type, id)`: validate the live resource, + capture its identity, and create the binding; +- the same key is already bound to the same `(type, id)` and the stored identity + still matches: validate it and return success without changing the state file; +- the same key and id still identify the resource but its identity properties + changed: show the identity diff and replace the snapshot only after explicit + confirmation; +- the key is bound to another id: show the old and new live resources and + replace the binding only after explicit confirmation; +- the `(type, id)` is externally bound under another key: fail rather than + create a second alias; +- the `(type, id)` is managed by this project: fail because a resource cannot + be both managed and external in one project. + +The no-op case must not update a timestamp: repeated `use` commands should +leave the state byte-stable. `boundAt` records creation of the binding, not its +most recent verification. + +`ct state list` shows managed resources and external bindings together by +default, with an explicit ownership/kind column. Optional `--managed` and +`--external` filters may narrow that view, but are never required to obtain a +complete project-state listing. Because managed and external keys are mutually +exclusive within one project, `ct state rm ` can remove either kind +without another mode flag; its output must say whether it removed a managed +state entry or an external binding. Removing state never deletes the live +ChurchTools resource. + +### 4. Resolution and validation + +The intended resolution order is: + +1. If an external host binding exists, read that id live and validate its type + and registry-defined hard identity snapshot. +2. Without a binding, use a visible owner's state and the unresolved logical + key as search hints only to find and describe live candidates for + remediation. +3. Missing, unique-but-unbound, or ambiguous discovery fails before any write + and lists actionable candidates or remedies. +4. A resolved external id is available to every existing reference position, + including dynamic rulesets, parents, and permissions. +5. Resolution never turns the external into a desired resource and never emits + create, update, or delete actions. + +External resources always require a persisted, host-specific binding created by +an explicit `ct use` invocation. Even when discovery finds exactly one live +candidate, `plan` must not consume it ephemerally and must not write the binding +itself. Instead it fails with the candidate's identifying details and the +complete deterministic `ct use --key ` command, followed by the +`ct plan --env ` verification command. This keeps plan read-only, makes the +consumer relationship auditable, and prevents a later same-named resource from +changing resolution. + +An identity mismatch is a hard plan error. Its explicit, local repair is the +same declarative binding command: `ct use --key `. +With the existing id it shows and, after confirmation, accepts identity changes; +with a new id it shows both live resources and replaces the id and identity +snapshot. The operation never writes to ChurchTools. Repeating it after the +desired binding and identity have been recorded succeeds as a no-op. + +### 5. Missing owner resources block the consumer + +An external prerequisite is never a pending consumer resource. `PendingRef` +means that the current apply is authorised and able to create the target later +in its own dependency graph; neither is true for a resource owned by another ct +project. + +If the owner project has not materialised the resource, consumer `plan` fails +before any write and identifies the owner and the next steps. Consumer `apply` +must not start with an unresolved external prerequisite, and ct never applies a +different project automatically. + +Diagnostics distinguish at least: + +- the visible owner state has no managed resource: apply the owner project + first; +- the owner state carries an id but the live read returns `404`: the owner state + is stale or the resource was deleted outside ct; +- the declared owner is not present below the explicitly analysed coordination + root: ownership cannot be verified there; +- the live resource exists but the consumer lacks its external binding: run + `ct use` in the consumer project. + +When the visible project layout permits it, the error may print concrete `cd`, +`ct plan`, `ct apply`, and `ct use` commands. It remains diagnostic: +cross-project apply ordering or execution is a separate future orchestration +feature, not part of #143. + +#### Actionable diagnostic contract + +A generic "cannot resolve external resource" message is insufficient. Every +blocking external-resource diagnostic must contain: + +1. **Context:** external type and key, consumer project, declared owner project, + environment, and ChurchTools host. +2. **Evidence:** which config, managed state, external state, and live lookup + were inspected, and the exact missing, stale, ambiguous, or mismatching fact. +3. **Consequence:** that consumer plan/apply is blocked before writes, and that + the consumer will not create or repair the owner's resource. +4. **Numbered remediation:** copyable commands using the discovered project + paths, environment, type, key, and id wherever those values are known. +5. **Verification:** the exact command to rerun after the repair. + +For example, when a visible owner has not materialised the group: + +```text +External prerequisite is not available + + resource: group "ojahr_fuzzies" + consumer: ojbp + owner: shared-masterdata + environment: prod + host: https://example.church.tools + +The owner config declares the group, but its prod state contains no managed id. +The consumer is blocked before writes and will not create the owner's group. + +Next steps: + 1. cd ../shared-masterdata + 2. ct plan --env prod + 3. Review the owner plan, then run: ct apply --env prod + 4. cd ../ojbp + 5. ct use group --key ojahr_fuzzies + 6. ct plan --env prod +``` + +The remediation changes by cause: + +- owner state id returns live `404`: run owner `plan` first and explain that its + state may be stale; do not suggest binding the missing id in the consumer; +- owner is outside the supplied coordination root: show how to rerun + `ct ownership check --env ` or correct the declared owner; +- owner resource exists and only the consumer binding is missing: print the + complete deterministic `ct use --key ` command; +- discovery is ambiguous: list every candidate with identifying properties and + print a complete bind command for each candidate; +- bound identity changed: show the field-level identity diff and print the bind + command that accepts the current id after confirmation. + +Paths and commands must be derived from the inspected coordination root rather +than hard-coded examples. The application layer should expose stable reason +codes and structured remediation details so terminal, future UI, and machine +readable output can present the same diagnosis without parsing prose. + +### 6. Ownership checks within an explicit coordination scope + +No local project can discover claims in unknown repositories. When several ct +projects are visible under an explicitly supplied directory, however, ct can +compare all of their environment states. + +From `processes/ojbp`, this command defines `processes` as the complete visible +coordination scope for this invocation: + +```bash +ct ownership check .. --env prod +``` + +The command recursively discovers ct projects below the explicit root while +ignoring unrelated directories such as `.git`, `node_modules`, and build +outputs. No directory outside the supplied root is searched. + +For each ChurchTools host it reports at least: + +- the same `(type, id)` managed by two projects: **error**; +- a resource managed by one project and consumed externally by others: **ok**; +- an external declaration naming an owner that does not manage the bound + resource: **error**; +- conflicting logical bindings or incompatible host data: **error**. + +Ownership conflicts produce a non-zero exit code so the analysis can be a CI +gate. Projects or repositories outside the explicit root remain unknowable; a +truly global guarantee would require a shared registry with atomic claims. + +An explicit directory is sufficient for the first implementation. A workspace +manifest may be added later for stable project ids, inclusion, or exclusion, +but is not required initially. + +## Non-goals for the first implementation + +- Importing another project's complete managed state as consumer state. +- Claiming that a directory scan can detect projects outside its explicit root. +- Silently taking lifecycle ownership as a side effect of reference resolution. +- Writing ownership markers into ChurchTools without a separate design and an + appropriate ChurchTools metadata contract. diff --git a/examples/portable.config.ts b/examples/portable.config.ts index 92553c3..a6ba0fc 100644 --- a/examples/portable.config.ts +++ b/examples/portable.config.ts @@ -5,14 +5,14 @@ * * References resolve from, in order: (1) resources managed by this tool (declared * here or already in state), (2) live master-data catalogs (group types, campuses, - * roles) matched by name. An unresolvable name fails the plan with a + * group statuses, roles) matched by name. An unresolvable name fails the plan with a * clear error naming the reference and where it was used — never a silent wrong id. * * The numeric escape hatch remains available everywhere (`groupTypeId: 2`, * `campusId: 3`, `q.eq("ctgroup.campusId", 4)`, `id: `) for the rare case - * where you deliberately target one instance's id — and is the ONLY way to set a - * group's `groupStatusId`: group statuses have no REST catalog to resolve a name - * against (#67), so unlike `groupType`/`campus` there is no `status:` sugar. + * where you deliberately target one instance's id. A group's lifecycle status can + * instead use `status: "active"`; it resolves through the nested group-status catalog + * in `/person/masterdata` (#157). */ import type { ConfigContext } from "../src/config/context.js"; import { q, churchQuery, ref } from "../src/config/context.js"; diff --git a/src/api/session.ts b/src/api/session.ts index 92000b0..dc95231 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -25,9 +25,9 @@ export interface AuthedSession { * An explicit `CT_LOGINTOKEN` env token carries no stored-host binding, so the * caller owns pairing it with the intended `CT_HOST` — no check applies there. */ -export async function authedSession(): Promise { - const config = await resolveConfig(); - const envToken = process.env.CT_LOGINTOKEN?.trim(); +export async function authedSession(env: NodeJS.ProcessEnv = process.env): Promise { + const config = await resolveConfig(env); + const envToken = env.CT_LOGINTOKEN?.trim(); // Resolve the stored token for the host we intend to hit (multi-host, #22): with several logins on // one machine, the per-host account is picked by host, keeping the token↔host binding below intact. const stored = await readCredentials(config.host); diff --git a/src/application/contracts.ts b/src/application/contracts.ts index 38904ca..3093ac7 100644 --- a/src/application/contracts.ts +++ b/src/application/contracts.ts @@ -3,7 +3,21 @@ export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; export type OperationName = - "plan" | "apply" | "coverage" | "adopt" | "state" | "refresh" | "destroy" | "auth"; + | "plan" + | "apply" + | "coverage" + | "adopt" + | "unadopt" + | "use" + | "unuse" + | "ownership" + | "state" + | "refresh" + | "destroy" + | "auth" + | "init" + | "environment" + | "input"; /** Common project selection accepted by CLI and, later, HTTP adapters. */ export interface ProjectRequest { diff --git a/src/application/errors.ts b/src/application/errors.ts index 880b68c..bd330b7 100644 --- a/src/application/errors.ts +++ b/src/application/errors.ts @@ -11,6 +11,11 @@ export const APPLICATION_ERROR_CODES = [ "OPERATION_EXPIRED", "OPERATION_ALREADY_USED", "MUTATION_BUSY", + "EXTERNAL_REFERENCE_BLOCKED", + "EXTERNAL_CONFIRMATION_REQUIRED", + "STATE_RELEASE_CONFIRMATION_REQUIRED", + "REQUEST_TOO_LARGE", + "IDEMPOTENCY_CONFLICT", ] as const; export type ApplicationErrorCode = (typeof APPLICATION_ERROR_CODES)[number]; diff --git a/src/application/operations/adopt-grants.ts b/src/application/operations/adopt-grants.ts index 3255a4f..9443be0 100644 --- a/src/application/operations/adopt-grants.ts +++ b/src/application/operations/adopt-grants.ts @@ -1,6 +1,6 @@ import { appendFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { authedSession } from "../../api/session.js"; +import { authedSession, type AuthedSession } from "../../api/session.js"; import type { CtClient } from "../../api/ctClient.js"; import { assertNotPeople } from "../../engine/guard.js"; import { buildAdoptedGrants, type AdoptedGrantsBlock } from "../../permissions/adopt.js"; @@ -11,7 +11,7 @@ import { declarability, decodeGroupsWithRoles, type RoleInstance } from "../../c import { slug } from "../../resources/registry.js"; import { loadState, type State } from "../../state/state.js"; import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; -import { resolveProject } from "../project.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; import { noopObserver, type OperationObserver } from "../ports.js"; export interface AdoptGrantsRequest extends ProjectRequest { @@ -65,6 +65,12 @@ function normalizeDomainType(raw: string): DomainType { */ export interface AdoptGrantsDependencies { observer?: OperationObserver; + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + loadState?: typeof loadState; + loadHostCatalog?: typeof loadHostCatalog; + authedSession?: () => Promise; + appendFile?: typeof appendFile; } export async function runAdoptGrants( @@ -83,15 +89,18 @@ export async function runAdoptGrants( // Load + validate the state file (host guard) BEFORE any network call, mirroring `ct adopt`, // so a state file recorded against another instance never triggers a request to the wrong host. - const project = await resolveProject(opts); - const state = await loadState(project.statePath, project.host); + const project = await (dependencies.resolveProject ?? resolveProject)(opts, dependencies.project); + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); // Bulk selection runs the same declarability verdict as `ct coverage` (over the effective // rather than the authored rows — see the call below), so it needs this host's catalog for // the same reason (#105): under the bundled one, `--all-declarable` // silently SKIPS role instances `ct plan` would manage, filed under an authId the active // catalog can name perfectly well. - const hostCatalog = await loadHostCatalog(project.host, resolve(project.cwd, CATALOG_DIR)); - const { client } = await authedSession(); + const hostCatalog = await (dependencies.loadHostCatalog ?? loadHostCatalog)( + project.host, + resolve(project.cwd, CATALOG_DIR), + ); + const { client } = await (dependencies.authedSession ?? authedSession)(); const bulkEmission = bulk ? await emitBulk(client, state, opts) : null; const emitted = bulkEmission?.blocks ?? [await emitSingle(client, state, opts.domainType, opts.domainId)]; @@ -115,7 +124,7 @@ export async function runAdoptGrants( let writtenPath: string | null = null; if (opts.write) { writtenPath = resolve(project.cwd, opts.write); - await appendFile(writtenPath, text, "utf8"); + await (dependencies.appendFile ?? appendFile)(writtenPath, text, "utf8"); } return { operation: "adopt", diff --git a/src/application/operations/adopt-group.ts b/src/application/operations/adopt-group.ts index f7ccdd7..84ef9c8 100644 --- a/src/application/operations/adopt-group.ts +++ b/src/application/operations/adopt-group.ts @@ -10,7 +10,7 @@ */ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { authedSession } from "../../api/session.js"; +import { authedSession, type AuthedSession } from "../../api/session.js"; import { CtApiError, type CtClient } from "../../api/ctClient.js"; import { formatError } from "../../api/format.js"; import { normalizeRuleset } from "../../engine/dynamic.js"; @@ -30,8 +30,8 @@ import { formatPortablizeWarnings, portablizeRuleset, scanUnportablized } from " import { chooseAdoptKey, loadState, saveState, upsert, type State } from "../../state/state.js"; import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; import { InMemoryMutationLock } from "../prepared-operation-store.js"; -import { resolveProject } from "../project.js"; -import { noopObserver, type OperationObserver } from "../ports.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; +import { noopObserver, type MutationLock, type OperationObserver } from "../ports.js"; import { warningSink, type WarningSink } from "../warnings.js"; export interface AdoptGroupRequest extends ProjectRequest { @@ -327,6 +327,12 @@ async function captureDynamic( export interface AdoptGroupDependencies { observer?: OperationObserver; + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + loadState?: typeof loadState; + saveState?: typeof saveState; + authedSession?: () => Promise; + lock?: MutationLock; } export async function runAdoptGroups( @@ -354,10 +360,10 @@ export async function runAdoptGroups( throw new Error("--key is only valid when adopting a single group."); } - const project = await resolveProject(opts); - return mutationLock.runExclusive(project.statePath, async () => { - const state = await loadState(project.statePath, project.host); - const { client } = await authedSession(); + const project = await (dependencies.resolveProject ?? resolveProject)(opts, dependencies.project); + return (dependencies.lock ?? mutationLock).runExclusive(project.statePath, async () => { + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const { client } = await (dependencies.authedSession ?? authedSession)(); let resolvedIds: number[]; if (ids.length > 0) { @@ -405,6 +411,7 @@ export async function runAdoptGroups( if (opts.withDynamic && opts.portableRulesets !== false) { portableCatalogMaps.campus = await reverse.idToKeyByKind("campus"); portableCatalogMaps["group-type"] = await reverse.idToKeyByKind("group-type"); + portableCatalogMaps["group-status"] = await reverse.idToKeyByKind("group-status"); groupTypeIdToKey = portableCatalogMaps["group-type"]; roleCatalog = await reverse.roleGroupTypeCatalog(); } @@ -550,7 +557,7 @@ export async function runAdoptGroups( }; } - await saveState(project.statePath, state); + await (dependencies.saveState ?? saveState)(project.statePath, state); if (reports.some((report) => report.action === "updated")) { addWarning({ code: "ADOPT_ALREADY_MANAGED", diff --git a/src/application/operations/apply.ts b/src/application/operations/apply.ts index 6b2be7b..61b2197 100644 --- a/src/application/operations/apply.ts +++ b/src/application/operations/apply.ts @@ -44,6 +44,12 @@ export interface PreparedApply { confirmation: ConfirmationRequirement; /** `null` when the prepared operation has no wall-clock expiry (the CLI's own runs). */ expiresAt: string | null; + bindings: { + environment: string | null; + configDigest: string; + stateDigest: string; + planDigest: string; + }; } export interface ApplyValue { @@ -60,6 +66,8 @@ export type ApplyResult = OperationResult; export interface PreparedApplyExecution { context: BuiltPlanContext; stateFingerprint: string; + configFingerprint: string; + planDigest: string; backupDir?: string; refresh: boolean; confirmation: ConfirmationRequirement; @@ -108,6 +116,23 @@ async function stateFingerprint( } } +function preparedPlanDigest(context: BuiltPlanContext): string { + return createHash("sha256") + .update( + JSON.stringify({ + project: { + environment: context.result.project.environment, + host: context.result.project.host, + configPath: context.result.project.configPath, + statePath: context.result.project.statePath, + }, + plan: context.result.value.plan, + permissions: context.result.value.permissions, + }), + ) + .digest("hex"); +} + /** backups/ dir: explicit flag → CT_BACKUP_DIR → `backups/` beside the state file. */ export function resolveBackupDir( explicit: string | undefined, @@ -185,11 +210,18 @@ export async function prepareApply( summary.permissions.toDelete; const confirmation = confirmationFor(context, changeCount); const fingerprint = await stateFingerprint(context.result.project.statePath, dependencies.readStateFile); + const configFingerprint = await stateFingerprint( + context.result.project.configPath, + dependencies.readStateFile, + ); + const planDigest = preparedPlanDigest(context); const store = dependencies.store ?? defaultStore; const stored = store.put( { context, stateFingerprint: fingerprint, + configFingerprint, + planDigest, backupDir: request.backupDir, refresh: request.refresh ?? false, confirmation, @@ -203,6 +235,12 @@ export async function prepareApply( changeCount, confirmation, expiresAt: stored.expiresAt === null ? null : stored.expiresAt.toISOString(), + bindings: { + environment: context.result.project.environment, + configDigest: configFingerprint, + stateDigest: fingerprint, + planDigest, + }, }; } @@ -228,6 +266,17 @@ export async function executePreparedApply( { details: { statePath } }, ); } + const currentConfigFingerprint = await stateFingerprint( + stored.context.result.project.configPath, + dependencies.readStateFile, + ); + if (currentConfigFingerprint !== stored.configFingerprint) { + throw new CtApplicationError( + "PLAN_CONFIRMATION_MISMATCH", + "The config file changed after this apply was prepared. Prepare and confirm a new plan.", + { details: { configPath: stored.context.result.project.configPath } }, + ); + } const observer = dependencies.observer ?? noopObserver; const { context } = stored; diff --git a/src/application/operations/environment.ts b/src/application/operations/environment.ts new file mode 100644 index 0000000..7cb8284 --- /dev/null +++ b/src/application/operations/environment.ts @@ -0,0 +1,29 @@ +import { resolve } from "node:path"; +import { loadEnvProfiles, resolveEnvsPath, type EnvProfile } from "../../env/envs.js"; + +export interface ListEnvironmentsRequest { + cwd?: string; +} + +export interface ListEnvironmentsResult { + operation: "environment"; + environmentsPath: string; + environments: EnvProfile[]; +} + +export interface ListEnvironmentsDependencies { + cwd?: () => string; + env?: NodeJS.ProcessEnv; + load?: typeof loadEnvProfiles; +} + +/** Return non-secret environment profiles for adapter selection and discovery. */ +export async function listEnvironments( + request: ListEnvironmentsRequest = {}, + dependencies: ListEnvironmentsDependencies = {}, +): Promise { + const cwd = resolve(dependencies.cwd?.() ?? process.cwd(), request.cwd ?? "."); + const environmentsPath = resolve(cwd, resolveEnvsPath(undefined, dependencies.env ?? process.env)); + const environments = await (dependencies.load ?? loadEnvProfiles)(environmentsPath); + return { operation: "environment", environmentsPath, environments }; +} diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index 3a235dc..1422225 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -8,3 +8,9 @@ export * from "./auth.js"; export * from "./adopt-group.js"; export * from "./adopt-grants.js"; export * from "./destroy.js"; +export * from "./use.js"; +export * from "./ownership.js"; +export * from "./release.js"; +export * from "./init.js"; +export * from "./environment.js"; +export * from "./input.js"; diff --git a/src/application/operations/init.ts b/src/application/operations/init.ts new file mode 100644 index 0000000..4067074 --- /dev/null +++ b/src/application/operations/init.ts @@ -0,0 +1,40 @@ +import { initializeConfigRepository, type InitResult } from "../../init.js"; + +export interface InitWorkspaceRequest { + directory: string; + template?: string; + host?: string; + environment?: string; + protected?: boolean; + git?: boolean; + yes?: boolean; +} + +export interface InitWorkspaceResult { + operation: "init"; + value: InitResult; +} + +export interface InitWorkspaceDependencies { + initialize?: typeof initializeConfigRepository; + isTTY?: boolean; + ask?: (question: string) => Promise; +} + +/** Non-interactive workspace initialization shared by CLI and HTTP adapters. */ +export async function runInitWorkspace( + request: InitWorkspaceRequest, + dependencies: InitWorkspaceDependencies = {}, +): Promise { + const value = await (dependencies.initialize ?? initializeConfigRepository)(request.directory, { + template: request.template, + host: request.host, + environment: request.environment, + protected: request.protected, + git: request.git, + yes: request.yes, + isTTY: dependencies.isTTY, + ask: dependencies.ask, + }); + return { operation: "init", value }; +} diff --git a/src/application/operations/input.ts b/src/application/operations/input.ts new file mode 100644 index 0000000..ab1d9ec --- /dev/null +++ b/src/application/operations/input.ts @@ -0,0 +1,177 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { JsonValue } from "../contracts.js"; +import type { DesiredResource } from "../../engine/types.js"; +import type { DesiredPermission } from "../../permissions/types.js"; + +export interface ProcessInputDocument { + schemaVersion: string; + clientRevision: string; + payload: JsonValue; +} + +export interface ProcessInputSnapshot extends ProcessInputDocument { + digest: string; + createdAt: string; +} + +export interface CreateInputSnapshotRequest extends ProcessInputDocument { + cwd?: string; + persist?: boolean; +} + +export interface InputSnapshotResult { + operation: "input"; + value: ProcessInputSnapshot; + persisted: boolean; +} + +export interface ListInputSnapshotsResult { + operation: "input"; + snapshots: ProcessInputSnapshot[]; +} + +export interface ValidateInputResult { + operation: "input"; + valid: boolean; + errors: { path: string; message: string }[]; + digest: string | null; +} + +export interface InputOperationDependencies { + now?: () => Date; +} + +export interface ProcessInputValidation { + valid: boolean; + errors: { path: string; message: string }[]; +} + +export interface GeneratedProcessConfig { + resources: DesiredResource[]; + permissions: DesiredPermission[]; +} + +/** Installed by the operator; process input can select data, never executable code. */ +export interface ProcessInputGenerator { + id: string; + supportedSchemaVersions: readonly string[]; + validate(document: ProcessInputDocument): ProcessInputValidation | Promise; + generate(document: ProcessInputDocument): GeneratedProcessConfig | Promise; +} + +function canonical(value: JsonValue): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(value[key]!)}`) + .join(",")}}`; +} + +function documentDigest(document: ProcessInputDocument): string { + return createHash("sha256") + .update(canonical(document as unknown as JsonValue)) + .digest("hex"); +} + +function snapshotDirectory(cwd: string): string { + return join(resolve(cwd), ".ct", "process-input", "snapshots"); +} + +export function validateProcessInput(document: unknown): ValidateInputResult { + const errors: { path: string; message: string }[] = []; + if (typeof document !== "object" || document === null || Array.isArray(document)) { + return { + operation: "input", + valid: false, + errors: [{ path: "", message: "expected an object" }], + digest: null, + }; + } + const candidate = document as Record; + if (typeof candidate.schemaVersion !== "string" || candidate.schemaVersion.trim() === "") { + errors.push({ path: "/schemaVersion", message: "must be a non-empty string" }); + } + if (typeof candidate.clientRevision !== "string" || candidate.clientRevision.trim() === "") { + errors.push({ path: "/clientRevision", message: "must be a non-empty string" }); + } + if (!("payload" in candidate)) errors.push({ path: "/payload", message: "is required" }); + if (errors.length > 0) return { operation: "input", valid: false, errors, digest: null }; + const normalized: ProcessInputDocument = { + schemaVersion: candidate.schemaVersion as string, + clientRevision: candidate.clientRevision as string, + payload: candidate.payload as JsonValue, + }; + return { operation: "input", valid: true, errors: [], digest: documentDigest(normalized) }; +} + +export async function createInputSnapshot( + request: CreateInputSnapshotRequest, + dependencies: InputOperationDependencies = {}, +): Promise { + const validation = validateProcessInput(request); + if (!validation.valid || !validation.digest) { + throw new Error( + `Invalid process input: ${validation.errors.map((item) => `${item.path} ${item.message}`).join(", ")}`, + ); + } + let value: ProcessInputSnapshot = { + schemaVersion: request.schemaVersion, + clientRevision: request.clientRevision, + payload: request.payload, + digest: validation.digest, + createdAt: (dependencies.now?.() ?? new Date()).toISOString(), + }; + const persisted = request.persist !== false; + if (persisted) { + const directory = snapshotDirectory(request.cwd ?? process.cwd()); + await mkdir(directory, { recursive: true }); + const path = join(directory, `${value.digest}.json`); + try { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); + } catch (caught) { + if (!( + typeof caught === "object" && + caught !== null && + (caught as NodeJS.ErrnoException).code === "EEXIST" + )) { + throw caught; + } + value = JSON.parse(await readFile(path, "utf8")) as ProcessInputSnapshot; + } + } + return { operation: "input", value, persisted }; +} + +export async function getInputSnapshot(cwd: string, digest: string): Promise { + if (!/^[a-f0-9]{64}$/.test(digest)) throw new Error("Invalid snapshot digest."); + const path = join(snapshotDirectory(cwd), `${digest}.json`); + const value = JSON.parse(await readFile(path, "utf8")) as ProcessInputSnapshot; + return { operation: "input", value, persisted: true }; +} + +export async function listInputSnapshots(cwd: string): Promise { + const directory = snapshotDirectory(cwd); + let names: string[]; + try { + names = await readdir(directory); + } catch (caught) { + if ( + typeof caught === "object" && + caught !== null && + (caught as NodeJS.ErrnoException).code === "ENOENT" + ) { + return { operation: "input", snapshots: [] }; + } + throw caught; + } + const snapshots = await Promise.all( + names + .filter((name) => /^[a-f0-9]{64}\.json$/.test(name)) + .map(async (name) => JSON.parse(await readFile(join(directory, name), "utf8")) as ProcessInputSnapshot), + ); + snapshots.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + return { operation: "input", snapshots }; +} diff --git a/src/application/operations/ownership.ts b/src/application/operations/ownership.ts new file mode 100644 index 0000000..94dce8f --- /dev/null +++ b/src/application/operations/ownership.ts @@ -0,0 +1,351 @@ +import { readdir } from "node:fs/promises"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { loadEnvProfile } from "../../env/envs.js"; +import { identityDifferences } from "../../resolve/external.js"; +import { resourceType } from "../../resources/registry.js"; +import { + externalResources, + loadState, + type ExternalResource, + type ManagedResource, +} from "../../state/state.js"; +import type { OperationResult } from "../contracts.js"; + +const IGNORED_DIRECTORIES = new Set([ + ".git", + "node_modules", + "dist", + "build", + "coverage", + ".next", + ".turbo", + "out", +]); + +export interface OwnershipCheckRequest { + root: string; + environment: string; + cwd?: string; +} + +export interface OwnershipProject { + name: string; + path: string; + relativePath: string; + host: string; + statePath: string; + managed: ManagedResource[]; + externals: ExternalResource[]; +} + +export type OwnershipReason = + | "DUPLICATE_OWNER" + | "OWNER_HINT_MISMATCH" + | "OWNER_OUTSIDE_SCOPE" + | "OWNER_NOT_VISIBLE" + | "KEY_MISMATCH" + | "CONFLICTING_BINDING" + | "INCOMPATIBLE_IDENTITY" + | "PROJECT_STATE_INVALID"; + +export interface OwnershipFinding { + severity: "ok" | "error"; + reason: OwnershipReason | "OWNERSHIP_OK"; + host: string; + type?: string; + id?: number; + key?: string; + projects: string[]; + message: string; + remediation?: string[]; +} + +export type OwnershipCheckResult = OperationResult<{ + root: string; + environment: string; + projects: OwnershipProject[]; + hosts: string[]; + findings: OwnershipFinding[]; + conflicts: number; + completeScope: true; +}>; + +export interface OwnershipDependencies { + discover?: (root: string) => Promise; + loadEnvProfile?: typeof loadEnvProfile; + loadState?: typeof loadState; +} + +async function discoverEnvironmentFiles(root: string): Promise { + const found: string[] = []; + const walk = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + if (entry.isSymbolicLink()) return; + const path = join(directory, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIRECTORIES.has(entry.name)) await walk(path); + } else if (entry.isFile() && entry.name === "ct.envs.json") { + found.push(path); + } + }), + ); + }; + await walk(root); + return found.sort(); +} + +function pair(type: string, id: number): string { + return `${type}\0${id}`; +} + +function claim( + project: OwnershipProject, + entry: ManagedResource | ExternalResource, + kind: "managed" | "external", +) { + return { project, entry, kind } as const; +} + +/** Analyse only the explicitly supplied directory tree; no network and no search outside it. */ +export async function checkOwnership( + request: OwnershipCheckRequest, + dependencies: OwnershipDependencies = {}, +): Promise { + if (!request.environment.trim()) throw new Error("ownership check requires --env ."); + const cwd = resolve(request.cwd ?? process.cwd()); + const root = resolve(cwd, request.root); + const envFiles = await (dependencies.discover ?? discoverEnvironmentFiles)(root); + const projects: OwnershipProject[] = []; + const findings: OwnershipFinding[] = []; + + for (const envPath of envFiles) { + const path = dirname(envPath); + let profile; + try { + profile = await (dependencies.loadEnvProfile ?? loadEnvProfile)(request.environment, envPath); + } catch (error) { + if (error instanceof Error && error.message.startsWith("Unknown environment")) continue; + findings.push({ + severity: "error", + reason: "PROJECT_STATE_INVALID", + host: "unknown", + projects: [relative(root, path) || "."], + message: error instanceof Error ? error.message : String(error), + }); + continue; + } + const statePath = resolve(path, profile.statePath); + try { + const state = await (dependencies.loadState ?? loadState)(statePath, profile.host); + projects.push({ + name: basename(path), + path, + relativePath: relative(root, path) || ".", + host: profile.host, + statePath, + managed: Object.values(state.resources), + externals: Object.values(externalResources(state)), + }); + } catch (error) { + findings.push({ + severity: "error", + reason: "PROJECT_STATE_INVALID", + host: profile.host, + projects: [relative(root, path) || "."], + message: `Cannot inspect ${statePath}: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + + for (const host of [...new Set(projects.map((project) => project.host))].sort()) { + const scoped = projects.filter((project) => project.host === host); + const claims = scoped.flatMap((project) => [ + ...project.managed.map((entry) => claim(project, entry, "managed")), + ...project.externals.map((entry) => claim(project, entry, "external")), + ]); + const byPair = new Map(); + const byKey = new Map(); + for (const item of claims) { + const p = pair(item.entry.type, item.entry.id); + byPair.set(p, [...(byPair.get(p) ?? []), item]); + byKey.set(item.entry.key, [...(byKey.get(item.entry.key) ?? []), item]); + } + + for (const items of byPair.values()) { + const first = items[0]!; + const errorsBefore = findings.filter((finding) => finding.severity === "error").length; + const owners = items.filter((item) => item.kind === "managed"); + const consumers = items.filter((item) => item.kind === "external"); + const keys = [...new Set(items.map((item) => item.entry.key))]; + if (owners.length > 1) { + findings.push({ + severity: "error", + reason: "DUPLICATE_OWNER", + host, + type: first.entry.type, + id: first.entry.id, + projects: owners.map((item) => item.project.relativePath), + message: `${first.entry.type} #${first.entry.id} is managed by ${owners.length} visible ct projects.`, + remediation: owners + .slice(1) + .map( + (item) => + `cd ${item.project.path} && ct unadopt ${item.entry.type} ${item.entry.key} --env ${request.environment}`, + ), + }); + } + if (keys.length > 1) { + const canonical = owners[0]?.entry.key ?? keys[0]!; + findings.push({ + severity: "error", + reason: "KEY_MISMATCH", + host, + type: first.entry.type, + id: first.entry.id, + projects: items.map((item) => item.project.relativePath), + message: `${first.entry.type} #${first.entry.id} uses different logical keys: ${keys.join(", ")}.`, + remediation: items + .filter((item) => item.entry.key !== canonical) + .map( + (item) => + `cd ${item.project.path} && ct state rekey ${item.entry.type} ${item.entry.key} ${canonical} --env ${request.environment}`, + ), + }); + } + for (const consumer of consumers) { + const consumerEntry = consumer.entry as ExternalResource; + const hinted = consumerEntry.owner; + if (hinted) { + const visible = scoped.find( + (project) => + project.name === hinted || project.relativePath === hinted || project.path === hinted, + ); + if (!visible) { + findings.push({ + severity: "error", + reason: "OWNER_OUTSIDE_SCOPE", + host, + type: consumer.entry.type, + id: consumer.entry.id, + key: consumer.entry.key, + projects: [consumer.project.relativePath], + message: `Owner hint ${JSON.stringify(hinted)} is not visible below ${root}.`, + remediation: [`ct ownership check --env ${request.environment}`], + }); + } else if ( + !visible.managed.some( + (entry) => entry.type === consumer.entry.type && entry.id === consumer.entry.id, + ) + ) { + findings.push({ + severity: "error", + reason: "OWNER_HINT_MISMATCH", + host, + type: consumer.entry.type, + id: consumer.entry.id, + key: consumer.entry.key, + projects: [consumer.project.relativePath, visible.relativePath], + message: `Owner hint ${JSON.stringify(hinted)} is visible but does not manage this binding.`, + remediation: [`Correct --owner metadata with ct use, or repair the hinted owner's state.`], + }); + } + } + if ( + owners.length === 0 && + !findings.some( + (finding) => finding.reason === "OWNER_OUTSIDE_SCOPE" && finding.key === consumer.entry.key, + ) + ) { + findings.push({ + severity: "error", + reason: "OWNER_NOT_VISIBLE", + host, + type: consumer.entry.type, + id: consumer.entry.id, + key: consumer.entry.key, + projects: [consumer.project.relativePath], + message: `No visible ct project manages ${consumer.entry.type} #${consumer.entry.id}.`, + remediation: [`Broaden the explicit root or establish exactly one managed owner.`], + }); + } + const owner = owners[0]; + if (owner) { + const ownerEntry = owner.entry as ManagedResource; + const ownerIdentity = resourceType(ownerEntry.type).external.identity(ownerEntry.fields); + const diff = identityDifferences(consumerEntry.identity, ownerIdentity); + if (diff.length > 0) { + findings.push({ + severity: "error", + reason: "INCOMPATIBLE_IDENTITY", + host, + type: consumer.entry.type, + id: consumer.entry.id, + key: consumer.entry.key, + projects: [owner.project.relativePath, consumer.project.relativePath], + message: `Owner managed snapshot and consumer hard identity disagree (${diff.map((item) => item.field).join(", ")}).`, + remediation: [ + `cd ${consumer.project.path} && ct use ${consumer.entry.type} ${consumer.entry.id} --key ${consumer.entry.key} --env ${request.environment}`, + ], + }); + } + } + } + const errorsAfter = findings.filter((finding) => finding.severity === "error").length; + if (owners.length === 1 && consumers.length > 0 && keys.length === 1 && errorsAfter === errorsBefore) { + findings.push({ + severity: "ok", + reason: "OWNERSHIP_OK", + host, + type: first.entry.type, + id: first.entry.id, + key: first.entry.key, + projects: items.map((item) => item.project.relativePath), + message: `${owners[0]!.project.relativePath} owns; ${consumers.length} consumer(s) bind read-only.`, + }); + } + } + + for (const [key, items] of byKey) { + const bindings = [...new Set(items.map((item) => pair(item.entry.type, item.entry.id)))]; + if (bindings.length > 1) { + findings.push({ + severity: "error", + reason: "CONFLICTING_BINDING", + host, + key, + projects: items.map((item) => item.project.relativePath), + message: `Logical key ${JSON.stringify(key)} maps to incompatible type/id bindings on this host.`, + remediation: ["Rekey the conflicting project state so one portable key has one meaning."], + }); + } + } + } + + const conflicts = findings.filter((finding) => finding.severity === "error").length; + return { + operation: "ownership", + project: { + cwd, + configPath: "", + statePath: "", + environmentsPath: "", + configDisplayPath: "", + stateDisplayPath: "", + environment: request.environment, + protected: false, + host: "multiple", + }, + warnings: [], + value: { + root, + environment: request.environment, + projects, + hosts: [...new Set(projects.map((project) => project.host))].sort(), + findings, + conflicts, + completeScope: true, + }, + }; +} diff --git a/src/application/operations/plan.ts b/src/application/operations/plan.ts index 86b6fb7..3281b60 100644 --- a/src/application/operations/plan.ts +++ b/src/application/operations/plan.ts @@ -6,11 +6,14 @@ import { summarize, type Plan, type PlanAction } from "../../engine/types.js"; import { CATALOG_DIR, loadHostCatalog } from "../../permissions/catalog-store.js"; import { buildPermissionPlan, type PermissionPlanItem } from "../../permissions/plan.js"; import { Resolver } from "../../resolve/resolver.js"; +import { ExternalReferenceError } from "../../resolve/external.js"; import { loadState, type State } from "../../state/state.js"; import type { CtClient } from "../../api/ctClient.js"; import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; import { noopObserver, type OperationObserver } from "../ports.js"; import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; +import { CtApplicationError } from "../errors.js"; +import type { JsonValue } from "../contracts.js"; export type PlanRequest = ProjectRequest; @@ -120,20 +123,39 @@ export async function buildPlanContext( state, desired, host: project.host, + context: { + consumer: project.cwd.split(/[\\/]/).filter(Boolean).at(-1), + cwd: project.cwd, + configPath: project.configPath, + statePath: project.statePath, + environment: project.environment, + }, }); observer.emit({ type: "phase-started", phase: "build-plan" }); - const [resourceResult, permissionResult] = await Promise.all([ - (dependencies.buildPlan ?? buildPlan)(client, state, desired, { configDir, resolver }), - (dependencies.buildPermissionPlan ?? buildPermissionPlan)( - client, - state, - permissions, - desired, - resolver, - client.version ?? undefined, - ), - ]); + let resourceResult: Awaited>; + let permissionResult: Awaited>; + try { + [resourceResult, permissionResult] = await Promise.all([ + (dependencies.buildPlan ?? buildPlan)(client, state, desired, { configDir, resolver }), + (dependencies.buildPermissionPlan ?? buildPermissionPlan)( + client, + state, + permissions, + desired, + resolver, + client.version ?? undefined, + ), + ]); + } catch (error) { + if (error instanceof ExternalReferenceError) { + throw new CtApplicationError("EXTERNAL_REFERENCE_BLOCKED", error.message, { + details: error.details as unknown as Record, + cause: error, + }); + } + throw error; + } const fetchErrors = [...resourceResult.fetchErrors, ...permissionResult.fetchErrors]; const warnings: CtWarning[] = permissionResult.warnings.map((message) => ({ code: "PERMISSION_CATALOG", diff --git a/src/application/operations/release.ts b/src/application/operations/release.ts new file mode 100644 index 0000000..c7032c6 --- /dev/null +++ b/src/application/operations/release.ts @@ -0,0 +1,102 @@ +import type { ExternalResource, ManagedResource } from "../../state/state.js"; +import { CtApplicationError } from "../errors.js"; +import { PreparedOperationStore } from "../prepared-operation-store.js"; +import { + removeStateEntry, + type StateOperationDependencies, + type StateRemoveRequest, + type StateRemoveResult, +} from "./state.js"; + +export type ReleaseKind = "managed" | "external"; +export type ReleaseOperation = "unadopt" | "unuse"; +export type ReleaseConfirmation = + { type: "environment"; expected: string } | { type: "key"; expected: string }; +export type ReleaseConfirmationProof = + { type: "environment"; value: string } | { type: "key"; value: string }; + +export interface ReleaseRequest extends StateRemoveRequest { + kind: ReleaseKind; +} + +export interface PreparedReleaseExecution { + request: StateRemoveRequest; + entry: ManagedResource | ExternalResource; + confirmation: ReleaseConfirmation; +} + +export interface PreparedRelease { + id: string; + preview: StateRemoveResult; + confirmation: ReleaseConfirmation; +} + +export interface ReleaseOperationDependencies extends StateOperationDependencies { + store?: PreparedOperationStore; + /** `null` disables wall-clock expiry for a CLI prompt; adapters get a bounded default. */ + preparedTtlMs?: number | null; +} + +const PREPARED_RELEASE_TTL_MS = 5 * 60 * 1000; +const defaultStore = new PreparedOperationStore(); + +function operation(kind: ReleaseKind): ReleaseOperation { + return kind === "managed" ? "unadopt" : "unuse"; +} + +function assertConfirmation(requirement: ReleaseConfirmation, proof?: ReleaseConfirmationProof): void { + if (proof?.type === requirement.type && proof.value === requirement.expected) return; + throw new CtApplicationError( + "STATE_RELEASE_CONFIRMATION_REQUIRED", + `${requirement.type === "environment" ? "Environment" : "Logical key"} ` + + `${JSON.stringify(requirement.expected)} was not confirmed. State was not changed.`, + { details: { confirmationType: requirement.type, expected: requirement.expected } }, + ); +} + +/** Prepare and retain the exact state entry the adapter must present for confirmation. */ +export async function prepareRelease( + request: ReleaseRequest, + dependencies: ReleaseOperationDependencies = {}, +): Promise { + const op = operation(request.kind); + const stateRequest: StateRemoveRequest = { + type: request.type, + key: request.key, + cwd: request.cwd, + configPath: request.configPath, + statePath: request.statePath, + environment: request.environment, + force: request.force, + expectedKind: request.kind, + requireReadableConfig: true, + operation: op, + dryRun: true, + }; + const preview = await removeStateEntry(stateRequest, dependencies); + const confirmation: ReleaseConfirmation = preview.project.environment + ? { type: "environment", expected: preview.project.environment } + : { type: "key", expected: request.key }; + const stored = (dependencies.store ?? defaultStore).put( + { + request: { ...stateRequest, dryRun: false }, + entry: preview.value.entry, + confirmation, + }, + dependencies.preparedTtlMs === undefined ? PREPARED_RELEASE_TTL_MS : dependencies.preparedTtlMs, + ); + return { id: stored.id, preview, confirmation }; +} + +/** Execute only the immutable entry that was previewed, after application-level proof validation. */ +export async function executePreparedRelease( + prepared: Pick, + proof: ReleaseConfirmationProof | undefined, + dependencies: ReleaseOperationDependencies = {}, +): Promise { + const store = dependencies.store ?? defaultStore; + const candidate = store.peek(prepared.id); + assertConfirmation(candidate.confirmation, proof); + store.take(prepared.id); + return removeStateEntry({ ...candidate.request, expectedEntry: candidate.entry }, dependencies); +} diff --git a/src/application/operations/state.ts b/src/application/operations/state.ts index 4ec60a5..af2d12a 100644 --- a/src/application/operations/state.ts +++ b/src/application/operations/state.ts @@ -1,7 +1,13 @@ import { loadConfig } from "../../config/load.js"; import { resourceType } from "../../resources/registry.js"; import { collectRefs, isRef, type Ref } from "../../resolve/refs.js"; -import { loadState, saveState, type ManagedResource } from "../../state/state.js"; +import { + externalResources, + loadState, + saveState, + type ExternalResource, + type ManagedResource, +} from "../../state/state.js"; import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; import { InMemoryMutationLock } from "../prepared-operation-store.js"; import type { MutationLock } from "../ports.js"; @@ -16,17 +22,34 @@ export interface StateOperationDependencies { lock?: MutationLock; } -export type StateListResult = OperationResult<{ resources: ManagedResource[] }>; +export type StateEntry = + | { kind: "managed"; ownership: "owned"; entry: ManagedResource } + | { kind: "external"; ownership: "read-only"; entry: ExternalResource }; + +export interface StateListRequest extends ProjectRequest { + managed?: boolean; + external?: boolean; +} + +export type StateListResult = OperationResult<{ entries: StateEntry[]; resources: ManagedResource[] }>; export interface StateRemoveRequest extends ProjectRequest { type: string; key: string; force?: boolean; dryRun?: boolean; + /** Restrict a public lifecycle command to its own state partition. */ + expectedKind?: "managed" | "external"; + /** Refuse a stale prepare/confirm/execute sequence if any binding metadata changed meanwhile. */ + expectedEntry?: ManagedResource | ExternalResource; + /** Safe public commands fail closed when their reference guard cannot load config. */ + requireReadableConfig?: boolean; + operation?: "state" | "unuse" | "unadopt"; } export type StateRemoveResult = OperationResult<{ - entry: ManagedResource; + kind: "managed" | "external"; + entry: ManagedResource | ExternalResource; removed: boolean; churchToolsContacted: false; }>; @@ -34,16 +57,29 @@ export type StateRemoveResult = OperationResult<{ const defaultLock = new InMemoryMutationLock(); export async function listState( - request: ProjectRequest = {}, + request: StateListRequest = {}, dependencies: StateOperationDependencies = {}, ): Promise { const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const includeManaged = request.managed || !request.external; + const includeExternal = request.external || !request.managed; + const resources = includeManaged ? Object.values(state.resources) : []; + const entries: StateEntry[] = [ + ...resources.map((entry): StateEntry => ({ kind: "managed", ownership: "owned", entry })), + ...(includeExternal + ? Object.values(externalResources(state)).map((entry): StateEntry => ({ + kind: "external", + ownership: "read-only", + entry, + })) + : []), + ]; return { operation: "state", project, warnings: [], - value: { resources: Object.values(state.resources) }, + value: { entries, resources }, }; } @@ -59,6 +95,15 @@ async function declaredKeys( else if (ref.kind === "group-member-field") keys.add(ref.group); else keys.add(ref.key); }; + // Resource fields and dynamic rulesets carry typed ref.* values; parent/dependsOn + // remain portable string keys. Walk all of them so unuse/unadopt cannot leave a + // config that only fails on the next plan. + for (const resource of resources) { + if (resource.parent) keys.add(resource.parent); + for (const key of resource.parents ?? []) keys.add(key); + for (const key of resource.dependsOn ?? []) keys.add(key); + for (const ref of collectRefs([resource.fields, resource.dynamic?.ruleset])) addRef(ref); + } for (const ref of collectRefs(permissions)) addRef(ref); for (const permission of permissions) { for (const grant of permission.grants) { @@ -86,7 +131,9 @@ export async function removeStateEntry( const lock = dependencies.lock ?? defaultLock; return lock.runExclusive(project.statePath, async () => { const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); - const entry = state.resources[request.key]; + const managed = state.resources[request.key]; + const external = externalResources(state)[request.key]; + const entry = managed ?? external; if (!entry) { throw new Error( `No entry "${request.key}" in ${project.stateDisplayPath}. List them with \`ct state list\`.`, @@ -99,20 +146,41 @@ export async function removeStateEntry( ); } + const kind = managed ? "managed" : "external"; + if (request.expectedKind && kind !== request.expectedKind) { + const command = kind === "managed" ? "unadopt" : "unuse"; + throw new Error( + `"${request.key}" is ${kind}, not ${request.expectedKind}. Use \`ct ${command} ${entry.type} ${request.key}\`.`, + ); + } + if (request.expectedEntry && JSON.stringify(entry) !== JSON.stringify(request.expectedEntry)) { + throw new Error( + `${kind} ${entry.type}.${request.key} changed while confirmation was pending. Inspect it and retry.`, + ); + } const warnings: CtWarning[] = []; if (!request.force) { try { const declared = await declaredKeys(project.configPath, dependencies); if (declared.has(request.key)) { + const consequence = + kind === "managed" + ? "the next plan could recreate the live object or fail to resolve one of its references" + : "the next plan would fail because the external prerequisite no longer resolves"; throw new Error( - `"${request.key}" is still declared in the config, so removing it from state would make the next ` + - `plan propose CREATING a resource that already exists on this host. Remove the ` + - `declaration first, or pass --force if you are deleting both in the same change.`, + `"${request.key}" is still declared or referenced in the config; ${consequence}. ` + + `Remove every declaration/ref first, or pass --force only when both changes belong together.`, ); } } catch (caught) { - if (caught instanceof Error && caught.message.includes("is still declared in the config")) + if (caught instanceof Error && caught.message.includes("is still declared or referenced")) throw caught; + if (request.requireReadableConfig) { + throw new Error( + `Could not read the config to verify that "${request.key}" is unused ` + + `(${caught instanceof Error ? caught.message : String(caught)}). Fix the config or pass --force after reviewing all references.`, + ); + } warnings.push({ code: "CONFIG_UNREADABLE", message: @@ -123,14 +191,91 @@ export async function removeStateEntry( } if (!request.dryRun) { - delete state.resources[request.key]; + if (kind === "managed") delete state.resources[request.key]; + else delete externalResources(state)[request.key]; await (dependencies.saveState ?? saveState)(project.statePath, state); } return { - operation: "state", + operation: request.operation ?? "state", project, warnings, - value: { entry, removed: !request.dryRun, churchToolsContacted: false }, + value: { kind, entry, removed: !request.dryRun, churchToolsContacted: false }, + }; + }); +} + +export interface StateRekeyRequest extends ProjectRequest { + type: string; + oldKey: string; + newKey: string; + dryRun?: boolean; +} + +export type StateRekeyResult = OperationResult<{ + kind: "managed" | "external"; + entry: ManagedResource | ExternalResource; + oldKey: string; + newKey: string; + changed: boolean; + churchToolsContacted: false; +}>; + +export async function rekeyStateEntry( + request: StateRekeyRequest, + dependencies: StateOperationDependencies = {}, +): Promise { + resourceType(request.type); + const oldKey = request.oldKey.trim(); + const newKey = request.newKey.trim(); + if (!oldKey || !newKey) throw new Error("Old and new logical keys must be non-empty."); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const lock = dependencies.lock ?? defaultLock; + return lock.runExclusive(project.statePath, async () => { + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const externals = externalResources(state); + const managed = state.resources[oldKey]; + const external = externals[oldKey]; + const entry = managed ?? external; + if (!entry) throw new Error(`No entry "${oldKey}" in ${project.stateDisplayPath}.`); + if (entry.type !== request.type) { + throw new Error(`"${oldKey}" is a ${entry.type}, not a ${request.type}.`); + } + const collision = state.resources[newKey] ?? externals[newKey]; + if (collision && newKey !== oldKey) { + throw new Error( + `Logical key "${newKey}" is already used by ${collision.type} #${collision.id}; keys are unique across managed and external entries.`, + ); + } + const kind = managed ? "managed" : "external"; + const changed = oldKey !== newKey; + const updated = changed ? { ...entry, key: newKey } : entry; + if (changed && !request.dryRun) { + if (kind === "managed") { + delete state.resources[oldKey]; + state.resources[newKey] = updated as ManagedResource; + } else { + delete externals[oldKey]; + externals[newKey] = updated as ExternalResource; + } + await (dependencies.saveState ?? saveState)(project.statePath, state); + } + return { + operation: "state", + project, + warnings: [ + { + code: "STATE_REKEY_REFS", + message: `Update every ref.* use from "${oldKey}" to "${newKey}" consistently.`, + }, + ], + value: { + kind, + entry: updated, + oldKey, + newKey, + changed: changed && !request.dryRun, + churchToolsContacted: false, + }, }; }); } diff --git a/src/application/operations/use.ts b/src/application/operations/use.ts new file mode 100644 index 0000000..c229469 --- /dev/null +++ b/src/application/operations/use.ts @@ -0,0 +1,289 @@ +import { authedSession, type AuthedSession } from "../../api/session.js"; +import { resourceType, slug, type CtWriteClient } from "../../resources/registry.js"; +import { + externalResources, + findByTypeId, + findExternalByTypeId, + loadState, + saveState, + type ExternalResource, +} from "../../state/state.js"; +import { identityDifferences, type ExternalCandidate } from "../../resolve/external.js"; +import type { OperationResult, ProjectRequest } from "../contracts.js"; +import { CtApplicationError } from "../errors.js"; +import { InMemoryMutationLock } from "../prepared-operation-store.js"; +import { systemClock, type Clock, type MutationLock } from "../ports.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; + +export interface UseOperationDependencies { + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + loadState?: typeof loadState; + saveState?: typeof saveState; + authedSession?: () => Promise; + clock?: Clock; + lock?: MutationLock; +} + +export interface DiscoverExternalRequest extends ProjectRequest { + type: string; + search: string; +} + +export type DiscoverExternalResult = OperationResult<{ + type: string; + search: string; + candidates: ExternalCandidate[]; +}>; + +export type InspectExternalResult = OperationResult<{ + type: string; + candidate: ExternalCandidate; + /** Existing consumer key wins over a newly derived slug. */ + suggestedKey: string; +}>; + +export interface UseResourceRequest extends ProjectRequest { + type: string; + id: string | number; + key: string; + owner?: string; + acceptChanges?: boolean; + dryRun?: boolean; +} + +export type UseAction = "created" | "no-op" | "identity-updated" | "rebound" | "metadata-updated"; + +export type UseResourceResult = OperationResult<{ + action: UseAction; + binding: ExternalResource; + live: ExternalCandidate; + previous?: ExternalResource; + previousLive?: ExternalCandidate; + identityDiff: ReturnType; + written: boolean; + churchToolsWritten: false; +}>; + +const defaultLock = new InMemoryMutationLock(); + +function parseId(raw: string | number): number { + const value = String(raw).trim(); + if (!/^\d+$/.test(value)) throw new Error(`Invalid id "${raw}" — expected a non-negative integer.`); + const id = Number.parseInt(value, 10); + if (!Number.isSafeInteger(id)) throw new Error(`Invalid id "${raw}" — expected a safe integer.`); + return id; +} + +function requireKey(value: string): string { + const key = value.trim(); + if (!key) throw new Error("External logical key must be non-empty. Pass --key ."); + if (/\s/.test(key)) throw new Error(`Logical key ${JSON.stringify(key)} must not contain whitespace.`); + return key; +} + +async function readOne( + client: AuthedSession["client"], + type: string, + id: number, +): Promise> { + const spec = resourceType(type); + const row = spec.fetchOne + ? await spec.fetchOne(client as CtWriteClient, id) + : await client.get>(spec.itemPath(id)); + if (!row) throw new Error(`No ${type} with id ${id} exists in ChurchTools.`); + return row; +} + +function candidate(type: string, row: Record): ExternalCandidate { + const spec = resourceType(type); + const id = row.id; + if (typeof id !== "number") throw new Error(`Live ${type} candidate carries no numeric id.`); + return { + id, + name: typeof row.name === "string" ? row.name : `#${id}`, + identity: spec.external.identity(row), + display: spec.external.display(row), + }; +} + +/** Read-only fuzzy discovery for the terminal adapter. Never persists or resolves a reference. */ +export async function discoverExternalCandidates( + request: DiscoverExternalRequest, + dependencies: UseOperationDependencies = {}, +): Promise { + const spec = resourceType(request.type); + const search = request.search.trim(); + if (!search) throw new Error("Interactive search must be non-empty."); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const { client } = await (dependencies.authedSession ?? authedSession)(); + const page = await client.getAll>(spec.collectionPath); + const needle = search.toLocaleLowerCase(); + const needleSlug = slug(search); + const matches = page.data.filter((row) => { + const name = typeof row.name === "string" ? row.name : ""; + return name.toLocaleLowerCase().includes(needle) || slug(name).includes(needleSlug); + }); + return { + operation: "use", + project, + warnings: [], + value: { type: request.type, search, candidates: matches.map((row) => candidate(request.type, row)) }, + }; +} + +/** Inspect one exact live id for interactive key proposal and replacement presentation. */ +export async function inspectExternalCandidate( + request: ProjectRequest & { type: string; id: string | number }, + dependencies: UseOperationDependencies = {}, +): Promise { + resourceType(request.type); + const id = parseId(request.id); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const { client } = await (dependencies.authedSession ?? authedSession)(); + const row = await readOne(client, request.type, id); + const live = candidate(request.type, { ...row, id }); + const existing = findExternalByTypeId(state, request.type, id); + return { + operation: "use", + project, + warnings: [], + value: { + type: request.type, + candidate: live, + suggestedKey: existing?.key ?? resourceType(request.type).deriveKey({ ...row, id }), + }, + }; +} + +/** Validate and persist one explicit read-only binding. Never writes to ChurchTools. */ +export async function runUseResource( + request: UseResourceRequest, + dependencies: UseOperationDependencies = {}, +): Promise { + resourceType(request.type); + const id = parseId(request.id); + const key = requireKey(request.key); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const lock = dependencies.lock ?? defaultLock; + return lock.runExclusive(project.statePath, async () => { + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const managedKey = state.resources[key]; + if (managedKey) { + throw new Error( + `Logical key "${key}" is already managed as ${managedKey.type} #${managedKey.id}; it cannot also be external.`, + ); + } + const managedId = findByTypeId(state, request.type, id); + if (managedId) { + throw new Error( + `${request.type} #${id} is already managed by this ct project as "${managedId.key}"; use is read-only and cannot duplicate ownership.`, + ); + } + const alias = findExternalByTypeId(state, request.type, id); + if (alias && alias.key !== key) { + throw new Error( + `${request.type} #${id} is already external as "${alias.key}". Rekey that binding instead of creating a second alias.`, + ); + } + const { client } = await (dependencies.authedSession ?? authedSession)(); + const row = await readOne(client, request.type, id); + const live = candidate(request.type, { ...row, id }); + const entries = externalResources(state); + const existing = entries[key]; + if (existing && existing.type !== request.type) { + throw new Error( + `Logical key "${key}" is already external as ${existing.type} #${existing.id}, not ${request.type}.`, + ); + } + + const now = (dependencies.clock ?? systemClock).now().toISOString(); + let action: UseAction; + let binding: ExternalResource; + let diff: ReturnType = []; + let previousLive: ExternalCandidate | undefined; + if (!existing) { + action = "created"; + binding = { + type: request.type, + key, + id, + ...(request.owner?.trim() ? { owner: request.owner.trim() } : {}), + identity: live.identity, + boundAt: now, + }; + } else if (existing.id !== id) { + action = "rebound"; + diff = identityDifferences(existing.identity, live.identity); + try { + const oldRow = await readOne(client, request.type, existing.id); + previousLive = candidate(request.type, { ...oldRow, id: existing.id }); + } catch { + // A stale old target is still useful evidence: the persisted snapshot remains in `previous`. + } + binding = { + ...existing, + id, + identity: live.identity, + boundAt: now, + ...(request.owner?.trim() ? { owner: request.owner.trim() } : {}), + }; + } else { + diff = identityDifferences(existing.identity, live.identity); + const owner = request.owner?.trim() || existing.owner; + if (diff.length > 0) action = "identity-updated"; + else if (owner !== existing.owner) action = "metadata-updated"; + else action = "no-op"; + binding = { + ...existing, + ...(owner ? { owner } : {}), + identity: diff.length > 0 ? live.identity : existing.identity, + }; + } + + if ((action === "identity-updated" || action === "rebound") && !request.acceptChanges) { + throw new CtApplicationError( + "EXTERNAL_CONFIRMATION_REQUIRED", + action === "rebound" + ? `${request.type}.${key} is bound to #${existing!.id} (${JSON.stringify(previousLive ?? existing!.identity)}), not #${id} (${JSON.stringify(live)}). Explicit confirmation is required to replace it.` + : `${request.type}.${key} #${id} changed hard identity: ${diff.map((item) => `${item.field}: ${JSON.stringify(item.expected)} -> ${JSON.stringify(item.actual)}`).join(", ")}. Explicit confirmation is required to accept the field diff.`, + { + details: { + action, + type: request.type, + key, + oldId: existing!.id, + newId: id, + identityDiff: diff as never, + previous: existing as never, + live: live as never, + proposed: binding as never, + previousLive: (previousLive ?? null) as never, + }, + }, + ); + } + + const written = action !== "no-op" && !request.dryRun; + if (written) { + entries[key] = binding; + await (dependencies.saveState ?? saveState)(project.statePath, state); + } + return { + operation: "use", + project, + warnings: [], + value: { + action, + binding, + live, + ...(existing ? { previous: existing } : {}), + ...(previousLive ? { previousLive } : {}), + identityDiff: diff, + written, + churchToolsWritten: false, + }, + }; + }); +} diff --git a/src/auth/login.ts b/src/auth/login.ts index f546a25..8d047ba 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -246,6 +246,66 @@ export async function loginWithPassword( password: string, opts: { fetchImpl?: typeof fetch; askTotp?: () => Promise } = {}, ): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + const started = await beginPasswordLogin(host, username, password, { fetchImpl }); + if (started.kind === "token") return started.token; + if (!opts.askTotp) { + throw new LoginError( + "This account requires a 2FA code, which needs an interactive terminal.", + started.status, + ); + } + const code = (await opts.askTotp()).trim(); + return continuePasswordLogin(started.continuation, code, { fetchImpl }); +} + +export interface PasswordLoginContinuation { + host: string; + cookie: string; + personId: number; +} + +export type PasswordLoginStart = + | { kind: "token"; token: string } + | { kind: "totp"; continuation: PasswordLoginContinuation; status: number }; + +async function readPersonalToken( + continuation: PasswordLoginContinuation, + fetchImpl: typeof fetch, + secrets: (string | undefined)[] = [], +): Promise { + const tokenRes = await fetchWithRetry( + `${continuation.host}/api/persons/${continuation.personId}/logintoken`, + { + headers: { + Accept: "application/json", + ...(continuation.cookie ? { Cookie: continuation.cookie } : {}), + }, + }, + { isIdempotent: true, fetchImpl }, + ); + const tokenBody = await readJson(tokenRes); + if (!tokenRes.ok) fail("Could not read the personal login token", tokenRes, tokenBody, secrets); + const raw = tokenBody.data ?? tokenBody; + const token = + typeof raw === "string" + ? raw + : typeof raw === "object" && raw !== null + ? (raw as Record).token + : undefined; + if (typeof token !== "string" || token.trim() === "") { + throw new LoginError("ChurchTools returned no login token for this account.", tokenRes.status); + } + return token.trim(); +} + +/** Start a password login without retaining the password when ChurchTools asks for TOTP. */ +export async function beginPasswordLogin( + host: string, + username: string, + password: string, + opts: { fetchImpl?: typeof fetch } = {}, +): Promise { const fetchImpl = opts.fetchImpl ?? fetch; const secrets: (string | undefined)[] = [password]; let cookie = ""; @@ -258,56 +318,44 @@ export async function loginWithPassword( } const status = pick(loginBody, "status"); - let personId = personIdOf(loginBody); + const personId = personIdOf(loginBody); if (status === "totp") { if (personId === undefined) { throw new LoginError("ChurchTools asked for a 2FA code but returned no personId.", loginRes.status); } - if (!opts.askTotp) { - throw new LoginError( - "This account requires a 2FA code, which needs an interactive terminal.", - loginRes.status, - ); - } - const code = (await opts.askTotp()).trim(); - secrets.push(code); - if (!/^\d{6}$/.test(code)) { - throw new LoginError("The 2FA code must be six digits.", 0); - } - const totpRes = await postJson(fetchImpl, `${host}/api/login/totp`, cookie, { code, personId }); - cookie = mergeCookies(cookie, totpRes); - const totpBody = await readJson(totpRes); - if (!totpRes.ok) { - fail("2FA verification failed", totpRes, totpBody, secrets); - } - personId = personIdOf(totpBody) ?? personId; + return { kind: "totp", continuation: { host, cookie, personId }, status: loginRes.status }; } if (personId === undefined) { throw new LoginError("ChurchTools accepted the login but returned no personId.", loginRes.status); } - const tokenRes = await fetchWithRetry( - `${host}/api/persons/${personId}/logintoken`, - { headers: { Accept: "application/json", ...(cookie ? { Cookie: cookie } : {}) } }, - { isIdempotent: true, fetchImpl }, - ); - const tokenBody = await readJson(tokenRes); - if (!tokenRes.ok) { - fail("Could not read the personal login token", tokenRes, tokenBody, secrets); - } - const raw = tokenBody.data ?? tokenBody; - const token = - typeof raw === "string" - ? raw - : typeof raw === "object" && raw !== null - ? (raw as Record).token - : undefined; - if (typeof token !== "string" || token.trim() === "") { - throw new LoginError("ChurchTools returned no login token for this account.", tokenRes.status); - } - return token.trim(); + return { + kind: "token", + token: await readPersonalToken({ host, cookie, personId }, fetchImpl, secrets), + }; +} + +/** Complete one server-held TOTP continuation; the code is never retained or returned. */ +export async function continuePasswordLogin( + continuation: PasswordLoginContinuation, + code: string, + opts: { fetchImpl?: typeof fetch } = {}, +): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + const normalized = code.trim(); + if (!/^\d{6}$/.test(normalized)) throw new LoginError("The 2FA code must be six digits.", 0); + const secrets = [normalized]; + const totpRes = await postJson(fetchImpl, `${continuation.host}/api/login/totp`, continuation.cookie, { + code: normalized, + personId: continuation.personId, + }); + const cookie = mergeCookies(continuation.cookie, totpRes); + const totpBody = await readJson(totpRes); + if (!totpRes.ok) fail("2FA verification failed", totpRes, totpBody, secrets); + const personId = personIdOf(totpBody) ?? continuation.personId; + return readPersonalToken({ host: continuation.host, cookie, personId }, fetchImpl, secrets); } /** diff --git a/src/auth/tokenStore.ts b/src/auth/tokenStore.ts index b86516d..d182858 100644 --- a/src/auth/tokenStore.ts +++ b/src/auth/tokenStore.ts @@ -118,8 +118,8 @@ export async function readCredentials(host?: string): Promise { - const fromEnv = process.env.CT_LOGINTOKEN?.trim(); +export async function readToken(host?: string, env: NodeJS.ProcessEnv = process.env): Promise { + const fromEnv = env.CT_LOGINTOKEN?.trim(); if (fromEnv) { return fromEnv; } diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 4fd4cfb..12ddb9e 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -12,6 +12,7 @@ import { renderPermissionPlan } from "../permissions/render.js"; import { confirm, confirmEnv } from "../ui/prompt.js"; import { cliObserver } from "./observer.js"; import { info, warn, success, error } from "../ui.js"; +import { generateSelectedInput } from "../operations/input-projection.js"; interface ApplyOptions { config?: string; @@ -21,6 +22,8 @@ interface ApplyOptions { backupDir?: string; autoApprove?: boolean; refresh?: boolean; + inputSnapshot?: string; + generator?: string; } // Retain this command-module export for callers that used it before the application extraction. @@ -35,6 +38,8 @@ export function applyCommand(): Command { .option("--confirm-env ", "confirm a protected env non-interactively (must match --env exactly)") .option("--backup-dir ", "directory for the pre-apply backup (or set CT_BACKUP_DIR)") .option("-y, --auto-approve", "skip the confirmation prompt") + .option("--input-snapshot ", "generate desired config from this immutable input snapshot") + .option("--generator ", "trusted local process-input generator module") .option( "--refresh", "after a successful apply, POST /dynamicgroups/{id}/refresh for each changed dynamic group (per-group only)", @@ -42,6 +47,7 @@ export function applyCommand(): Command { .action(async (opts: ApplyOptions) => { let prepared; try { + const selectedInput = await generateSelectedInput(process.cwd(), opts.inputSnapshot, opts.generator); prepared = await prepareApply( { configPath: opts.config, @@ -53,7 +59,12 @@ export function applyCommand(): Command { // operator needs to read the rendered diff (#156 review). Staleness is caught by the // state fingerprint at execute time, not by a timer. }, - { preparedTtlMs: null }, + { + preparedTtlMs: null, + ...(selectedInput + ? { loadConfig: async () => ({ ...selectedInput.generated, configDir: process.cwd() }) } + : {}), + }, ); } catch (caught) { if (caught instanceof CtApplicationError && caught.code === "PLAN_INCOMPLETE") { diff --git a/src/commands/environment.ts b/src/commands/environment.ts new file mode 100644 index 0000000..a331c6b --- /dev/null +++ b/src/commands/environment.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { listEnvironments } from "../application/operations/environment.js"; +import { out } from "../ui.js"; + +export function environmentCommand(): Command { + const command = new Command("environment") + .alias("env") + .description("Discover the configured ChurchTools environments"); + command + .command("list") + .description("List non-secret environment profiles") + .action(async () => { + const result = await listEnvironments(); + out({ path: result.environmentsPath, environments: result.environments }); + }); + return command; +} diff --git a/src/commands/init.ts b/src/commands/init.ts index a801edc..d348a31 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -2,7 +2,7 @@ import { createInterface } from "node:readline"; import { Command } from "commander"; import { bootstrapLoginToken } from "../auth/login.js"; import { isSecureStorageAvailable } from "../auth/tokenStore.js"; -import { initializeConfigRepository } from "../init.js"; +import { runInitWorkspace } from "../application/operations/init.js"; import { error, formatError, info, success } from "../ui.js"; import { verifyAndStoreLoginToken } from "./auth.js"; @@ -37,15 +37,21 @@ export function initCommand(): Command { .option("--no-git", "do not initialize a Git repository") .option("-y, --yes", "accept defaults and do not prompt") .action(async (directory: string, opts: InitCommandOptions) => { - const result = await initializeConfigRepository(directory, { - template: opts.template, - host: opts.host, - environment: opts.env, - protected: opts.protected, - git: opts.git, - yes: opts.yes, - ask, - }); + const { value: result } = await runInitWorkspace( + { + directory, + template: opts.template, + host: opts.host, + environment: opts.env, + protected: opts.protected, + git: opts.git, + yes: opts.yes, + }, + { + isTTY: Boolean(process.stdin.isTTY), + ask, + }, + ); success(`Initialized ct config repository in ${result.directory}`); info(`Created: ${[...result.files, ...result.directories.map((name) => `${name}/`)].join(", ")}`); diff --git a/src/commands/input.ts b/src/commands/input.ts new file mode 100644 index 0000000..55ab22f --- /dev/null +++ b/src/commands/input.ts @@ -0,0 +1,58 @@ +import { readFile } from "node:fs/promises"; +import { Command } from "commander"; +import { + createInputSnapshot, + getInputSnapshot, + listInputSnapshots, + validateProcessInput, + type ProcessInputDocument, +} from "../application/operations/input.js"; +import { out } from "../ui.js"; +import { loadTrustedProcessGenerator } from "../server/generator.js"; + +async function readDocument(path: string): Promise { + let raw: string; + if (path === "-") { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + raw = Buffer.concat(chunks).toString("utf8"); + } else { + raw = await readFile(path, "utf8"); + } + return JSON.parse(raw) as ProcessInputDocument; +} + +export function inputCommand(): Command { + const command = new Command("input").description("Validate and store versioned process input snapshots"); + command + .command("validate") + .argument("", "JSON input document, or - for stdin") + .option("--generator ", "also validate with this trusted local generator module") + .action(async (file: string, opts: { generator?: string }) => { + const document = await readDocument(file); + let result = validateProcessInput(document); + if (result.valid && opts.generator) { + const generator = await loadTrustedProcessGenerator(opts.generator); + const generated = await generator.validate(document); + result = { ...result, ...generated }; + } + out(result); + if (!result.valid) process.exitCode = 1; + }); + command + .command("snapshot") + .argument("", "JSON input document, or - for stdin") + .option("--no-persist", "validate and digest without writing an immutable snapshot") + .action(async (file: string, opts: { persist?: boolean }) => { + out(await createInputSnapshot({ ...(await readDocument(file)), persist: opts.persist })); + }); + command + .command("list") + .description("List immutable process input snapshots") + .action(async () => out(await listInputSnapshots(process.cwd()))); + command + .command("get") + .argument("", "SHA-256 snapshot digest") + .action(async (digest: string) => out(await getInputSnapshot(process.cwd(), digest))); + return command; +} diff --git a/src/commands/ownership.ts b/src/commands/ownership.ts new file mode 100644 index 0000000..f688425 --- /dev/null +++ b/src/commands/ownership.ts @@ -0,0 +1,27 @@ +import { Command } from "commander"; +import { checkOwnership } from "../application/operations/ownership.js"; +import { info, success, warn } from "../ui.js"; + +export function ownershipCommand(): Command { + const command = new Command("ownership").description("Analyse ownership within an explicit directory tree"); + command + .command("check") + .description("Check managed/external ownership claims without contacting ChurchTools") + .argument("", "complete coordination-scope directory for this invocation") + .requiredOption("-e, --env ", "environment name to inspect in every discovered ct project") + .action(async (root: string, opts: { env: string }) => { + const result = await checkOwnership({ root, environment: opts.env }); + info( + `Inspected ${result.value.projects.length} ct project(s) across ${result.value.hosts.length} host(s) below ${result.value.root}.`, + ); + for (const finding of result.value.findings) { + const line = `[${finding.reason}] ${finding.message}`; + if (finding.severity === "ok") success(line); + else warn(line); + finding.remediation?.forEach((step, index) => info(` ${index + 1}. ${step}`)); + } + info("Scope guarantee applies only below the explicit root; projects outside it remain unknowable."); + if (result.value.conflicts > 0) process.exitCode = 1; + }); + return command; +} diff --git a/src/commands/plan.ts b/src/commands/plan.ts index ef62fac..1b50113 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -7,6 +7,7 @@ import { renderPlan } from "../engine/render.js"; import { PLAN_MARKDOWN_LOCALES, renderPlanMarkdown, type PlanMarkdownLocale } from "../engine/markdown.js"; import { renderPermissionPlan } from "../permissions/render.js"; import { info, warn } from "../ui.js"; +import { generateSelectedInput } from "../operations/input-projection.js"; export type PlanFormat = "text" | "json" | "markdown"; @@ -24,6 +25,8 @@ interface PlanOptions { outputBase?: string; locale?: string; detailedExitcode?: boolean; + inputSnapshot?: string; + generator?: string; } function collectFormat(value: string, previous: string[]): string[] { @@ -100,6 +103,8 @@ export function planCommand(): Command { ) .option("--output-base ", "write selected formats as .txt/.json/.md") .option("--locale ", "Markdown language: de-DE or en", "de-DE") + .option("--input-snapshot ", "generate desired config from this immutable input snapshot") + .option("--generator ", "trusted local process-input generator module") .option( "--detailed-exitcode", "Terraform-style exit code: 0 = no changes, 1 = error, 2 = changes pending (resource or permission)", @@ -108,11 +113,17 @@ export function planCommand(): Command { // Resolved before any ChurchTools request, so an invalid combination fails cheaply. const outputTargets = planOutputTargets(opts); const locale = parsePlanLocale(opts.locale); - const result = await runPlan({ - configPath: opts.config, - statePath: opts.state, - environment: opts.env, - }); + const selectedInput = await generateSelectedInput(process.cwd(), opts.inputSnapshot, opts.generator); + const result = await runPlan( + { + configPath: opts.config, + statePath: opts.state, + environment: opts.env, + }, + selectedInput + ? { loadConfig: async () => ({ ...selectedInput.generated, configDir: process.cwd() }) } + : {}, + ); const { project, value } = result; const catalogPath = value.permissionCatalogPath ? relative(project.cwd, value.permissionCatalogPath) diff --git a/src/commands/release.ts b/src/commands/release.ts new file mode 100644 index 0000000..ed42804 --- /dev/null +++ b/src/commands/release.ts @@ -0,0 +1,105 @@ +import { Command } from "commander"; +import { + executePreparedRelease, + prepareRelease, + type ReleaseConfirmationProof, +} from "../application/operations/release.js"; +import { info, success, warn } from "../ui.js"; +import { confirmStateRemoval } from "./state-removal-confirmation.js"; + +interface ReleaseOptions { + state?: string; + env?: string; + config?: string; + force?: boolean; + dryRun?: boolean; + confirmEnv?: string; + confirmKey?: string; +} + +type ReleaseKind = "managed" | "external"; + +function noun(kind: ReleaseKind): string { + return kind === "managed" ? "managed ownership" : "external binding"; +} + +function commandName(kind: ReleaseKind): "unadopt" | "unuse" { + return kind === "managed" ? "unadopt" : "unuse"; +} + +function releaseCommand(kind: ReleaseKind): Command { + const name = commandName(kind); + const command = new Command(name) + .description( + kind === "managed" + ? "Stop managing an adopted object without changing it in ChurchTools" + : "Remove an external read-only binding without changing ChurchTools", + ) + .argument("", "ct-cli resource type") + .argument("", "logical key to release") + .option("-s, --state ", "state file path (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("-c, --config ", "config file to check for declarations and refs (or set CT_CONFIG)") + .option( + "--force", + "allow removal while the key is still declared/referenced or the config cannot be checked", + ) + .option("--dry-run", "validate and report the removal without writing state") + .option("--confirm-env ", "confirm a named environment non-interactively (must match --env)") + .option( + "--confirm-key ", + "confirm a legacy project without --env non-interactively (must match the logical key)", + ); + + return command.action(async (type: string, key: string, opts: ReleaseOptions) => { + const request = { + type, + key, + kind, + statePath: opts.state, + configPath: opts.config, + environment: opts.env, + force: opts.force, + } as const; + // The person reading the preview may take as long as needed; exact-entry + // comparison, not wall-clock expiry, rejects stale confirmation. + const prepared = await prepareRelease(request, { preparedTtlMs: null }); + const preview = prepared.preview; + for (const warning of preview.warnings) warn(warning.message); + const entry = preview.value.entry; + warn( + `${opts.dryRun ? "Would remove" : "About to remove"} ${noun(kind)} ` + + `${entry.type}.${entry.key} (#${entry.id}) from ${preview.project.stateDisplayPath}.`, + ); + info("ChurchTools will not be contacted; the live object will remain unchanged."); + if (opts.dryRun) return; + + const confirmed = await confirmStateRemoval(preview.project, key, { + confirmEnv: opts.confirmEnv, + confirmKey: opts.confirmKey, + }); + if (!confirmed) { + warn(`Aborted — ${preview.project.environment ? "environment" : "logical key"} was not confirmed.`); + process.exitCode = 1; + return; + } + + const proof: ReleaseConfirmationProof = preview.project.environment + ? { type: "environment", value: preview.project.environment } + : { type: "key", value: key }; + const result = await executePreparedRelease(prepared, proof); + success( + `${name}: removed ${noun(kind)} ${entry.type}.${entry.key} (#${entry.id}) from ` + + `${result.project.stateDisplayPath}.`, + ); + info("ChurchTools was not contacted."); + }); +} + +export function unuseCommand(): Command { + return releaseCommand("external"); +} + +export function unadoptCommand(): Command { + return releaseCommand("managed"); +} diff --git a/src/commands/server.ts b/src/commands/server.ts new file mode 100644 index 0000000..d6a0b3f --- /dev/null +++ b/src/commands/server.ts @@ -0,0 +1,108 @@ +import { Command, Option } from "commander"; +import { createCtApiServer } from "../server/app.js"; +import { info, success } from "../ui.js"; + +interface ServerOptions { + host: string; + port: string; + workspace: string[]; + allowOrigin: string[]; + publicUrl?: string; + trustedProxy?: boolean; + generator?: string; +} + +function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +function loopback(host: string): boolean { + return host === "127.0.0.1" || host === "::1" || host === "localhost"; +} + +function port(value: string): number { + if (!/^\d+$/.test(value)) throw new Error(`Invalid port "${value}".`); + const parsed = Number.parseInt(value, 10); + if (parsed < 0 || parsed > 65_535) throw new Error(`Invalid port "${value}".`); + return parsed; +} + +export function isExactAllowedOrigin(value: string): boolean { + const parsed = new URL(value); + if (parsed.username || parsed.password || parsed.search || parsed.hash) return false; + if (parsed.pathname !== "" && parsed.pathname !== "/") return false; + if (["chrome-extension:", "moz-extension:"].includes(parsed.protocol)) return parsed.host !== ""; + return parsed.origin === value || `${parsed.origin}/` === value; +} + +export function serverCommand(): Command { + return new Command("server") + .description("Run the versioned REST API for paired ChurchTools Extension clients") + .addOption(new Option("--host
", "listen address").default("127.0.0.1")) + .addOption(new Option("--port ", "listen port; 0 chooses a free port").default("8765")) + .option("--workspace ", "configured workspace root; repeatable", collect, []) + .option("--allow-origin ", "exact allowed Extension origin; repeatable", collect, []) + .option("--public-url ", "advertised HTTPS URL when a trusted reverse proxy terminates TLS") + .option("--trusted-proxy", "declare that --public-url is terminated by a trusted reverse proxy") + .option("--generator ", "trusted local process-input generator module") + .action(async (opts: ServerOptions) => { + const isLocal = loopback(opts.host); + if (!isLocal) { + if (!opts.trustedProxy || !opts.publicUrl) { + throw new Error( + "Non-loopback listening requires --trusted-proxy and an HTTPS --public-url. " + + "Binding to 0.0.0.0 alone is intentionally refused.", + ); + } + const advertised = new URL(opts.publicUrl); + if (advertised.protocol !== "https:") throw new Error("--public-url must use HTTPS."); + if (opts.allowOrigin.length === 0) { + throw new Error("Non-loopback mode requires at least one exact --allow-origin."); + } + } + for (const origin of opts.allowOrigin) { + const parsed = new URL(origin); + if ( + !isExactAllowedOrigin(origin) || + (!isLocal && !["https:", "chrome-extension:", "moz-extension:"].includes(parsed.protocol)) + ) { + throw new Error(`--allow-origin must be an exact origin: ${origin}`); + } + } + + const generator = opts.generator + ? await (await import("../server/generator.js")).loadTrustedProcessGenerator(opts.generator) + : undefined; + const api = await createCtApiServer({ + workspaceRoots: opts.workspace.length > 0 ? opts.workspace : [process.cwd()], + allowedOrigins: opts.allowOrigin, + // Loopback HTTP is acceptable for local credential submission, but its cookie must not be + // marked Secure. Remote mode is safe only through the declared HTTPS reverse proxy. + secureTransport: !isLocal && opts.trustedProxy === true, + generator, + audit: (event) => process.stderr.write(`${JSON.stringify(event)}\n`), + }); + await new Promise((resolve, reject) => { + api.server.once("error", reject); + api.server.listen(port(opts.port), opts.host, () => resolve()); + }); + const address = api.server.address(); + const actualPort = typeof address === "object" && address ? address.port : port(opts.port); + const localUrl = `http://${opts.host.includes(":") ? `[${opts.host}]` : opts.host}:${actualPort}`; + success(`ct REST API listening at ${opts.publicUrl ?? localUrl}/api/v1`); + info(`Pairing code: ${api.pairingCode} (expires ${api.pairingExpiresAt})`); + info(`API docs: ${opts.publicUrl ?? localUrl}/api/docs`); + info(`OpenAPI: ${opts.publicUrl ?? localUrl}/api/v1/openapi.json`); + info( + `Configured workspaces: ${api.workspaces.map((workspace) => `${workspace.name} (${workspace.id})`).join(", ")}`, + ); + + const close = (): void => { + api.server.close(() => { + process.exitCode = 0; + }); + }; + process.once("SIGINT", close); + process.once("SIGTERM", close); + }); +} diff --git a/src/commands/state-removal-confirmation.ts b/src/commands/state-removal-confirmation.ts new file mode 100644 index 0000000..d61ec7c --- /dev/null +++ b/src/commands/state-removal-confirmation.ts @@ -0,0 +1,25 @@ +import type { ResolvedProjectInfo } from "../application/contracts.js"; +import { confirmTyped } from "../ui/prompt.js"; + +export interface StateRemovalConfirmationOptions { + confirmEnv?: string; + confirmKey?: string; +} + +/** + * State-only lifecycle changes use the same typed environment gate as protected + * apply/destroy. Legacy projects without a named environment type the logical + * key instead; automation supplies the exact value explicitly. + */ +export async function confirmStateRemoval( + project: ResolvedProjectInfo, + key: string, + options: StateRemovalConfirmationOptions, +): Promise { + if (project.environment) { + if (options.confirmEnv !== undefined) return options.confirmEnv === project.environment; + return confirmTyped(project.environment); + } + if (options.confirmKey !== undefined) return options.confirmKey === key; + return confirmTyped(key); +} diff --git a/src/commands/state.ts b/src/commands/state.ts index 09634a1..3d50bdb 100644 --- a/src/commands/state.ts +++ b/src/commands/state.ts @@ -1,66 +1,134 @@ import { Command } from "commander"; -import { listState, removeStateEntry } from "../application/operations/state.js"; +import { listState, rekeyStateEntry, removeStateEntry } from "../application/operations/state.js"; import { info, out, success, warn } from "../ui.js"; +import { confirmStateRemoval } from "./state-removal-confirmation.js"; interface StateOptions { state?: string; env?: string; + managed?: boolean; + external?: boolean; } interface StateRmOptions extends StateOptions { config?: string; force?: boolean; dryRun?: boolean; + confirmEnv?: string; + confirmKey?: string; } export function stateCommand(): Command { - const cmd = new Command("state").description("Inspect the managed-resource state file"); + const cmd = new Command("state").description("Inspect managed and external ct-cli resource state"); cmd .command("list") - .description("List every resource under management (JSON to stdout)") + .description("List managed and external entries together (JSON to stdout)") .option("-s, --state ", "state file path (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("--managed", "show managed entries only") + .option("--external", "show external entries only") .action(async (opts: StateOptions) => { - const result = await listState({ statePath: opts.state, environment: opts.env }); + const result = await listState({ + statePath: opts.state, + environment: opts.env, + managed: opts.managed, + external: opts.external, + }); info( - `${result.value.resources.length} managed resource(s) in ${result.project.stateDisplayPath} (host ${result.project.host}).`, + `${result.value.entries.length} state entr${result.value.entries.length === 1 ? "y" : "ies"} in ${result.project.stateDisplayPath} (host ${result.project.host}).`, ); - out(result.value.resources); + out(result.value.entries.map(({ kind, ownership, entry }) => ({ kind, ownership, ...entry }))); }); cmd .command("rm") - .description("Un-adopt: remove a resource from the state file. Never touches ChurchTools.") + .description("Remove a managed or external entry from state. Never touches ChurchTools.") .argument("", "resource type, e.g. campus | group | group-role") .argument("", "logical key of the entry to remove") .option("-s, --state ", "state file path (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") .option("-c, --config ", "config file to check the key against (or set CT_CONFIG)") - .option("--force", "remove even though the key is still declared in the config") + .option("--force", "remove even though the key is still declared/referenced or config is unreadable") .option("--dry-run", "report what would be removed without writing") + .option("--confirm-env ", "confirm a named environment non-interactively (must match --env)") + .option( + "--confirm-key ", + "confirm a legacy project without --env non-interactively (must match the logical key)", + ) .action(async (type: string, key: string, opts: StateRmOptions) => { - const result = await removeStateEntry({ + const request = { type, key, statePath: opts.state, environment: opts.env, configPath: opts.config, force: opts.force, - dryRun: opts.dryRun, + } as const; + const preview = await removeStateEntry({ ...request, dryRun: true }); + for (const warning of preview.warnings) warn(warning.message); + const entry = preview.value.entry; + if (opts.dryRun) { + info( + `Would remove ${preview.value.kind} ${entry.type}.${key} (#${entry.id}) from ${preview.project.stateDisplayPath}.`, + ); + return; + } + warn( + `About to remove ${preview.value.kind} ${entry.type}.${key} (#${entry.id}) from ` + + `${preview.project.stateDisplayPath}. Prefer ct ${preview.value.kind === "managed" ? "unadopt" : "unuse"} for normal lifecycle changes.`, + ); + const confirmed = await confirmStateRemoval(preview.project, key, { + confirmEnv: opts.confirmEnv, + confirmKey: opts.confirmKey, }); - for (const warning of result.warnings) warn(warning.message); - const entry = result.value.entry; - if (!result.value.removed) { - info(`Would remove ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`); + if (!confirmed) { + warn(`Aborted — ${preview.project.environment ? "environment" : "logical key"} was not confirmed.`); + process.exitCode = 1; return; } - success(`Removed ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`); + const result = await removeStateEntry({ ...request, expectedEntry: entry }); + success( + `Removed ${result.value.kind} ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`, + ); info( - `ChurchTools was not contacted — #${entry.id} still exists there, now unmanaged. ` + - `Re-adopt it with \`ct adopt ${type} ${entry.id}\`.`, + `ChurchTools was not contacted — #${entry.id} still exists there. ` + + (result.value.kind === "managed" + ? `Re-adopt it with \`ct adopt ${type} ${entry.id}\`.` + : `Re-bind it with \`ct use ${type} ${entry.id} --key ${key}\`.`), ); }); + cmd + .command("rekey") + .description("Change the logical key of a managed or external state entry") + .argument("", "resource type") + .argument("", "current logical key") + .argument("", "new globally unique logical key") + .option("-s, --state ", "state file path (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("--dry-run", "report the rekey without writing") + .action( + async (type: string, oldKey: string, newKey: string, opts: StateOptions & { dryRun?: boolean }) => { + const result = await rekeyStateEntry({ + type, + oldKey, + newKey, + statePath: opts.state, + environment: opts.env, + dryRun: opts.dryRun, + }); + for (const warning of result.warnings) warn(warning.message); + if (opts.dryRun) { + info(`Would rekey ${result.value.kind} ${type}.${oldKey} to ${type}.${newKey}.`); + } else if (!result.value.changed) { + info(`${result.value.kind} ${type}.${oldKey} already has that key; state is unchanged.`); + } else { + success(`Rekeyed ${result.value.kind} ${type}.${oldKey} to ${type}.${newKey}.`); + } + info("ChurchTools was not contacted."); + }, + ); + return cmd; } diff --git a/src/commands/use.ts b/src/commands/use.ts new file mode 100644 index 0000000..3ba68db --- /dev/null +++ b/src/commands/use.ts @@ -0,0 +1,146 @@ +import { Command } from "commander"; +import { + discoverExternalCandidates, + inspectExternalCandidate, + runUseResource, + type UseResourceResult, +} from "../application/operations/use.js"; +import { CtApplicationError } from "../application/errors.js"; +import { askVisible, confirm } from "../ui/prompt.js"; +import { info, success, warn } from "../ui.js"; + +interface UseOptions { + key?: string; + owner?: string; + state?: string; + env?: string; + yes?: boolean; + dryRun?: boolean; +} + +function candidateLine(candidate: { + id: number; + name: string; + identity: Record; + display: Record; +}): string { + const details = Object.entries({ ...candidate.identity, ...candidate.display }) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(", "); + return `#${candidate.id} ${JSON.stringify(candidate.name)}${details ? ` · ${details}` : ""}`; +} + +async function bind( + type: string, + id: number, + key: string, + opts: UseOptions, +): Promise { + const request = { + type, + id, + key, + owner: opts.owner, + statePath: opts.state, + environment: opts.env, + dryRun: opts.dryRun, + }; + try { + return await runUseResource(request); + } catch (error) { + if (!(error instanceof CtApplicationError) || error.code !== "EXTERNAL_CONFIRMATION_REQUIRED") + throw error; + warn(error.message); + const accepted = await confirm("Accept this external binding change?", { assumeYes: opts.yes }); + if (!accepted) { + info("Aborted — external state was not changed."); + return null; + } + return runUseResource({ ...request, acceptChanges: true }); + } +} + +export function useCommand(): Command { + return new Command("use") + .description("Bind an existing ChurchTools object as an external read-only prerequisite") + .argument("", "ct-cli resource type, e.g. campus | group | group-type") + .argument("", "exact ChurchTools id, or an interactive fuzzy name search") + .option("-k, --key ", "portable logical key (required for non-interactive use)") + .option("--owner ", "optional owner-project coordination hint") + .option("-s, --state ", "state file path (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("-y, --yes", "accept an identity change or replacement without prompting") + .option("--dry-run", "validate and report the binding without writing state") + .action(async (type: string, selector: string, opts: UseOptions) => { + const numeric = /^\d+$/.test(selector.trim()); + const interactive = Boolean(process.stdin.isTTY); + if ((!numeric || !opts.key) && !interactive) { + throw new Error( + "Non-interactive use requires an exact numeric id and --key, e.g. `ct use group 4711 --key ojahr_fuzzies`.", + ); + } + + let chosen; + let suggestedKey: string | undefined; + if (numeric) { + const inspected = await inspectExternalCandidate({ + type, + id: selector, + statePath: opts.state, + environment: opts.env, + }); + chosen = inspected.value.candidate; + suggestedKey = inspected.value.suggestedKey; + } else { + const discovered = await discoverExternalCandidates({ + type, + search: selector, + statePath: opts.state, + environment: opts.env, + }); + if (discovered.value.candidates.length === 0) { + throw new Error(`No live ${type} matches ${JSON.stringify(selector)}.`); + } + info(`Matching ${type} candidates:`); + discovered.value.candidates.forEach((candidate, index) => + info(` ${index + 1}. ${candidateLine(candidate)}`), + ); + const answer = ( + await askVisible(`Select candidate [1-${discovered.value.candidates.length}]: `) + ).trim(); + const selected = answer === "" && discovered.value.candidates.length === 1 ? 1 : Number(answer); + if (!Number.isInteger(selected) || selected < 1 || selected > discovered.value.candidates.length) { + throw new Error("No valid candidate selected. External state was not changed."); + } + chosen = discovered.value.candidates[selected - 1]!; + const inspected = await inspectExternalCandidate({ + type, + id: chosen.id, + statePath: opts.state, + environment: opts.env, + }); + chosen = inspected.value.candidate; + suggestedKey = inspected.value.suggestedKey; + } + + let key = opts.key?.trim(); + if (!key) { + const proposal = suggestedKey!; + const answer = (await askVisible(`Logical key [${proposal}]: `)).trim(); + key = answer || proposal; + } + const result = await bind(type, chosen.id, key, opts); + if (!result) return; + const { action, binding, written } = result.value; + if (opts.dryRun) { + info(`Would ${action} external ${type}.${binding.key} -> #${binding.id}; ChurchTools is read-only.`); + } else if (action === "no-op") { + success(`External ${type}.${binding.key} already binds #${binding.id}; state is byte-unchanged.`); + } else { + success( + `${action}: external ${type}.${binding.key} -> #${binding.id} in ${result.project.stateDisplayPath}.`, + ); + } + if (written) info("ChurchTools was read for validation and was not written."); + }); +} diff --git a/src/completion/candidates.ts b/src/completion/candidates.ts index 801750a..db5531e 100644 --- a/src/completion/candidates.ts +++ b/src/completion/candidates.ts @@ -39,10 +39,20 @@ type DynamicSource = (position: Position, partial: string) => Promise */ const DYNAMIC_ARGUMENTS: Record = { "ct adopt type": () => resourceTypes(), + "ct unadopt type": () => resourceTypes(), + "ct use type": () => resourceTypes(), + "ct unuse type": () => resourceTypes(), "ct state rm type": () => resourceTypes(), + "ct state rekey type": () => resourceTypes(), // `ct state rm ` refuses a key belonging to another type, so the type // already typed narrows the keys — completing into a guaranteed error helps nobody. "ct state rm key": async (position) => stateKeys(await statePathFor(position), position.positionals[0]), + "ct state rekey old-key": async (position) => + stateKeys(await statePathFor(position), position.positionals[0]), + "ct unadopt key": async (position) => + stateKeys(await statePathFor(position), position.positionals[0], "managed"), + "ct unuse key": async (position) => + stateKeys(await statePathFor(position), position.positionals[0], "external"), }; /** diff --git a/src/completion/sources.ts b/src/completion/sources.ts index 75803b0..bcb8690 100644 --- a/src/completion/sources.ts +++ b/src/completion/sources.ts @@ -74,15 +74,19 @@ export function envStatePath(path: string, name: string): Promise * ` rejects a key of any other type: offering it would only complete into an error. */ -export function stateKeys(path: string, type?: string): Promise { +export function stateKeys(path: string, type?: string, kind?: "managed" | "external"): Promise { return offline(async () => { - const resources = objectField(JSON.parse(await readFile(path, "utf8")), "resources"); - return Object.entries(resources) + const state = JSON.parse(await readFile(path, "utf8")); + const resources = objectField(state, "resources"); + const externals = objectField(state, "externals"); + const entries = + kind === "managed" ? resources : kind === "external" ? externals : { ...resources, ...externals }; + return Object.entries(entries) .filter(([, entry]) => type === undefined || (isObject(entry) && entry.type === type)) .map(([key]) => key); }, []); diff --git a/src/config/context.ts b/src/config/context.ts index 3b1c3e9..99fa30f 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -24,7 +24,7 @@ import { collectRefs } from "../resolve/refs.js"; import type { DomainType } from "../permissions/grants.js"; import type { DesiredPermission, Grant, PreserveUnknown } from "../permissions/types.js"; import { KNOWN_SCOPE_FIELDS } from "../permissions/catalog.js"; -import { GROUP_STATUS_NO_CATALOG, isRef, ref, refKey, type Ref } from "../resolve/refs.js"; +import { isRef, ref, refKey, type Ref } from "../resolve/refs.js"; import { normalizeScopeEntry } from "../permissions/scope.js"; import { conventionalRulesetRef, isCallerAssignedId, knownFields } from "../resources/registry.js"; import { warn } from "../ui.js"; @@ -180,15 +180,11 @@ export interface PermissionInput { preserveUnknown?: PreserveUnknown; } -/** Logical id-field sugar for declarations: a named string field → a Ref-valued numeric id field. - * `status` (→ `groupStatusId`) is deliberately NOT here (#67): ChurchTools exposes no REST catalog - * for group statuses — `/group/memberstatus` is a different dimension (member statuses, string - * ids), verified live 2026-07-10. A declared `status:` field fails fast in {@link toDesired} instead - * of silently resolving against the wrong dimension. `ref.status`/`RefKind: "group-status"` remain - * in src/resolve/refs.ts so the sugar can return if CT ever ships a real group-status endpoint. */ +/** Logical id-field sugar for declarations: a named string field → a Ref-valued numeric id field. */ const ID_SUGAR: Record Ref }> = { campus: { idField: "campusId", make: ref.campus }, groupType: { idField: "groupTypeId", make: ref.groupType }, + status: { idField: "groupStatusId", make: ref.status }, }; /** The numeric id fields a declaration may carry — each accepts a number, `null`, or a {@link Ref}. */ @@ -366,7 +362,7 @@ export interface ConfigContext { * Grants on a PERSON status (`status` domain, #90) — they apply to every person carrying that * status, so this is the instance-wide lever. Addressed by `personStatus: ""` (resolved * against `/statuses`) or the numeric `id:` escape hatch. Note that person statuses are a different - * dimension from group statuses (`groupStatusId`), which have no catalog at all (#67). + * dimension from group statuses (`groupStatusId`), whose catalog is nested in `/person/masterdata`. */ status(input: PermissionInput): void; } @@ -535,17 +531,8 @@ function toDesired(type: string, input: ResourceInput, location?: string): Desir if (parents !== undefined && (!Array.isArray(parents) || parents.some((p) => typeof p !== "string"))) { throw new Error(`${type} "${key}": "parents" must be an array of string group keys.`); } - // `status` (group status) has no REST catalog to resolve a name against — fail fast here rather - // than let it fall through to ID_SUGAR (which no longer carries a "status" entry, so it would - // otherwise silently be treated as an unrecognised field and just warn) or, worse, silently pick - // the wrong dimension (#67: `/group/memberstatus` is member statuses, string ids — a live-verified - // mismatch). Checked before the sugar loop so the message is specific, not the generic unknown-id - // fallback below. - if (fields.status !== undefined) { - throw new Error(`${type} "${key}": "status" cannot be resolved by name — ${GROUP_STATUS_NO_CATALOG}`); - } - // Logical id-field sugar (#20): a named string field (`campus`/`groupType`) sugars into - // a Ref-valued numeric id field (`campusId`/`groupTypeId`). The per-host resolver + // Logical id-field sugar (#20): a named string field (`campus`/`groupType`/`status`) sugars into + // a Ref-valued numeric id field (`campusId`/`groupTypeId`/`groupStatusId`). The per-host resolver // turns the Ref into a real id at plan time. Declaring BOTH forms (`campus` + `campusId`) is a // conflict — reject it rather than silently pick one. Numeric ids still pass straight through. for (const [logical, { idField, make }] of Object.entries(ID_SUGAR)) { @@ -569,8 +556,6 @@ function toDesired(type: string, input: ResourceInput, location?: string): Desir for (const idField of ID_FIELDS) { const value = fields[idField]; if (value === undefined || value === null || typeof value === "number" || isRef(value)) continue; - // `groupStatusId` has no logical sugar field (#67 — see ID_SUGAR above), so its hint omits the - // "use the X field" clause rather than pointing at a sugar that doesn't exist. const sugarName = Object.entries(ID_SUGAR).find(([, s]) => s.idField === idField)?.[0]; const hint = sugarName ? ` (use the "${sugarName}" field, or ref.*)` : ""; throw new Error( @@ -647,10 +632,9 @@ function toDesired(type: string, input: ResourceInput, location?: string): Desir } /** - * Every managed hierarchy parent must reference a group declared in the same config. - * A parent that resolves to nothing (typo, unmanaged group) or to a non-group would - * diff forever against the managed-only actual side, so reject it up front rather than - * emit a plan that can never converge. + * A hierarchy parent may be declared in this config or supplied by an external group binding. + * Config evaluation can reject a known non-group immediately; unknown keys are deliberately left + * for the plan-time resolver/state validation, because external declarations live in per-host state. */ function validateReferences(resources: DesiredResource[]): void { const byKey = new Map(resources.map((r) => [r.key, r])); @@ -658,10 +642,7 @@ function validateReferences(resources: DesiredResource[]): void { for (const parentKey of r.parents ?? []) { const target = byKey.get(parentKey); if (!target) { - throw new Error( - `Group "${r.key}" declares hierarchy parent "${parentKey}", which is not declared in this config. ` + - `Managed parents must reference a group by its key (omit unmanaged parents entirely).`, - ); + continue; } if (target.type !== "group") { throw new Error( diff --git a/src/config/query-refs.ts b/src/config/query-refs.ts index 0703fd8..36940de 100644 --- a/src/config/query-refs.ts +++ b/src/config/query-refs.ts @@ -51,6 +51,7 @@ export const VAR_REF_KINDS: Readonly> = { "ctgroup.id": "group", "ctgroup.campusId": "campus", "ctgroup.groupTypeId": "group-type", + "ctgroup.groupStatusId": "group-status", "person.campusId": "campus", }; @@ -113,7 +114,6 @@ export interface PortablizeWarning { * is a literal, not an id, and reporting it would bury the real findings in noise. */ const UNPORTABLE_ENTITY_VARS: Readonly> = { - "ctgroup.groupStatusId": "group statuses have no REST catalog (#67) — no logical form exists", // #127. A ruleset that includes or excludes specific people by id is common — four of five // auto-group rulesets captured in one week did it — and it was the ONE entity var the audit never // mentioned, so the only way to find it was to read the captured JSON by hand. The absence of a diff --git a/src/engine/build.ts b/src/engine/build.ts index 91fd191..a6b41c3 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -119,6 +119,16 @@ export async function buildPlan( desired: DesiredResource[], opts: BuildOptions = {}, ): Promise { + const resolver = opts.resolver ?? new Resolver({ client, state, desired }); + // Hierarchy predates the generic Ref sentinel and stores parent keys as strings. Validate those + // keys through the same resolver so external parents receive the identical live identity gate. + await Promise.all( + desired.flatMap((resource) => + (resource.parents ?? []).map((parent) => + resolver.resolveKey("group", parent, `group "${resource.key}" hierarchy parent`), + ), + ), + ); // Keyed by logical key (globally unique), not CT id (unique only within a type — the Mainz campus is id 0). const { actual, @@ -141,7 +151,6 @@ export async function buildPlan( // Resolution pass (#20): rewrite Ref-valued fields (and the dynamic ruleset, walked deeply) to // numbers / pending markers AFTER folding, BEFORE computePlan — so the diff stays number↔number. // Unknown/ambiguous refs THROW here (a config error, not a degrade-and-continue fetch error). - const resolver = opts.resolver ?? new Resolver({ client, state, desired }); const resolved = await Promise.all( folded.desired.map(async (d) => { const fields = await resolver.resolveValue(d.fields, `${d.type} "${d.key}"`); diff --git a/src/engine/hierarchy.ts b/src/engine/hierarchy.ts index 08cf182..e5f5c64 100644 --- a/src/engine/hierarchy.ts +++ b/src/engine/hierarchy.ts @@ -3,13 +3,13 @@ * can have several parents. `GET /groups/hierarchies` returns, per group, the * ids of its parents and children. * - * We surface hierarchy in the plan as an opt-in `parents` set-field on a group, - * resolved to logical keys and **restricted to managed groups** — an edge to an - * unmanaged group is invisible (managed-guard), never diffed or proposed for - * removal. + * We surface hierarchy in the plan as an opt-in `parents` set-field on a managed + * group. Parent ids are mapped only when state gives them a logical key, either + * as another managed group or as an explicit external group binding. Every other + * live edge stays invisible (managed-guard), never diffed or proposed for removal. */ -import type { State } from "../state/state.js"; +import { externalResources, type State } from "../state/state.js"; import type { DesiredResource } from "./types.js"; export interface HierarchyEntry { @@ -30,8 +30,8 @@ export function parentIdsByGroupId(entries: HierarchyEntry[]): Map): string[] { const keys: string[] = []; @@ -72,6 +72,9 @@ export function applyHierarchy( groupIdToKey.set(managed.id, managed.key); } } + for (const external of Object.values(externalResources(state))) { + if (external.type === "group") groupIdToKey.set(external.id, external.key); + } // Single pass over the desired opt-ins (one copy of the predicate, mirroring the desired-side // guard below). A group's actual gets a `parents` set only when it opted in AND is a managed diff --git a/src/engine/synthetic.ts b/src/engine/synthetic.ts index 9051201..cddf8b1 100644 --- a/src/engine/synthetic.ts +++ b/src/engine/synthetic.ts @@ -7,7 +7,7 @@ */ import type { CtClient } from "../api/ctClient.js"; import { CtApiError } from "../api/ctClient.js"; -import type { State } from "../state/state.js"; +import { findByKey, type State } from "../state/state.js"; import type { DesiredResource, FieldChange, Plan, PlanItem } from "./types.js"; import { applyHierarchy, parentIdsByGroupId, type HierarchyEntry } from "./hierarchy.js"; import { assertNotPeople } from "./guard.js"; @@ -124,9 +124,9 @@ export interface SyntheticField { } function resolveId(state: State, key: string): number { - const managed = state.resources[key]; - if (!managed) throw new Error(`Cannot resolve parent "${key}" — not under management yet.`); - return managed.id; + const binding = findByKey(state, key); + if (!binding) throw new Error(`Cannot resolve parent "${key}" — no managed or external binding exists.`); + return binding.id; } /** `parents`: many-to-many group hierarchy, reconciled per-edge. Wraps the existing hierarchy helpers. */ diff --git a/src/index.ts b/src/index.ts index 3fa5b90..261f8d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,8 @@ #!/usr/bin/env node import { Command } from "commander"; -import { authCommand } from "./commands/auth.js"; -import { getCommand } from "./commands/get.js"; -import { adoptCommand } from "./commands/adopt.js"; -import { stateCommand } from "./commands/state.js"; -import { coverageCommand } from "./commands/coverage.js"; -import { permissionsCommand } from "./commands/permissions.js"; -import { reportCommand } from "./commands/report.js"; -import { refreshCommand } from "./commands/refresh.js"; -import { planCommand } from "./commands/plan.js"; -import { applyCommand } from "./commands/apply.js"; -import { destroyCommand } from "./commands/destroy.js"; -import { initCommand } from "./commands/init.js"; -import { completionCommand } from "./commands/completion.js"; import { plannedCommands } from "./commands/placeholders.js"; +import { operationCatalog } from "./operations/catalog.js"; +import { buildCliProjection } from "./operations/cli-projection.js"; import { isCompletionRequest, serveCompletionRequest } from "./completion/shell.js"; import { isMainModule } from "./isMain.js"; import { versionLine } from "./version.js"; @@ -29,19 +18,7 @@ export function buildProgram(): Command { ) .version(versionLine(import.meta.url)); - program.addCommand(initCommand()); - program.addCommand(authCommand()); - program.addCommand(getCommand()); - program.addCommand(adoptCommand()); - program.addCommand(stateCommand()); - program.addCommand(coverageCommand()); - program.addCommand(permissionsCommand()); - program.addCommand(reportCommand()); - program.addCommand(refreshCommand()); - program.addCommand(planCommand()); - program.addCommand(applyCommand()); - program.addCommand(destroyCommand()); - program.addCommand(completionCommand()); + for (const command of buildCliProjection(operationCatalog)) program.addCommand(command); for (const cmd of plannedCommands()) { program.addCommand(cmd); } diff --git a/src/operations/catalog.ts b/src/operations/catalog.ts new file mode 100644 index 0000000..a791341 --- /dev/null +++ b/src/operations/catalog.ts @@ -0,0 +1,1133 @@ +export type JsonSchema = Record; +export type HttpMethod = "GET" | "POST" | "DELETE"; + +export interface OperationParameter { + name: string; + description: string; + schema: JsonSchema; + required?: boolean; + sensitive?: boolean; + cli?: { kind: "argument" | "option"; name: string }; + http?: { in: "path" | "query" | "header" | "body"; name?: string }; + /** Restrict this parameter to one action of a multi-stage operation. */ + actions?: readonly string[]; +} + +export interface OperationHttpProjection { + method: HttpMethod; + path: string; + action?: string; + successStatus?: number; + responseMediaType?: "application/json" | "text/html"; +} + +export interface OperationDefinition { + id: string; + summary: string; + /** Longer transport-neutral explanation shown by generated API documentation. */ + description?: string; + mutation: boolean; + longRunning: boolean; + capabilities: readonly ("read" | "plan" | "mutate" | "credentials")[]; + parameters: readonly OperationParameter[]; + resultSchema: JsonSchema; + cli?: { path: readonly string[] }; + http?: readonly OperationHttpProjection[]; + adapterSpecific?: "cli" | "http"; +} + +const stringSchema = { type: "string" } satisfies JsonSchema; +const booleanSchema = { type: "boolean" } satisfies JsonSchema; +const objectSchema = { type: "object", additionalProperties: true } satisfies JsonSchema; +const snapshotDigestSchema = { + type: "string", + pattern: "^[a-f0-9]{64}$", + description: "SHA-256 digest returned when the immutable process-input snapshot was created", + examples: ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"], +} satisfies JsonSchema; +const processInputSchema = { + type: "object", + required: ["schemaVersion", "clientRevision", "payload"], + properties: { + schemaVersion: { + type: "string", + description: "Version of the form/process data contract understood by the installed generator", + examples: ["1"], + }, + clientRevision: { + type: "string", + description: "Revision of the UI or process definition that produced this input", + examples: ["campus-form@42"], + }, + payload: { + description: "Pure JSON form data; never executable JavaScript", + examples: [{ campus: "Mainz", withKidsGroup: true }], + }, + }, + additionalProperties: false, +} satisfies JsonSchema; +const projectParameters: readonly OperationParameter[] = [ + { + name: "workspaceId", + description: "Configured workspace identifier", + schema: stringSchema, + required: true, + http: { in: "path" }, + }, + { + name: "environment", + description: "Named environment profile", + schema: stringSchema, + cli: { kind: "option", name: "--env" }, + http: { in: "body" }, + }, + { + name: "configPath", + description: "Config path within the workspace", + schema: stringSchema, + cli: { kind: "option", name: "--config" }, + http: { in: "body" }, + }, + { + name: "statePath", + description: "State path within the workspace", + schema: stringSchema, + cli: { kind: "option", name: "--state" }, + http: { in: "body" }, + }, +]; +const workspaceParameter = projectParameters[0]!; +const environmentParameter = projectParameters[1]!; +const configParameter = projectParameters[2]!; +const stateParameter = projectParameters[3]!; + +const operationResultSchema = { + type: "object", + required: ["operation"], + properties: { operation: stringSchema }, + additionalProperties: true, +} satisfies JsonSchema; + +/** + * The product surface. Commander, HTTP routing, capability discovery and OpenAPI all enumerate + * this exact array; an operation is never registered independently in an adapter. + */ +export const operationCatalog: readonly OperationDefinition[] = [ + { + id: "system.health", + summary: "Report server health and version", + mutation: false, + longRunning: false, + capabilities: [], + parameters: [], + resultSchema: objectSchema, + http: [{ method: "GET", path: "/api/v1/health" }], + adapterSpecific: "http", + }, + { + id: "system.capabilities", + summary: "Discover operations and transport capabilities", + mutation: false, + longRunning: false, + capabilities: [], + parameters: [], + resultSchema: objectSchema, + http: [{ method: "GET", path: "/api/v1/capabilities" }], + adapterSpecific: "http", + }, + { + id: "system.openapi", + summary: "Publish the generated OpenAPI contract", + mutation: false, + longRunning: false, + capabilities: [], + parameters: [], + resultSchema: objectSchema, + http: [{ method: "GET", path: "/api/v1/openapi.json" }], + adapterSpecific: "http", + }, + { + id: "system.docs", + summary: "Browse the generated OpenAPI contract with Scalar", + mutation: false, + longRunning: false, + capabilities: [], + parameters: [], + resultSchema: { type: "string" }, + http: [{ method: "GET", path: "/api/docs", responseMediaType: "text/html" }], + adapterSpecific: "http", + }, + { + id: "session.pair", + summary: "Exchange a short-lived pairing code for a scoped session", + mutation: true, + longRunning: false, + capabilities: [], + parameters: [ + { + name: "code", + description: "Short-lived pairing code", + schema: stringSchema, + required: true, + sensitive: true, + http: { in: "body" }, + }, + { + name: "capabilities", + description: "Requested session capabilities", + schema: { type: "array", items: { type: "string", enum: ["read", "plan", "mutate", "credentials"] } }, + http: { in: "body" }, + }, + ], + resultSchema: objectSchema, + http: [{ method: "POST", path: "/api/v1/pair" }], + adapterSpecific: "http", + }, + { + id: "workspace.list", + summary: "List configured workspace roots", + mutation: false, + longRunning: false, + capabilities: ["read"], + parameters: [], + resultSchema: objectSchema, + http: [{ method: "GET", path: "/api/v1/workspaces" }], + adapterSpecific: "http", + }, + { + id: "run.get", + summary: "Poll progress and terminal result for a long-running operation", + mutation: false, + longRunning: false, + capabilities: ["read"], + parameters: [ + { + name: "runId", + description: "Operation run identifier", + schema: stringSchema, + required: true, + http: { in: "path" }, + }, + ], + resultSchema: objectSchema, + http: [{ method: "GET", path: "/api/v1/runs/:runId" }], + adapterSpecific: "http", + }, + { + id: "run.events", + summary: "Read operation progress as Server-Sent Events", + mutation: false, + longRunning: false, + capabilities: ["read"], + parameters: [ + { + name: "runId", + description: "Operation run identifier", + schema: stringSchema, + required: true, + http: { in: "path" }, + }, + ], + resultSchema: objectSchema, + http: [{ method: "GET", path: "/api/v1/runs/:runId/events" }], + adapterSpecific: "http", + }, + { + id: "run.cancel", + summary: "Request cooperative cancellation of a long-running operation", + mutation: true, + longRunning: false, + capabilities: ["mutate"], + parameters: [ + { + name: "runId", + description: "Operation run identifier", + schema: stringSchema, + required: true, + http: { in: "path" }, + }, + ], + resultSchema: objectSchema, + http: [{ method: "DELETE", path: "/api/v1/runs/:runId" }], + adapterSpecific: "http", + }, + { + id: "workspace.init", + summary: "Initialize a workspace below a configured root", + mutation: true, + longRunning: false, + capabilities: ["mutate"], + parameters: [ + { + name: "rootId", + description: "Configured root in which to initialize", + schema: stringSchema, + http: { in: "body" }, + }, + { + name: "directory", + description: "Target directory", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "directory" }, + http: { in: "body" }, + }, + { + name: "template", + description: "standard or process", + schema: { type: "string", enum: ["standard", "process"] }, + cli: { kind: "option", name: "--template" }, + http: { in: "body" }, + }, + { + name: "host", + description: "ChurchTools base URL", + schema: stringSchema, + cli: { kind: "option", name: "--host" }, + http: { in: "body" }, + }, + { + name: "environment", + description: "First environment name", + schema: stringSchema, + cli: { kind: "option", name: "--env" }, + http: { in: "body" }, + }, + { + name: "protected", + description: "Protect the environment", + schema: booleanSchema, + cli: { kind: "option", name: "--protected" }, + http: { in: "body" }, + }, + { + name: "git", + description: "Initialize Git", + schema: booleanSchema, + cli: { kind: "option", name: "--git" }, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["init"] }, + http: [{ method: "POST", path: "/api/v1/workspaces" }], + }, + { + id: "environment.list", + summary: "List non-secret environment profiles", + mutation: false, + longRunning: false, + capabilities: ["read"], + parameters: [workspaceParameter], + resultSchema: operationResultSchema, + cli: { path: ["environment", "list"] }, + http: [{ method: "GET", path: "/api/v1/workspaces/:workspaceId/environments" }], + }, + { + id: "auth.status", + summary: "Read non-secret authentication status", + mutation: false, + longRunning: false, + capabilities: ["read"], + parameters: [ + workspaceParameter, + environmentParameter, + { + name: "all", + description: "Check every configured environment", + schema: booleanSchema, + cli: { kind: "option", name: "--all" }, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["auth", "status"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/auth/status" }], + }, + { + id: "auth.login", + summary: "Authenticate and store a host-bound token", + mutation: true, + longRunning: true, + capabilities: ["credentials"], + parameters: [ + workspaceParameter, + { + name: "host", + description: "ChurchTools base URL", + schema: stringSchema, + required: true, + http: { in: "body" }, + cli: { kind: "option", name: "--host" }, + }, + { + name: "token", + description: "Personal login token", + schema: stringSchema, + sensitive: true, + http: { in: "body" }, + cli: { kind: "option", name: "--token" }, + }, + { + name: "username", + description: "Username or email", + schema: stringSchema, + sensitive: true, + http: { in: "body" }, + }, + { + name: "password", + description: "One-shot password", + schema: stringSchema, + sensitive: true, + http: { in: "body" }, + }, + { + name: "totp", + description: "Six-digit 2FA continuation code", + schema: stringSchema, + sensitive: true, + http: { in: "body" }, + }, + { + name: "continuationId", + description: "Opaque server-held 2FA continuation", + schema: stringSchema, + sensitive: true, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["auth", "login"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/auth/login" }], + }, + { + id: "auth.logout", + summary: "Remove host-bound credentials", + mutation: true, + longRunning: false, + capabilities: ["credentials"], + parameters: [workspaceParameter, environmentParameter], + resultSchema: operationResultSchema, + cli: { path: ["auth", "logout"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/auth/logout" }], + }, + { + id: "input.validate", + summary: "Validate versioned process input", + description: + "Checks form/process JSON before it is stored. This feature is optional: users who maintain ct.config.ts directly do not need process-input snapshots.", + mutation: false, + longRunning: false, + capabilities: ["read"], + parameters: [ + workspaceParameter, + { + name: "document", + description: "Versioned JSON form/process data to validate", + schema: processInputSchema, + required: true, + http: { in: "body" }, + cli: { kind: "argument", name: "file" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["input", "validate"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/input/validate" }], + }, + { + id: "input.snapshot", + summary: "Create an immutable process input snapshot", + description: + "Stores one exact version of browser form data and returns its SHA-256 digest. The digest lets plan and apply select the same unmodified input later.", + mutation: true, + longRunning: false, + capabilities: ["mutate"], + parameters: [ + workspaceParameter, + { + name: "document", + description: "Versioned JSON form/process data to store immutably", + schema: processInputSchema, + required: true, + http: { in: "body" }, + cli: { kind: "argument", name: "file" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["input", "snapshot"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/input/snapshots", successStatus: 201 }], + }, + { + id: "input.list", + summary: "List immutable process input snapshots", + mutation: false, + longRunning: false, + capabilities: ["read"], + parameters: [workspaceParameter], + resultSchema: operationResultSchema, + cli: { path: ["input", "list"] }, + http: [{ method: "GET", path: "/api/v1/workspaces/:workspaceId/input/snapshots" }], + }, + { + id: "input.get", + summary: "Retrieve an immutable process input snapshot", + mutation: false, + longRunning: false, + capabilities: ["read"], + parameters: [ + workspaceParameter, + { + name: "digest", + description: "Snapshot SHA-256 digest", + schema: snapshotDigestSchema, + required: true, + cli: { kind: "argument", name: "digest" }, + http: { in: "path" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["input", "get"] }, + http: [{ method: "GET", path: "/api/v1/workspaces/:workspaceId/input/snapshots/:digest" }], + }, + { + id: "plan", + summary: "Create a read-only desired-versus-actual plan", + description: + "Normally plan reads ct.config.ts. Supplying snapshotDigest instead selects immutable browser form data, which the trusted generator configured by the server operator translates into the normal desired ct model before the same planning logic runs.", + mutation: false, + longRunning: true, + capabilities: ["plan"], + parameters: [ + ...projectParameters, + { + name: "snapshotDigest", + description: + "Optional immutable form-input version. Omit it for the normal ct.config.ts workflow. When supplied, the server's trusted generator converts that exact snapshot into the desired ct model.", + schema: snapshotDigestSchema, + http: { in: "body" }, + cli: { kind: "option", name: "--input-snapshot" }, + }, + { + name: "generatorPath", + description: + "CLI-only path to a trusted local generator; required together with --input-snapshot. REST clients cannot choose executable code: the server operator configures the generator when starting ct server.", + schema: stringSchema, + cli: { kind: "option", name: "--generator" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["plan"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/plans" }], + }, + { + id: "apply", + summary: "Prepare and explicitly execute an apply", + description: + "Normally apply reads ct.config.ts. An optional snapshotDigest binds preparation to one exact version of browser form data and its generated desired model, preventing the form input from changing unnoticed between review and execution.", + mutation: true, + longRunning: true, + capabilities: ["plan", "mutate"], + parameters: [ + ...projectParameters, + { + name: "snapshotDigest", + description: + "Optional immutable form-input version. Omit it for the normal ct.config.ts workflow. The prepared apply remains bound to this exact digest through execution.", + schema: snapshotDigestSchema, + http: { in: "body" }, + cli: { kind: "option", name: "--input-snapshot" }, + actions: ["prepare"], + }, + { + name: "generatorPath", + description: + "CLI-only path to a trusted local generator; required together with --input-snapshot. REST clients use the generator fixed by the server operator.", + schema: stringSchema, + cli: { kind: "option", name: "--generator" }, + }, + { + name: "backupDir", + description: "Pre-apply backup directory", + schema: stringSchema, + cli: { kind: "option", name: "--backup-dir" }, + http: { in: "body" }, + actions: ["prepare"], + }, + { + name: "refresh", + description: "Refresh changed dynamic groups after apply", + schema: booleanSchema, + cli: { kind: "option", name: "--refresh" }, + http: { in: "body" }, + actions: ["prepare"], + }, + { + name: "confirmation", + description: "Core-required confirmation proof", + schema: objectSchema, + http: { in: "body" }, + actions: ["execute"], + }, + { + name: "idempotencyKey", + description: "Retry-safe request identity", + schema: stringSchema, + http: { in: "header", name: "Idempotency-Key" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["apply"] }, + http: [ + { + method: "POST", + path: "/api/v1/workspaces/:workspaceId/applies", + action: "prepare", + successStatus: 201, + }, + { + method: "POST", + path: "/api/v1/workspaces/:workspaceId/applies/:operationId/execute", + action: "execute", + }, + ], + }, + { + id: "coverage", + summary: "Report managed and unmanaged host coverage", + mutation: false, + longRunning: true, + capabilities: ["read"], + parameters: [ + workspaceParameter, + environmentParameter, + stateParameter, + { + name: "type", + description: "Group type filter", + schema: stringSchema, + cli: { kind: "option", name: "--type" }, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["coverage"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/coverage" }], + }, + { + id: "refresh", + summary: "Refresh managed dynamic groups", + mutation: true, + longRunning: true, + capabilities: ["mutate"], + parameters: [ + workspaceParameter, + environmentParameter, + stateParameter, + { + name: "group", + description: "Managed group key", + schema: stringSchema, + cli: { kind: "option", name: "--group" }, + http: { in: "body" }, + }, + { + name: "all", + description: "Refresh all managed dynamic groups", + schema: booleanSchema, + cli: { kind: "option", name: "--all" }, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["refresh"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/refreshes" }], + }, + { + id: "adopt.resource", + summary: "Put one existing ChurchTools resource under management", + mutation: true, + longRunning: true, + capabilities: ["mutate"], + parameters: [ + workspaceParameter, + environmentParameter, + stateParameter, + { + name: "type", + description: "Resource type", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "type" }, + http: { in: "body" }, + }, + { + name: "id", + description: "ChurchTools resource id", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "id" }, + http: { in: "body" }, + }, + { + name: "key", + description: "Logical key", + schema: stringSchema, + cli: { kind: "option", name: "--key" }, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["adopt"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/adoptions/resources" }], + }, + { + id: "adopt.groups", + summary: "Adopt one or more groups", + mutation: true, + longRunning: true, + capabilities: ["mutate"], + parameters: [ + workspaceParameter, + environmentParameter, + stateParameter, + { + name: "ids", + description: "ChurchTools group ids", + schema: { type: "array", items: stringSchema }, + cli: { kind: "argument", name: "ids" }, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["adopt", "group"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/adoptions/groups" }], + }, + { + id: "adopt.grants", + summary: "Project live grants into portable configuration blocks", + mutation: false, + longRunning: true, + capabilities: ["read"], + parameters: [ + workspaceParameter, + environmentParameter, + stateParameter, + { + name: "domainType", + description: "Permission domain type", + schema: stringSchema, + cli: { kind: "argument", name: "domainType" }, + http: { in: "body" }, + }, + { + name: "domainId", + description: "Permission domain id", + schema: stringSchema, + cli: { kind: "argument", name: "domainId" }, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["adopt", "grants"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/adoptions/grants" }], + }, + { + id: "use.resource", + summary: "Bind an existing ChurchTools resource as a read-only external prerequisite", + mutation: true, + longRunning: true, + capabilities: ["read", "mutate"], + parameters: [ + workspaceParameter, + environmentParameter, + stateParameter, + { + name: "type", + description: "Resource type", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "type" }, + http: { in: "body" }, + }, + { + name: "id", + description: "Exact ChurchTools resource id; the CLI also accepts an interactive name search", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "selector" }, + http: { in: "body" }, + }, + { + name: "key", + description: "Portable logical key", + schema: stringSchema, + required: true, + cli: { kind: "option", name: "--key" }, + http: { in: "body" }, + }, + { + name: "owner", + description: "Optional owner-project coordination hint", + schema: stringSchema, + cli: { kind: "option", name: "--owner" }, + http: { in: "body" }, + }, + { + name: "acceptChanges", + description: "Explicitly accept a changed identity or replacement", + schema: booleanSchema, + cli: { kind: "option", name: "--yes" }, + http: { in: "body" }, + }, + { + name: "dryRun", + description: "Validate without changing state", + schema: booleanSchema, + cli: { kind: "option", name: "--dry-run" }, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["use"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/external-bindings" }], + }, + { + id: "release.managed", + summary: "Stop managing a resource without changing it in ChurchTools", + mutation: true, + longRunning: false, + capabilities: ["mutate"], + parameters: [ + ...projectParameters, + { + name: "type", + description: "Resource type", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "type" }, + http: { in: "path" }, + actions: ["prepare"], + }, + { + name: "key", + description: "Logical key to release", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "key" }, + http: { in: "path" }, + actions: ["prepare"], + }, + { + name: "force", + description: "Allow release while the key remains declared or referenced", + schema: booleanSchema, + cli: { kind: "option", name: "--force" }, + http: { in: "body" }, + actions: ["prepare"], + }, + { + name: "dryRun", + description: "Preview without changing state", + schema: booleanSchema, + cli: { kind: "option", name: "--dry-run" }, + }, + { + name: "confirmation", + description: "Core-required environment or logical-key confirmation proof", + schema: objectSchema, + http: { in: "body" }, + actions: ["execute"], + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["unadopt"] }, + http: [ + { + method: "POST", + path: "/api/v1/workspaces/:workspaceId/releases/managed/execute/:operationId", + action: "execute", + }, + { + method: "POST", + path: "/api/v1/workspaces/:workspaceId/releases/managed/prepare/:type/:key", + action: "prepare", + successStatus: 201, + }, + ], + }, + { + id: "release.external", + summary: "Remove an external binding without changing ChurchTools", + mutation: true, + longRunning: false, + capabilities: ["mutate"], + parameters: [ + ...projectParameters, + { + name: "type", + description: "Resource type", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "type" }, + http: { in: "path" }, + actions: ["prepare"], + }, + { + name: "key", + description: "Logical key to release", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "key" }, + http: { in: "path" }, + actions: ["prepare"], + }, + { + name: "force", + description: "Allow release while the key remains declared or referenced", + schema: booleanSchema, + cli: { kind: "option", name: "--force" }, + http: { in: "body" }, + actions: ["prepare"], + }, + { + name: "dryRun", + description: "Preview without changing state", + schema: booleanSchema, + cli: { kind: "option", name: "--dry-run" }, + }, + { + name: "confirmation", + description: "Core-required environment or logical-key confirmation proof", + schema: objectSchema, + http: { in: "body" }, + actions: ["execute"], + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["unuse"] }, + http: [ + { + method: "POST", + path: "/api/v1/workspaces/:workspaceId/releases/external/execute/:operationId", + action: "execute", + }, + { + method: "POST", + path: "/api/v1/workspaces/:workspaceId/releases/external/prepare/:type/:key", + action: "prepare", + successStatus: 201, + }, + ], + }, + { + id: "ownership.check", + summary: "Check managed and external ownership claims within a workspace tree", + mutation: false, + longRunning: true, + capabilities: ["read"], + parameters: [ + workspaceParameter, + { + name: "root", + description: "Complete coordination-scope directory within the workspace", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "root" }, + http: { in: "body" }, + }, + { + name: "environment", + description: "Environment name to inspect in every discovered project", + schema: stringSchema, + required: true, + cli: { kind: "option", name: "--env" }, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["ownership", "check"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/ownership/check" }], + }, + { + id: "state.list", + summary: "List managed state entries", + mutation: false, + longRunning: false, + capabilities: ["read"], + parameters: [workspaceParameter, environmentParameter, stateParameter], + resultSchema: operationResultSchema, + cli: { path: ["state", "list"] }, + http: [{ method: "POST", path: "/api/v1/workspaces/:workspaceId/state/list" }], + }, + { + id: "state.remove", + summary: "Remove one managed state entry without touching ChurchTools", + mutation: true, + longRunning: false, + capabilities: ["mutate"], + parameters: [ + workspaceParameter, + environmentParameter, + configParameter, + stateParameter, + { + name: "type", + description: "Resource type", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "type" }, + http: { in: "path" }, + }, + { + name: "key", + description: "Logical resource key", + schema: stringSchema, + required: true, + cli: { kind: "argument", name: "key" }, + http: { in: "path" }, + }, + { + name: "force", + description: "Remove even while declared", + schema: booleanSchema, + cli: { kind: "option", name: "--force" }, + http: { in: "body" }, + }, + { + name: "dryRun", + description: "Preview without writing", + schema: booleanSchema, + cli: { kind: "option", name: "--dry-run" }, + http: { in: "body" }, + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["state", "rm"] }, + http: [{ method: "DELETE", path: "/api/v1/workspaces/:workspaceId/state/:type/:key" }], + }, + { + id: "destroy", + summary: "Prepare and explicitly execute resource destruction", + mutation: true, + longRunning: true, + capabilities: ["plan", "mutate"], + parameters: [ + workspaceParameter, + environmentParameter, + stateParameter, + { + name: "targets", + description: "Logical keys", + schema: { type: "array", items: stringSchema }, + required: true, + cli: { kind: "option", name: "--target" }, + http: { in: "body" }, + actions: ["prepare"], + }, + { + name: "memberFields", + description: "Portable group member-field identities", + schema: { type: "array", items: stringSchema }, + cli: { kind: "option", name: "--member-field" }, + http: { in: "body" }, + actions: ["prepare"], + }, + { + name: "backupDir", + description: "Pre-destroy backup directory", + schema: stringSchema, + cli: { kind: "option", name: "--backup-dir" }, + http: { in: "body" }, + actions: ["prepare"], + }, + { + name: "confirmation", + description: "Core-required confirmation proof", + schema: objectSchema, + http: { in: "body" }, + actions: ["execute"], + }, + ], + resultSchema: operationResultSchema, + cli: { path: ["destroy"] }, + http: [ + { + method: "POST", + path: "/api/v1/workspaces/:workspaceId/destroys", + action: "prepare", + successStatus: 201, + }, + { + method: "POST", + path: "/api/v1/workspaces/:workspaceId/destroys/:operationId/execute", + action: "execute", + }, + ], + }, + { + id: "read.raw", + summary: "Read allowlisted or explicit ChurchTools API resources", + mutation: false, + longRunning: true, + capabilities: ["read"], + parameters: [], + resultSchema: objectSchema, + cli: { path: ["get"] }, + adapterSpecific: "cli", + }, + { + id: "permission.catalog", + summary: "Inspect or refresh the permission catalog", + mutation: true, + longRunning: true, + capabilities: ["read", "mutate"], + parameters: [], + resultSchema: objectSchema, + cli: { path: ["permissions"] }, + adapterSpecific: "cli", + }, + { + id: "permission.report", + summary: "Generate permission reports", + mutation: false, + longRunning: true, + capabilities: ["read"], + parameters: [], + resultSchema: objectSchema, + cli: { path: ["report"] }, + adapterSpecific: "cli", + }, + { + id: "shell.completion", + summary: "Generate shell completion hooks", + mutation: false, + longRunning: false, + capabilities: [], + parameters: [], + resultSchema: objectSchema, + cli: { path: ["completion"] }, + adapterSpecific: "cli", + }, + { + id: "server.start", + summary: "Start the versioned REST API adapter", + mutation: false, + longRunning: true, + capabilities: [], + parameters: [], + resultSchema: objectSchema, + cli: { path: ["server"] }, + adapterSpecific: "cli", + }, +] as const; + +export function operationById(id: string): OperationDefinition { + const definition = operationCatalog.find((candidate) => candidate.id === id); + if (!definition) throw new Error(`Unknown operation ${id}.`); + return definition; +} + +export function cliRootNames(catalog: readonly OperationDefinition[] = operationCatalog): string[] { + return [...new Set(catalog.flatMap((definition) => (definition.cli ? [definition.cli.path[0]!] : [])))]; +} diff --git a/src/operations/cli-projection.ts b/src/operations/cli-projection.ts new file mode 100644 index 0000000..89d36c9 --- /dev/null +++ b/src/operations/cli-projection.ts @@ -0,0 +1,54 @@ +import type { Command } from "commander"; +import { adoptCommand } from "../commands/adopt.js"; +import { applyCommand } from "../commands/apply.js"; +import { authCommand } from "../commands/auth.js"; +import { completionCommand } from "../commands/completion.js"; +import { coverageCommand } from "../commands/coverage.js"; +import { destroyCommand } from "../commands/destroy.js"; +import { environmentCommand } from "../commands/environment.js"; +import { getCommand } from "../commands/get.js"; +import { initCommand } from "../commands/init.js"; +import { inputCommand } from "../commands/input.js"; +import { ownershipCommand } from "../commands/ownership.js"; +import { permissionsCommand } from "../commands/permissions.js"; +import { planCommand } from "../commands/plan.js"; +import { refreshCommand } from "../commands/refresh.js"; +import { unadoptCommand, unuseCommand } from "../commands/release.js"; +import { reportCommand } from "../commands/report.js"; +import { stateCommand } from "../commands/state.js"; +import { serverCommand } from "../commands/server.js"; +import { useCommand } from "../commands/use.js"; +import { cliRootNames, type OperationDefinition } from "./catalog.js"; + +const factories: Record Command> = { + init: initCommand, + auth: authCommand, + input: inputCommand, + environment: environmentCommand, + get: getCommand, + adopt: adoptCommand, + unadopt: unadoptCommand, + use: useCommand, + unuse: unuseCommand, + ownership: ownershipCommand, + state: stateCommand, + coverage: coverageCommand, + permissions: permissionsCommand, + report: reportCommand, + refresh: refreshCommand, + plan: planCommand, + apply: applyCommand, + destroy: destroyCommand, + completion: completionCommand, + server: serverCommand, +}; + +/** Build the root command tree by enumerating the same catalog used by HTTP and OpenAPI. */ +export function buildCliProjection(catalog: readonly OperationDefinition[]): Command[] { + const roots = cliRootNames(catalog); + return roots.map((root) => { + const factory = factories[root]; + if (!factory) throw new Error(`Operation catalog has no Commander projection factory for ${root}.`); + return factory(); + }); +} diff --git a/src/operations/input-projection.ts b/src/operations/input-projection.ts new file mode 100644 index 0000000..1121f46 --- /dev/null +++ b/src/operations/input-projection.ts @@ -0,0 +1,37 @@ +import { + getInputSnapshot, + type GeneratedProcessConfig, + type ProcessInputGenerator, +} from "../application/operations/input.js"; +import { loadTrustedProcessGenerator } from "../server/generator.js"; + +export interface GeneratedInputSelection { + digest: string; + generator: ProcessInputGenerator; + generated: GeneratedProcessConfig; +} + +export async function generateSelectedInput( + cwd: string, + digest: string | undefined, + generatorPath: string | undefined, +): Promise { + if (!digest && !generatorPath) return null; + if (!digest || !generatorPath) { + throw new Error("--input-snapshot and --generator must be provided together."); + } + const generator = await loadTrustedProcessGenerator(generatorPath); + const snapshot = (await getInputSnapshot(cwd, digest)).value; + if (!generator.supportedSchemaVersions.includes(snapshot.schemaVersion)) { + throw new Error(`Generator ${generator.id} does not support schema ${snapshot.schemaVersion}.`); + } + const validation = await generator.validate(snapshot); + if (!validation.valid) { + throw new Error( + `Process input rejected by ${generator.id}: ${validation.errors + .map((item) => `${item.path} ${item.message}`) + .join(", ")}`, + ); + } + return { digest, generator, generated: await generator.generate(snapshot) }; +} diff --git a/src/permissions/scope.ts b/src/permissions/scope.ts index d81d9ba..23c3d64 100644 --- a/src/permissions/scope.ts +++ b/src/permissions/scope.ts @@ -230,7 +230,10 @@ export async function resolveScopeRefs( continue; // unknown right — desiredTuples reports it with the catalog's own hint } for (const raw of g.scope) { - const entry = normalizeScopeEntry(raw, where); + let entry = normalizeScopeEntry(raw, where); + // Bare strings are the historical group-scope sugar. Pre-resolve them through the shared + // resolver too, so an external group binding works in exactly the same permission position. + if (typeof entry === "string" && scopeField === GROUP_SCOPE_FIELD) entry = ref.group(entry); if (!isRef(entry)) continue; const dimension = expectedDimension(entry, scopeField, where); const k = refKey(entry); @@ -385,8 +388,14 @@ export function resolveScope( } else if (declaredGroupKeys.has(key)) { pending.push({ key, id: null, type: "group" }); } else { + const external = refs?.get(refKey(ref.group(key))); + if (external?.id !== null && external?.id !== undefined) { + resolved.push({ key: String(external.id), id: external.id, numeric: true }); + continue; + } throw new Error( - `Scope key "${key}" does not resolve to a managed group. Declare/adopt it, use a group already under management, or pass a raw numeric dataId if this right's scope is not a group (see the catalog's scopeField).`, + `Scope key "${key}" does not resolve to a managed or external group. Declare/adopt it, bind it with ` + + `\`ct use group --key ${key}\`, or pass a raw numeric dataId if this right's scope is not a group.`, ); } } diff --git a/src/resolve/external.ts b/src/resolve/external.ts new file mode 100644 index 0000000..3ade1df --- /dev/null +++ b/src/resolve/external.ts @@ -0,0 +1,128 @@ +import type { ExternalResource } from "../state/state.js"; + +export const EXTERNAL_REASON_CODES = [ + "EXTERNAL_BINDING_MISSING", + "EXTERNAL_BINDING_AMBIGUOUS", + "EXTERNAL_IDENTITY_MISMATCH", + "EXTERNAL_BINDING_STALE", + "EXTERNAL_READ_FAILED", +] as const; + +export type ExternalReasonCode = (typeof EXTERNAL_REASON_CODES)[number]; + +export interface ExternalCandidate { + id: number; + name: string; + identity: Record; + display: Record; +} + +export interface IdentityDifference { + field: string; + expected: unknown; + actual: unknown; +} + +export interface ExternalDiagnosticContext { + consumer?: string; + cwd?: string; + configPath?: string; + statePath?: string; + environment?: string | null; + host: string; +} + +export interface ExternalRemediation { + description: string; + command?: string; +} + +export interface ExternalDiagnosticDetails { + reason: ExternalReasonCode; + type: string; + key: string; + site: string; + context: ExternalDiagnosticContext; + binding?: ExternalResource; + candidates?: ExternalCandidate[]; + identityDiff?: IdentityDifference[]; + evidence: string[]; + consequence: string; + remediation: ExternalRemediation[]; + verification: string; +} + +function fieldBag(value: Record): string { + const entries = Object.entries(value); + return entries.length === 0 + ? "" + : ` · ${entries.map(([key, item]) => `${key}=${JSON.stringify(item)}`).join(", ")}`; +} + +export function identityDifferences( + expected: Record, + actual: Record, +): IdentityDifference[] { + const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort(); + return keys + .filter((field) => JSON.stringify(expected[field]) !== JSON.stringify(actual[field])) + .map((field) => ({ field, expected: expected[field], actual: actual[field] })); +} + +export function planVerification(environment?: string | null): string { + return environment ? `ct plan --env ${environment}` : "ct plan"; +} + +export function useBindingCommand( + type: string, + id: number, + key: string, + environment?: string | null, +): string { + return `ct use ${type} ${id} --key ${key}${environment ? ` --env ${environment}` : ""}`; +} + +export function renderExternalDiagnostic(details: ExternalDiagnosticDetails): string { + const { context } = details; + const lines = [ + "External prerequisite is not available", + "", + ` resource: ${details.type} ${JSON.stringify(details.key)}`, + ` referenced: ${details.site}`, + ` consumer: ${context.consumer ?? context.cwd ?? "current ct project"}`, + ` owner: ${details.binding?.owner ?? "unknown"}`, + ` environment: ${context.environment ?? "default"}`, + ` host: ${context.host}`, + "", + "Evidence:", + ...details.evidence.map((item) => ` - ${item}`), + ]; + if (details.candidates?.length) { + lines.push("", "Candidates:"); + for (const candidate of details.candidates) { + lines.push( + ` - #${candidate.id} ${JSON.stringify(candidate.name)}${fieldBag(candidate.identity)}${fieldBag(candidate.display)}`, + ); + } + } + if (details.identityDiff?.length) { + lines.push("", "Identity changes:"); + for (const diff of details.identityDiff) { + lines.push(` - ${diff.field}: ${JSON.stringify(diff.expected)} -> ${JSON.stringify(diff.actual)}`); + } + } + lines.push("", `Consequence: ${details.consequence}`, "", "Next steps:"); + details.remediation.forEach((step, index) => { + lines.push(` ${index + 1}. ${step.command ?? step.description}`); + if (step.command && step.description) lines.push(` ${step.description}`); + }); + lines.push("", `Verification: ${details.verification}`); + return lines.join("\n"); +} + +export class ExternalReferenceError extends Error { + constructor(readonly details: ExternalDiagnosticDetails) { + super(renderExternalDiagnostic(details)); + this.name = "ExternalReferenceError"; + } +} diff --git a/src/resolve/refs.ts b/src/resolve/refs.ts index 98e062e..5b52aa9 100644 --- a/src/resolve/refs.ts +++ b/src/resolve/refs.ts @@ -24,6 +24,9 @@ export type RefKind = | "security-level" | "comment-viewer" | "group-type" + | "age-group" + | "target-group" + | "relationship-type" | "group-status" | "person-status" | "role-def" @@ -32,21 +35,6 @@ export type RefKind = | "group-type-role" | "group-member-field"; -/** - * Shared explanation for why a group-status reference can never be resolved by name (#67): - * ChurchTools exposes no REST catalog for group statuses — `GET /group/memberstatus` is a - * different dimension (member statuses, string ids), live-verified 2026-07-10 on eqrm prod. - * Used verbatim by both guards that can see a group-status reference, so their messages can't - * drift apart: - * - the eval-time guard (src/config/context.ts) for a declared `status:` field, and - * - the plan-time guard (src/resolve/resolver.ts) for a `groupStatusId: ref.status(...)` value - * that bypassed the eval-time guard (the id-field escape hatch accepts any Ref) and reached - * the resolver directly. - */ -export const GROUP_STATUS_NO_CATALOG = - `group statuses have no REST catalog (GET /group/memberstatus is a different dimension: member ` + - `statuses, string ids — verified 2026-07-10). Declare a numeric "groupStatusId" instead (e.g. "groupStatusId: 1").`; - /** Simple key-addressed reference: campus / department / security level / group type / group status / person status / role definition / group. */ export interface SimpleRef { __ctRef: true; @@ -56,6 +44,9 @@ export interface SimpleRef { | "security-level" | "comment-viewer" | "group-type" + | "age-group" + | "target-group" + | "relationship-type" | "group-status" | "person-status" | "role-def" @@ -204,6 +195,21 @@ export const ref = { kind: "group-type", key: requireKey("group-type", key), }), + ageGroup: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "age-group", + key: requireKey("age-group", key), + }), + targetGroup: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "target-group", + key: requireKey("target-group", key), + }), + relationshipType: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "relationship-type", + key: requireKey("relationship-type", key), + }), status: (key: string): SimpleRef => ({ __ctRef: true, kind: "group-status", @@ -211,9 +217,8 @@ export const ref = { }), /** * A PERSON status (`/statuses` — "0 - First", "3 - Group Active", …), the domain of a `status` - * permission declaration. Unrelated to {@link ref.status} (GROUP status, `groupStatusId`), which - * has no catalog at all (#67) — person statuses do, so this one resolves by name like any other - * master-data ref. + * permission declaration. Unrelated to {@link ref.status} (GROUP status, `groupStatusId`), whose + * separate catalog is nested under `/person/masterdata.groupStatuses` (#157). */ personStatus: (key: string): SimpleRef => ({ __ctRef: true, diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 0c91814..62e1136 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -7,28 +7,23 @@ * state resolves to a {@link PendingRef} (its id is only known after the * resource tier applies — re-resolved at apply time, mirroring the permission * scope pattern in src/permissions/scope.ts). - * 2. Live catalog master data, matched by `slug(name) === key` with an exact-name - * secondary: campus → /campuses, group-type → /group/grouptypes, role-def → /group/roles. - * Each catalog is fetched at most once per run and cached by a `Map`, - * so the resolver is safe to share across `buildPlan` and `buildPermissionPlan` running - * concurrently (both await the same in-flight promise). group-status ("group-status" / - * `ref.status`) has NO catalog here — ChurchTools exposes no REST list endpoint for group - * statuses at all (live-verified 2026-07-10 on eqrm prod; see the note by `CATALOG_PATH` - * below and #67). A declared `status:` field fails fast at eval time (src/config/context.ts) - * before it ever reaches this resolver — but a `groupStatusId: ref.status(...)` value skips - * that guard (the id-field escape hatch accepts any Ref) and lands on step 3 below, where - * `notFound` special-cases "group-status" to give the same actionable message instead of - * the generic "declare/adopt it" advice, which would be wrong (no such resource, no catalog). - * 3. Hard error naming the kind, key, referencing site, and host. + * 2. A persisted external binding, read live by id and checked against the registry-defined hard + * identity. It is never pending and never enters desired/write inputs. + * 3. Live catalog discovery for diagnostics only. A unique candidate still blocks until `ct use` + * persists the explicit host binding; ambiguous candidates each get a complete command. Catalog + * reads are cached so a resolver shared by resource and permission planning is concurrency-safe. + * Group status is the specialised exception: it resolves directly from + * `/person/masterdata.groupStatuses` because it has no manageable resource or external binding + * type (#157). * * Unknown / ambiguous references THROW (a config error — distinct from the * degrade-and-continue fetchErrors path). Resolved ids are never written back to * config; only state carries ids. */ -import type { CtClient } from "../api/ctClient.js"; -import type { State } from "../state/state.js"; +import { CtApiError, type CtClient } from "../api/ctClient.js"; +import { externalResources, type ExternalResource, type State } from "../state/state.js"; import type { DesiredResource } from "../engine/types.js"; -import { slug } from "../resources/registry.js"; +import { RESOURCES, resourceType, slug, type CtWriteClient } from "../resources/registry.js"; import { conflictingReferenceName, groupScopedRows, @@ -41,7 +36,6 @@ import { import { collectRefs, deepMapRefs, - GROUP_STATUS_NO_CATALOG, isPendingRef, isRef, pendingRef, @@ -55,76 +49,31 @@ import { type RefKind, type SimpleRef, } from "./refs.js"; - -/** ref kind → managed resource type (state/desired). group-status has neither: no catalog and never managed (#67). */ -const REF_KIND_TYPE: Partial> = { - campus: "campus", - "group-type": "group-type", - // Person statuses became an adoptable resource in #96, so a `personStatus: "…"` domain now - // resolves from managed state / this run's declarations FIRST and only falls through to the - // `/statuses` catalog for a status this config does not own. That ordering is what makes a - // status declared in the same config usable as a permission domain (it resolves to a PendingRef, - // which buildPermissionPlan carries as a pending domain, #69). - "person-status": "person-status", - // Bereiche became a managed resource in #108 (writes go through the legacy master-data endpoint, - // reads stay `GET /departments`), so a `{ department: "…" }` ref resolves from managed state first - // and only falls back to the catalog for a Bereich this config does not own. - department: "department", - // Security levels became a managed resource in #110, so — exactly like person statuses above — a - // `{ securityLevel: "…" }` ref resolves from managed state / this run's declarations FIRST and only - // falls through to the `/securitylevels` catalog for a level this config does not own. That - // ordering is what lets a config declare a level and scope a grant to it in the same run (the ref - // resolves to a PendingRef, carried as a pending scope). - "security-level": "security-level", - // Comment viewers became a managed resource in #151 — same ordering as security levels above, and - // for the sharper reason: their ids genuinely differ across hosts of the same deployment, so a - // config that declares its viewers must resolve `{ commentViewer: "…" }` against what IT owns - // before falling back to `/person/commentviewers` for a viewer it does not. - "comment-viewer": "comment-viewer", - "role-def": "group-role", - group: "group", -}; +import { + ExternalReferenceError, + identityDifferences, + planVerification, + useBindingCommand, + type ExternalCandidate, + type ExternalDiagnosticContext, +} from "./external.js"; + +/** ref kind → managed resource type (state/desired). Group-status is catalog-only (#157). */ +const REF_KIND_TYPE: Partial> = Object.fromEntries( + Object.entries(RESOURCES).map(([type, spec]) => [spec.external.refKind, type]), +) as Partial>; /** * ref kind → live catalog path. `group` has no catalog (managed-only); `group-role` is gated. * - * `group-status` is deliberately ABSENT (#67, disproving the prior assumption documented here): - * `GET /group/memberstatus` is NOT a group-status catalog — live-verified 2026-07-10 on eqrm prod, - * it returns MEMBER statuses (`{id: "active", name: "Active"}, {id: "requested", ...}`, STRING ids), - * a completely different dimension from `groupStatusId` (numeric — e.g. 1 = active, 4 = archived on - * that instance). Further probing found no REST list endpoint for group statuses at all - * (`/groups/statuses` parses as `/groups/{groupId}`, `/group/statuses` and `/groupstatuses` 404) — - * neither read nor write. So `status:` sugar fails fast at eval time instead (src/config/context.ts) - * rather than reaching this resolver and either resolving against the wrong dimension or landing - * here as an unconditional hard error. If CT ever ships a real group-status endpoint, add it back - * here and restore the `status` entry to `ID_SUGAR` in context.ts. + * Group statuses have no dedicated endpoint, but `GET /person/masterdata` contains them under + * `groupStatuses` (#157). That numeric-id/technical-name catalog is distinct from both + * `/group/memberstatus` (membership statuses with string ids) and `/statuses` (person statuses). + * The reader below special-cases the nested response shape. */ const CATALOG_PATH: Partial> = { - campus: "/campuses", - // Bereiche/departments — the `cdb_bereich` permission scope dimension (#98). Catalog-ONLY, with no - // REF_KIND_TYPE entry above: `GET /departments` exists but no POST/PUT/DELETE does (live-probed on - // eqrm prod, CT 3.135.2, 2026-08-13), so a department is resolvable by name on every host yet can - // never be declared, adopted or created. Rows carry {id, name, nameTranslated, sortKey, shorty}. - department: "/departments", - // Security levels — the `cc_securitylevel` scope dimension (#110). `GET /securitylevels` returns a - // flat `[{id, name, sortKey}]` array ("Stufe 1 (Niedrig)" … "Stufe 4 (Sehr hoch)"), live-verified on - // eqrm prod (CT 3.135.2, 2026-08-13) and eqrm-dev (2026-08-14). Unlike `department` this kind ALSO - // has a REF_KIND_TYPE entry: levels are a managed resource, so this catalog is the fallback for a - // level the config does not own, not the only source. Reading it by name matters because the ids - // are not a protocol constant — an editable table with an auto-increment id and a supported - // reorder, so a hard-coded `scope: [1, 2, 3]` is portable only by convention. - "security-level": "/securitylevels", - // Comment viewers — the `cdb_comment_viewer` scope dimension (#102). `[{id, name, sortKey}]`, - // live-verified on eqrm-dev CT 3.135.2, 2026-08-14. NB `id: 0` is a real row here ("Alle"), which - // is why nothing in the resolve path may treat a falsy id as "missing". - "comment-viewer": "/person/commentviewers", - "group-type": "/group/grouptypes", - // PERSON statuses — the domain of a `status` permission declaration (#90). Unlike GROUP statuses - // (see the note above), these DO have a flat REST catalog: `GET /statuses` returns - // `[{id, name, shorty, …}]` — live-verified 2026-08-10 on eqrm prod. (`/person/masterdata` carries - // the same rows under a `statuses` key, but nested; this catalog reader expects a top-level array.) - "person-status": "/statuses", - "role-def": "/group/roles", + ...Object.fromEntries(Object.values(RESOURCES).map((spec) => [spec.external.refKind, spec.collectionPath])), + "group-status": "/person/masterdata", }; interface CatalogRecord { @@ -146,6 +95,8 @@ export interface ResolverDeps { desired: DesiredResource[]; /** Host label for error messages. Defaults to `state.host`. */ host?: string; + /** Public project metadata used to produce copyable, structured external diagnostics. */ + context?: Omit; } /** @@ -197,7 +148,9 @@ export class Resolver { private readonly client: Pick & Partial>; private readonly state: State; private readonly host: string; + private readonly context: ExternalDiagnosticContext; private readonly catalogs = new Map>(); + private readonly externalReads = new Map>(); /** Per-group role list cache (group_role domain resolution), keyed by group id, fetched at most once. */ private readonly groupRoleLists = new Map>(); /** Declared logical keys indexed by resource type — a same-run target that resolves to pending. */ @@ -229,6 +182,7 @@ export class Resolver { this.client = deps.client; this.state = deps.state; this.host = deps.host ?? deps.state.host; + this.context = { ...deps.context, host: this.host }; for (const d of deps.desired) { let set = this.declaredByType.get(d.type); if (!set) { @@ -266,10 +220,15 @@ export class Resolver { const managed = this.state.resources[r.key]; if (managed && managed.type === type) return managed.id; if (this.declaredByType.get(type)?.has(r.key)) return pendingRef(r); + // (2) a persisted external binding, always read live and hard-identity validated. + const external = externalResources(this.state)[r.key]; + if (external && external.type === type) return this.resolveBoundExternal(external, site); + // (3) discovery is diagnostic only. It never supplies an ephemeral id. + return this.requireExternalBinding(type, r, site); } - // (2) live catalog - if (CATALOG_PATH[r.kind] !== undefined) return this.resolveFromCatalog(r, site); - // (3) hard error + // Group statuses cannot be managed or bound, so their read-only master-data catalog resolves + // directly. Compound/owned structures keep their specialised errors. + if (r.kind === "group-status") return this.resolveFromCatalog(r, site); throw this.notFound(r, site); } @@ -289,6 +248,12 @@ export class Resolver { return deepMapRefs(value, (r) => byKey.get(refKey(r))); } + /** Resolve a registry type/key pair used by string-only legacy positions such as hierarchy parents. */ + async resolveKey(type: string, key: string, site: string): Promise { + const kind = resourceType(type).external.refKind; + return this.resolve({ __ctRef: true, kind, key } as SimpleRef, site); + } + /** * Fetch one master-data catalog, ONCE per run, paginated. * @@ -302,9 +267,14 @@ export class Resolver { let p = this.catalogs.get(kind); if (!p) { const path = CATALOG_PATH[kind]!; - const rows = this.client.getAll - ? this.client.getAll(path).then((page) => page.data) - : this.client.get(path); + const rows = + kind === "group-status" + ? this.client + .get<{ groupStatuses?: CatalogRecord[] }>(path) + .then((masterdata) => masterdata.groupStatuses ?? []) + : this.client.getAll + ? this.client.getAll(path).then((page) => page.data) + : this.client.get(path); p = rows.then((r) => (Array.isArray(r) ? r : [])); this.catalogs.set(kind, p); } @@ -317,8 +287,6 @@ export class Resolver { if (candidates.length > 1) throw this.ambiguous(r, site, candidates); return candidates[0]!.id; }; - // Primary: slugified name. Secondary: exact (case-sensitive) name — covers a name that does not - // survive slugging cleanly. Ambiguity in either bucket is a hard error listing the candidates. const bySlug = rows.filter((row) => typeof row.name === "string" && slug(row.name) === r.key); if (bySlug.length >= 1) return pick(bySlug); const byExact = rows.filter((row) => row.name === r.key); @@ -326,6 +294,166 @@ export class Resolver { throw this.notFound(r, site); } + private async resolveBoundExternal(external: ExternalResource, site: string): Promise { + let read = this.externalReads.get(external.key); + if (!read) { + read = this.validateBoundExternal(external, site); + this.externalReads.set(external.key, read); + } + return read; + } + + private async validateBoundExternal(external: ExternalResource, site: string): Promise { + const spec = resourceType(external.type); + let live: Record | null; + try { + live = spec.fetchOne + ? await spec.fetchOne(this.client as CtWriteClient, external.id) + : await this.client.get>(spec.itemPath(external.id)); + } catch (error) { + if (error instanceof CtApiError && error.status === 404) live = null; + else { + throw new ExternalReferenceError({ + reason: "EXTERNAL_READ_FAILED", + type: external.type, + key: external.key, + site, + context: this.context, + binding: external, + evidence: [ + `Inspected external state binding ${external.type}.${external.key} -> #${external.id}.`, + `The live item read failed: ${error instanceof Error ? error.message : String(error)}.`, + ], + consequence: + "Consumer plan/apply is blocked before writes; the consumer will not create or repair the owner's object.", + remediation: [{ description: "Restore live read access or retry after the transient failure." }], + verification: planVerification(this.context.environment), + }); + } + } + if (live === null) { + throw new ExternalReferenceError({ + reason: "EXTERNAL_BINDING_STALE", + type: external.type, + key: external.key, + site, + context: this.context, + binding: external, + evidence: [ + `Inspected external state binding ${external.type}.${external.key} -> #${external.id}.`, + `The registry item read ${spec.itemPath(external.id)} returned 404 / no row.`, + ], + consequence: + "Consumer plan/apply is blocked before writes; the consumer will not recreate the missing owner's object.", + remediation: external.owner + ? [ + { + description: `Run plan in owner project ${JSON.stringify(external.owner)} and repair its stale state/object first.`, + }, + ] + : [ + { + description: + "Locate the owner project and run its plan before changing this consumer binding.", + }, + ], + verification: planVerification(this.context.environment), + }); + } + const identity = spec.external.identity(live); + const diff = identityDifferences(external.identity, identity); + if (diff.length > 0) { + const command = useBindingCommand(external.type, external.id, external.key, this.context.environment); + throw new ExternalReferenceError({ + reason: "EXTERNAL_IDENTITY_MISMATCH", + type: external.type, + key: external.key, + site, + context: this.context, + binding: external, + identityDiff: diff, + evidence: [ + `Inspected external state binding ${external.type}.${external.key} -> #${external.id}.`, + "The live object exists, but its registry-defined hard identity differs from the stored snapshot.", + ], + consequence: + "Consumer plan/apply is blocked before writes; display-only changes would not block, but hard identity changes require explicit acceptance.", + remediation: [ + { command, description: "Review the field diff and confirm accepting the live identity." }, + ], + verification: planVerification(this.context.environment), + }); + } + return external.id; + } + + private candidate(type: string, row: CatalogRecord): ExternalCandidate { + const spec = resourceType(type); + return { + id: row.id, + name: typeof row.name === "string" ? row.name : `#${row.id}`, + identity: spec.external.identity(row), + display: spec.external.display(row), + }; + } + + private async requireExternalBinding(type: string, r: SimpleRef, site: string): Promise { + let rows: CatalogRecord[]; + try { + rows = await this.catalog(r.kind); + } catch (error) { + throw new ExternalReferenceError({ + reason: "EXTERNAL_READ_FAILED", + type, + key: r.key, + site, + context: this.context, + evidence: [ + `Inspected managed state and external state; neither contains a ${type} binding for key ${JSON.stringify(r.key)}.`, + `Live discovery at ${resourceType(type).collectionPath} failed: ${error instanceof Error ? error.message : String(error)}.`, + ], + consequence: + "Consumer plan/apply is blocked before writes; the consumer will not create or repair the owner's object.", + remediation: [{ description: "Restore collection read access, then create the explicit binding." }], + verification: planVerification(this.context.environment), + }); + } + const bySlug = rows.filter((row) => typeof row.name === "string" && slug(row.name) === r.key); + const matches = bySlug.length > 0 ? bySlug : rows.filter((row) => row.name === r.key); + const candidates = matches.map((row) => this.candidate(type, row)); + const reason = candidates.length > 1 ? "EXTERNAL_BINDING_AMBIGUOUS" : "EXTERNAL_BINDING_MISSING"; + const remediation = + candidates.length > 0 + ? candidates.map((candidate) => ({ + command: useBindingCommand(type, candidate.id, r.key, this.context.environment), + description: `Bind ${JSON.stringify(candidate.name)} read-only in this consumer.`, + })) + : [ + { + description: + "Apply the owner project first, correct the logical key, or use `ct use` with the intended live id.", + }, + ]; + throw new ExternalReferenceError({ + reason, + type, + key: r.key, + site, + context: this.context, + candidates, + evidence: [ + `Inspected managed state and external state; neither contains a ${type} binding for key ${JSON.stringify(r.key)}.`, + candidates.length === 0 + ? `Live discovery at ${resourceType(type).collectionPath} found no matching candidate.` + : `Live discovery found ${candidates.length} matching candidate(s), but plan is read-only and cannot persist or consume an ephemeral binding.`, + ], + consequence: + "Consumer plan/apply is blocked before writes; the consumer will not create, adopt, or repair the owner's object.", + remediation, + verification: planVerification(this.context.environment), + }); + } + /** * Resolve a `group_role` domain by its (group, role) pair to the numeric pairing domainId (#25). * See the VERIFIED LIVE block above the {@link GROUP_ROLE_ENDPOINT} constant for the model this @@ -343,7 +471,7 @@ export class Resolver { site: string, opts: ResolveOptions = {}, ): Promise { - const groupId = this.groupIdForRole(r, site, opts); + const groupId = await this.groupIdForRole(r, site, opts); if (typeof groupId !== "number") return groupId; const rows = await this.groupRoleList(groupId); // #106 made a same-run GROUP resolve as pending. #120: the ROLE half needs the same treatment. @@ -388,7 +516,9 @@ export class Resolver { if (!this.declaresRoleNamed(r.role)) return false; const declaredTypes = this.declaredRoleDefTypes.get(slug(r.role)); if (declaredTypes === undefined) return true; // declared, but on no stated group type - const groupTypeId = this.state.resources[r.group]?.fields.groupTypeId; + const groupTypeId = + this.state.resources[r.group]?.fields.groupTypeId ?? + externalResources(this.state)[r.group]?.identity.groupTypeId; if (typeof groupTypeId !== "number") return true; // this host's state cannot say — stay lenient for (const t of declaredTypes) { // A group-type Ref that is itself pending cannot be this existing group's type, so a non-number @@ -469,7 +599,11 @@ export class Resolver { * declared in this run resolves to a {@link PendingRef} when the call site can finish it later * (#106) and stays a hard error otherwise. */ - private groupIdForRole(r: GroupRoleRef, site: string, opts: ResolveOptions): number | PendingRef { + private async groupIdForRole( + r: GroupRoleRef, + site: string, + opts: ResolveOptions, + ): Promise { const managed = this.state.resources[r.group]; if (managed && managed.type === "group") return managed.id; if (this.declaredByType.get("group")?.has(r.group)) { @@ -480,10 +614,10 @@ export class Resolver { `the group does. Apply the group first, then re-run, or pass a numeric id.`, ); } - throw new Error( - `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: no managed group named ` + - `"${r.group}" is declared or adopted. Declare/adopt it, fix the key, or pass a numeric id.`, - ); + const external = externalResources(this.state)[r.group]; + if (external?.type === "group") return this.resolveBoundExternal(external, site); + const simple: SimpleRef = { __ctRef: true, kind: "group", key: r.group }; + return this.requireExternalBinding("group", simple, site); } /** @@ -608,17 +742,6 @@ export class Resolver { } private notFound(r: SimpleRef, site: string): Error { - // group-status (#67, reviewer follow-up): a `groupStatusId: ref.status(...)` value bypasses the - // eval-time guard in src/config/context.ts (the id-field escape hatch accepts any Ref) and lands - // here. The generic "declare/adopt it, fix the key" advice below is actively wrong for - // group-status — there is no such managed resource type and no catalog to adopt against — so - // give the same actionable message the eval-time guard uses instead (shared constant so the two - // sites can't drift). - if (r.kind === "group-status") { - return new Error( - `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: ${GROUP_STATUS_NO_CATALOG}`, - ); - } const catalog = CATALOG_PATH[r.kind]; // A catalog-only kind has no managed resource type, so "Declare/adopt it" is advice the tool // cannot honour (#96's exact complaint about the old person-status message). The message says diff --git a/src/resolve/reverse.ts b/src/resolve/reverse.ts index c3c645e..269660e 100644 --- a/src/resolve/reverse.ts +++ b/src/resolve/reverse.ts @@ -2,10 +2,8 @@ * Reverse reference resolution for `ct adopt` (#52 item A): turn the numeric ChurchTools ids a * fetched resource carries (`campusId`, `groupTypeId`) into the logical sugar the DSL already * accepts (`campus`/`groupType`), so an adopted snippet is portable and reads like something a - * human would author — not a wall of instance-specific integers. `groupStatusId` is NOT reverse- - * sugared (#67): group statuses have no REST catalog to look a name up against (`/group/memberstatus` - * is a different dimension — member statuses, string ids — live-verified 2026-07-10), so adopt - * always emits it as a plain numeric field, same as any other unmapped id. + * human would author — not a wall of instance-specific integers. Group statuses are read from the + * nested `groupStatuses` catalog returned by `/person/masterdata` (#157). * * This is the mirror image of the forward {@link Resolver} (src/resolve/resolver.ts): it reads the * SAME master-data catalogs, matched here BY ID instead of by name, and emits `slug(name)` — exactly @@ -27,6 +25,7 @@ import { slug } from "../resources/registry.js"; const REVERSE_ID_FIELDS: Record = { campusId: { catalog: "/campuses", sugar: "campus" }, groupTypeId: { catalog: "/group/grouptypes", sugar: "groupType" }, + groupStatusId: { catalog: "/person/masterdata#groupStatuses", sugar: "status" }, }; /** @@ -38,6 +37,7 @@ const REVERSE_ID_FIELDS: Record = { const PORTABLE_CATALOG_PATHS: Partial> = { campus: "/campuses", "group-type": "/group/grouptypes", + "group-status": "/person/masterdata#groupStatuses", "role-def": "/group/roles", }; @@ -56,13 +56,13 @@ export interface RoleCatalogEntry { } export class ReverseResolver { - private readonly client: Pick; + private readonly client: Pick; /** id → logical key, per catalog path, fetched at most once. A failed fetch caches an empty map. */ private readonly catalogs = new Map>>(); /** groupTypeRoleId → {groupTypeId, name} from `/group/roles`, fetched at most once (#76). */ private roleCatalog?: Promise>; - constructor(client: Pick) { + constructor(client: Pick) { this.client = client; } @@ -74,9 +74,14 @@ export class ReverseResolver { // on eqrm prod `/group/roles` has 46 rows, so 36 roles had no id→key entry and every ruleset // `role.id` pointing at one was left as a host-specific number with a vague warning. The // forward Resolver was fixed for exactly this; the reverse side had the same bug. - p = this.client - .getAll(path) - .then(({ data: rows }) => { + const rows = + path === "/person/masterdata#groupStatuses" + ? this.client + .get<{ groupStatuses?: CatalogRecord[] }>("/person/masterdata") + .then((masterdata) => masterdata.groupStatuses ?? []) + : this.client.getAll(path).then(({ data }) => data); + p = rows + .then((rows) => { const map = new Map(); if (Array.isArray(rows)) { for (const row of rows) { @@ -145,10 +150,10 @@ export class ReverseResolver { /** * Reverse-sugar a managed-field bag for emission (#52 item A): each numeric id field with a catalog - * match becomes its logical `campus`/`groupType` key (dropping the numeric field); an id with NO - * match stays numeric and is named in `todos` so the emitter can flag it. Every other field — - * including `groupStatusId`, which has no `REVERSE_ID_FIELDS` entry (#67) — passes through - * untouched in its original position (and a `null` id — "no campus" — is omitted by the emitter). + * match becomes its logical `campus`/`groupType`/`status` key (dropping the numeric field); an id + * with NO match stays numeric and is named in `todos` so the emitter can flag it. Every other + * field passes through untouched in its original position (and a `null` id — "no campus" — is + * omitted by the emitter). */ async sugarFields( fields: Record, diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 7cefb32..71b0c3b 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -27,6 +27,27 @@ export interface CtWriteClient { get?(path: string): Promise; } +/** Generic read-only binding contract shared by `ct use`, resolution and ownership analysis. */ +export interface ExternalResourceAdapter { + /** Simple logical ref kind that resolves this top-level registry type. */ + refKind: + | "campus" + | "department" + | "security-level" + | "comment-viewer" + | "group-type" + | "age-group" + | "target-group" + | "relationship-type" + | "person-status" + | "role-def" + | "group"; + /** Minimal hard identity persisted in external state and validated on every plan. */ + identity(resource: Record): Record; + /** Non-validating fields shown while selecting or explaining a binding. */ + display(resource: Record): Record; +} + export interface AdoptableResource { /** Collection path: `POST` here creates, unless {@link createPath} overrides the target. */ collectionPath: string; @@ -127,6 +148,8 @@ export interface AdoptableResource { * (e.g. `group-role` → `roleDefinition`, because `groupRole` is the permission function). */ dslName?: string; + /** Mandatory generic external/read-only behaviour for this top-level ct-cli resource. */ + external: ExternalResourceAdapter; } /** Build a full spec, deriving `itemPath` from the collection path so each entry names its path once. */ @@ -153,6 +176,11 @@ function str(resource: Record, key: string): string { return typeof value === "string" ? value : ""; } +/** Keep adapter output compact and deterministic; absent API fields are not identity assertions. */ +function present(fields: Record): Record { + return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)); +} + /** * First `max` *code points* (not UTF-16 code units) of `value` — CT's create validators cap several * name/shorty fields. Plain `String#slice` operates on UTF-16 code units, which can split an astral @@ -320,6 +348,11 @@ export const RESOURCES: Record = { // exactly as before — this is only about the key. deriveKey: (r) => slug(str(r, "name") || str(r, "shorty")), managedFields: (r) => ({ name: r.name, shorty: r.shorty }), + external: { + refKind: "campus", + identity: (r) => present({ name: r.name }), + display: (r) => present({ shorty: r.shorty }), + }, }), group: define({ collectionPath: "/groups", @@ -338,6 +371,15 @@ export const RESOURCES: Record = { groupStatusId: fromInformation(r, "groupStatusId"), campusId: fromInformation(r, "campusId") ?? null, }), + external: { + refKind: "group", + identity: (r) => present({ name: r.name, groupTypeId: fromInformation(r, "groupTypeId") }), + display: (r) => + present({ + campusId: fromInformation(r, "campusId"), + groupStatusId: fromInformation(r, "groupStatusId"), + }), + }, }), "group-type": define({ collectionPath: "/group/grouptypes", @@ -345,6 +387,11 @@ export const RESOURCES: Record = { tier: 0, deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated }), + external: { + refKind: "group-type", + identity: (r) => present({ name: r.name }), + display: (r) => present({ nameTranslated: r.nameTranslated }), + }, // POST /group/grouptypes rejects a body carrying only name/nameTranslated: CT requires the fields // below (validated live on CT 3.134.1, #73, and against the OpenAPI POST schema). They are unmanaged // (create-only) and derived deterministically from the declared `name`. If a user declares one of @@ -376,6 +423,11 @@ export const RESOURCES: Record = { tier: 0, deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated, sortKey: r.sortKey }), + external: { + refKind: "age-group", + identity: (r) => present({ name: r.name }), + display: (r) => present({ nameTranslated: r.nameTranslated, sortKey: r.sortKey }), + }, }), "target-group": define({ collectionPath: "/group/targetgroups", @@ -383,6 +435,11 @@ export const RESOURCES: Record = { tier: 0, deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated, sortKey: r.sortKey }), + external: { + refKind: "target-group", + identity: (r) => present({ name: r.name }), + display: (r) => present({ nameTranslated: r.nameTranslated, sortKey: r.sortKey }), + }, }), "relationship-type": define({ collectionPath: "/person/relationshiptypes", @@ -396,6 +453,11 @@ export const RESOURCES: Record = { degreeNameA: r.degreeNameA, degreeNameB: r.degreeNameB, }), + external: { + refKind: "relationship-type", + identity: (r) => present({ name: r.name }), + display: (r) => present({ degreeNameA: r.degreeNameA, degreeNameB: r.degreeNameB }), + }, }), /** * PERSON statuses (`/statuses` — "0 - First", "3 - Group Active", …), the domain of a `ct.status` @@ -443,6 +505,11 @@ export const RESOURCES: Record = { sortKey: r.sortKey, securityLevelId: r.securityLevelId, }), + external: { + refKind: "person-status", + identity: (r) => present({ name: r.name }), + display: (r) => present({ shorty: r.shorty, isMember: r.isMember }), + }, }), /** * BEREICHE / DEPARTMENTS (#108) — `cdb_bereich`, the scope dimension of `churchdb:view alldata` @@ -473,6 +540,11 @@ export const RESOURCES: Record = { "through the legacy master-data endpoint. Verify it is unused first (`ct get departments`).", deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, shorty: r.shorty, sortKey: r.sortKey ?? 0 }), + external: { + refKind: "department", + identity: (r) => present({ name: r.name }), + display: (r) => present({ shorty: r.shorty }), + }, // There is no `GET /departments/{id}` — filter the collection instead. Without this every plan // after a create would read a 404 and propose creating the same Bereich again. fetchOne: async (client, id) => @@ -513,6 +585,11 @@ export const RESOURCES: Record = { "scopes). Verify it is unused first (`ct get security-levels`, `ct get data-fields`).", deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ id: r.id, name: r.name }), + external: { + refKind: "security-level", + identity: (r) => present({ name: r.name }), + display: (r) => present({ level: r.sortKey ?? r.id }), + }, }), /** * COMMENT VIEWERS (#151) — `cdb_comment_viewer`, the scope dimension of `churchdb:view comments` @@ -559,6 +636,11 @@ export const RESOURCES: Record = { "it is unused first (`ct get comment-viewers`, `ct report permissions`).", deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, sortKey: r.sortKey }), + external: { + refKind: "comment-viewer", + identity: (r) => present({ name: r.name }), + display: (r) => present({ sortKey: r.sortKey }), + }, // `sortKey` is managed but not mandatory in a hand-authored declaration; CT's create validator // for the 3-column master-data tables rejects a missing integer column, so supply a neutral one // (a declared value still wins — createDefaults merges UNDER the body). @@ -586,6 +668,11 @@ export const RESOURCES: Record = { // only diffs when the config declares it: `diffFields` walks the DESIRED keys. type: r.type, }), + external: { + refKind: "role-def", + identity: (r) => present({ name: r.name, groupTypeId: r.groupTypeId }), + display: (r) => present({ type: r.type }), + }, // Fields CT REQUIRES at create but the tool does not otherwise manage (#73/#121). The old comment // here claimed `type`/`isLeader`/`sortKey` were "all optional/nullable — no default needed", which // is what made this look supported. VERIFIED LIVE on eqrm-dev, CT 3.135.2 (2026-08-17): POSTing diff --git a/src/server/app.ts b/src/server/app.ts new file mode 100644 index 0000000..193ea03 --- /dev/null +++ b/src/server/app.ts @@ -0,0 +1,1024 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { createServer, type Server } from "node:http"; +import { authedSession } from "../api/session.js"; +import { beginPasswordLogin, continuePasswordLogin, type PasswordLoginContinuation } from "../auth/login.js"; +import { readToken } from "../auth/tokenStore.js"; +import { CtApplicationError } from "../application/errors.js"; +import { runAdoptGroups } from "../application/operations/adopt-group.js"; +import { runAdoptGrants } from "../application/operations/adopt-grants.js"; +import { runAdoptResource } from "../application/operations/adopt.js"; +import { runAuthLogin, runAuthLogout, runAuthStatus } from "../application/operations/auth.js"; +import { + executePreparedApply, + prepareApply, + type ConfirmationProof, + type PreparedApplyExecution, +} from "../application/operations/apply.js"; +import { runCoverage } from "../application/operations/coverage.js"; +import { + executePreparedDestroy, + prepareDestroy, + type PreparedDestroyExecution, +} from "../application/operations/destroy.js"; +import { listEnvironments } from "../application/operations/environment.js"; +import { + createInputSnapshot, + getInputSnapshot, + listInputSnapshots, + validateProcessInput, + type ProcessInputDocument, + type ProcessInputGenerator, +} from "../application/operations/input.js"; +import { runInitWorkspace } from "../application/operations/init.js"; +import { checkOwnership } from "../application/operations/ownership.js"; +import { runPlan } from "../application/operations/plan.js"; +import { runRefresh } from "../application/operations/refresh.js"; +import { + executePreparedRelease, + prepareRelease, + type PreparedReleaseExecution, + type ReleaseConfirmationProof, +} from "../application/operations/release.js"; +import { listState, removeStateEntry } from "../application/operations/state.js"; +import { runUseResource } from "../application/operations/use.js"; +import { PreparedOperationStore, InMemoryMutationLock } from "../application/prepared-operation-store.js"; +import type { OperationObserver } from "../application/ports.js"; +import { resolveProject as resolveApplicationProject } from "../application/project.js"; +import { + operationCatalog, + type OperationDefinition, + type OperationHttpProjection, +} from "../operations/catalog.js"; +import { VERSION } from "../version.js"; +import { generateOpenApi } from "./openapi.js"; +import { OperationRunStore } from "./runs.js"; +import { renderScalarDocs } from "./scalar-docs.js"; +import { RateLimiter, SessionManager, type ApiSession } from "./session.js"; +import { WorkspaceRegistry, type Workspace } from "./workspaces.js"; + +const API_VERSION = "v1"; +const DEFAULT_BODY_LIMIT = 1024 * 1024; + +interface MatchedRoute { + definition: OperationDefinition; + projection: OperationHttpProjection; + params: Record; +} + +interface HandlerResult { + status?: number; + body: unknown; + headers?: Record; +} + +export interface ApiServerOptions { + workspaceRoots: readonly string[]; + allowedOrigins?: readonly string[]; + secureTransport?: boolean; + bodyLimitBytes?: number; + rateLimitPerMinute?: number; + sessions?: SessionManager; + runs?: OperationRunStore; + /** Trusted, operator-installed generator. Never selected or uploaded by an API client. */ + generator?: ProcessInputGenerator; + audit?: (event: ApiAuditEvent) => void; +} + +export interface ApiAuditEvent { + requestId: string; + timestamp: string; + remoteAddress: string; + method: string; + path: string; + operation: string | null; + sessionId: string | null; + status: number; +} + +export interface CtApiServer { + server: Server; + pairingCode: string; + pairingExpiresAt: string; + workspaces: readonly Workspace[]; + openapi: Record; +} + +function compileRoute(path: string): RegExp { + const escaped = path + .split("/") + .map((part) => (part.startsWith(":") ? "([^/]+)" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))) + .join("/"); + return new RegExp(`^${escaped}/?$`); +} + +function matchRoute(method: string, pathname: string): MatchedRoute | null { + for (const definition of operationCatalog) { + for (const projection of definition.http ?? []) { + if (projection.method !== method) continue; + const match = compileRoute(projection.path).exec(pathname); + if (!match) continue; + const names = [...projection.path.matchAll(/:([A-Za-z0-9_]+)/g)].map((item) => item[1]!); + return { + definition, + projection, + params: Object.fromEntries(names.map((name, index) => [name, decodeURIComponent(match[index + 1]!)])), + }; + } + } + return null; +} + +async function readJson(request: IncomingMessage, limit: number): Promise> { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > limit) throw new CtApplicationError("REQUEST_TOO_LARGE", "Request body is too large."); + chunks.push(buffer); + } + if (chunks.length === 0) return {}; + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("JSON request body must be an object."); + } + return parsed as Record; +} + +function cookieToken(request: IncomingMessage): string | undefined { + const raw = request.headers.cookie; + if (!raw) return undefined; + for (const pair of raw.split(";")) { + const [name, ...value] = pair.trim().split("="); + if (name === "ct_session") return value.join("="); + } + return undefined; +} + +function bearerToken(request: IncomingMessage): string | undefined { + const authorization = request.headers.authorization; + if (authorization?.startsWith("Bearer ")) return authorization.slice(7).trim(); + return cookieToken(request); +} + +function problem(error: unknown, requestId: string): { status: number; body: Record } { + let status = 400; + let code = "INVALID_REQUEST"; + let detail = error instanceof Error ? error.message : "Request failed."; + let details: unknown; + if (error instanceof SyntaxError) { + code = "INVALID_JSON"; + detail = "Request body is not valid JSON."; + } else if (error instanceof CtApplicationError) { + code = error.code; + details = error.details; + if (error.code === "AUTH_REQUIRED") status = 401; + else if (error.code === "OPERATION_EXPIRED") status = 410; + else if (error.code === "REQUEST_TOO_LARGE") status = 413; + else if ( + [ + "MUTATION_BUSY", + "OPERATION_ALREADY_USED", + "PLAN_CONFIRMATION_MISMATCH", + "PREVENT_DESTROY", + "IDEMPOTENCY_CONFLICT", + "EXTERNAL_REFERENCE_BLOCKED", + "EXTERNAL_CONFIRMATION_REQUIRED", + "STATE_RELEASE_CONFIRMATION_REQUIRED", + ].includes(error.code) + ) + status = 409; + } else if (error instanceof Error && error.name === "Forbidden") { + status = 403; + code = "CAPABILITY_DENIED"; + } else if ( + typeof error === "object" && + error !== null && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + status = 404; + code = "NOT_FOUND"; + detail = "Requested resource was not found."; + } + return { + status, + body: { + type: `https://github.com/eqrm/ct-cli/blob/main/docs/rest-api.md#${code.toLowerCase()}`, + title: code.replaceAll("_", " ").toLowerCase(), + status, + code, + detail, + requestId, + ...(details ? { details } : {}), + }, + }; +} + +function sendJson( + response: ServerResponse, + status: number, + body: unknown, + headers: Record = {}, +): void { + const payload = `${JSON.stringify(body)}\n`; + response.writeHead(status, { + "Content-Type": status >= 400 ? "application/problem+json" : "application/json", + "Content-Length": Buffer.byteLength(payload), + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", + "Referrer-Policy": "no-referrer", + ...headers, + }); + response.end(payload); +} + +function sendScalarDocs(response: ServerResponse): void { + const nonce = randomBytes(18).toString("base64"); + const payload = renderScalarDocs(nonce); + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Content-Length": Buffer.byteLength(payload), + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": [ + "default-src 'none'", + `script-src 'nonce-${nonce}' https://cdn.jsdelivr.net`, + "style-src 'unsafe-inline'", + "img-src data: https:", + "font-src data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'none'", + "form-action 'none'", + ].join("; "), + "Referrer-Policy": "no-referrer", + }); + response.end(payload); +} + +function successBody(operation: string, requestId: string, result: unknown): unknown { + if (operation === "system.openapi") return result; + return { apiVersion: API_VERSION, requestId, operation, result }; +} + +function string(body: Record, name: string): string | undefined { + const value = body[name]; + return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined; +} + +function bool(body: Record, name: string): boolean | undefined { + return typeof body[name] === "boolean" ? (body[name] as boolean) : undefined; +} + +function strings(body: Record, name: string): string[] | undefined { + const value = body[name]; + return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined; +} + +function confirmationProof(value: unknown): ConfirmationProof | undefined { + if (value === undefined) return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("confirmation must be an object."); + } + const proof = value as Record; + if (proof.type === "yes") return { type: "yes" }; + if (proof.type === "environment" && typeof proof.value === "string") { + return { type: "environment", value: proof.value }; + } + throw new Error("confirmation must be {type: yes} or {type: environment, value: name}."); +} + +function releaseConfirmationProof(value: unknown): ReleaseConfirmationProof | undefined { + if (value === undefined) return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("confirmation must be an object."); + } + const proof = value as Record; + if ((proof.type === "environment" || proof.type === "key") && typeof proof.value === "string") { + return { type: proof.type, value: proof.value }; + } + throw new Error("confirmation must be {type: environment|key, value: string}."); +} + +function stableJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(",")}}`; +} + +function assertCapabilities(session: ApiSession, definition: OperationDefinition): void { + const missing = definition.capabilities.filter((capability) => !session.capabilities.has(capability)); + if (missing.length > 0) { + const error = new Error(`Session lacks required capabilities: ${missing.join(", ")}.`); + error.name = "Forbidden"; + throw error; + } +} + +function matchesSchema(value: unknown, schema: Record): boolean { + const expected = schema.type; + if (expected === "array") { + if (!Array.isArray(value)) return false; + const items = schema.items; + return ( + typeof items !== "object" || + items === null || + value.every((item) => matchesSchema(item, items as Record)) + ); + } + if (expected === "object") { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + } else if (typeof expected === "string" && typeof value !== expected) { + return false; + } + if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false; + if (typeof value === "string" && typeof schema.pattern === "string") { + return new RegExp(schema.pattern).test(value); + } + return true; +} + +function validateTransportInput( + matched: MatchedRoute, + body: Record, + request: IncomingMessage, + url: URL, +): void { + for (const parameter of matched.definition.parameters) { + if ( + !parameter.http || + (parameter.actions && !parameter.actions.includes(matched.projection.action ?? "")) + ) { + continue; + } + const externalName = parameter.http.name ?? parameter.name; + const value = + parameter.http.in === "body" + ? body[parameter.name] + : parameter.http.in === "path" + ? matched.params[parameter.name] + : parameter.http.in === "query" + ? (url.searchParams.get(externalName) ?? undefined) + : request.headers[externalName.toLowerCase()]; + if (value === undefined) { + if (parameter.required) throw new Error(`${parameter.name} is required.`); + continue; + } + if (!matchesSchema(value, parameter.schema)) { + throw new Error(`${parameter.name} does not match its operation schema.`); + } + } +} + +async function requestProject( + workspace: Workspace, + registry: WorkspaceRegistry, + body: Record, +): Promise<{ cwd: string; environment?: string; configPath?: string; statePath?: string }> { + const config = string(body, "configPath"); + const state = string(body, "statePath"); + return { + cwd: workspace.path, + environment: string(body, "environment"), + ...(config ? { configPath: await registry.resolveSafeWithin(workspace, config) } : {}), + ...(state ? { statePath: await registry.resolveSafeWithin(workspace, state) } : {}), + }; +} + +export async function createCtApiServer(options: ApiServerOptions): Promise { + const workspaces = await WorkspaceRegistry.create(options.workspaceRoots); + const sessions = options.sessions ?? new SessionManager(); + const runs = options.runs ?? new OperationRunStore(); + const rateLimiter = new RateLimiter(options.rateLimitPerMinute); + const allowedOrigins = new Set(options.allowedOrigins ?? []); + const openapi = generateOpenApi(); + const applyStore = new PreparedOperationStore(); + const destroyStore = new PreparedOperationStore(); + const releaseStore = new PreparedOperationStore(); + const loginContinuationStore = new PreparedOperationStore(); + const mutationLock = new InMemoryMutationLock(); + const idempotency = new Map(); + const preparedWorkspace = new Map(); + + const server = createServer(async (request, response) => { + const requestId = randomUUID(); + const auditContext = { operation: null as string | null, sessionId: null as string | null }; + response.once("finish", () => { + options.audit?.({ + requestId, + timestamp: new Date().toISOString(), + remoteAddress: request.socket.remoteAddress ?? "unknown", + method: request.method ?? "GET", + path: new URL(request.url ?? "/", "http://ct.local").pathname, + operation: auditContext.operation, + sessionId: auditContext.sessionId, + status: response.statusCode, + }); + }); + response.setHeader("X-Request-Id", requestId); + try { + const remote = request.socket.remoteAddress ?? "unknown"; + if (!rateLimiter.allow(remote)) { + sendJson( + response, + 429, + { + ...problem(new Error("Rate limit exceeded."), requestId).body, + status: 429, + code: "RATE_LIMITED", + }, + { "Retry-After": "60" }, + ); + return; + } + const origin = request.headers.origin; + if (origin && !allowedOrigins.has(origin)) { + const denied = problem(new Error("Origin is not allowlisted."), requestId); + sendJson(response, 403, { ...denied.body, status: 403, code: "ORIGIN_DENIED" }); + return; + } + if (origin) { + response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader("Vary", "Origin"); + } + if (request.method === "OPTIONS") { + response.writeHead(204, { + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type, Idempotency-Key, Prefer", + "Access-Control-Max-Age": "600", + }); + response.end(); + return; + } + const url = new URL(request.url ?? "/", "http://ct.local"); + const matched = matchRoute(request.method ?? "GET", url.pathname); + if (!matched) { + sendJson(response, 404, { + ...problem(new Error("No route matches this request."), requestId).body, + status: 404, + code: "NOT_FOUND", + }); + return; + } + auditContext.operation = matched.definition.id; + + const isPublic = matched.definition.capabilities.length === 0; + const session = isPublic ? null : sessions.authenticate(bearerToken(request)); + auditContext.sessionId = session?.id ?? null; + if (!isPublic && !session) { + sendJson( + response, + 401, + { + ...problem(new Error("A valid paired session is required."), requestId).body, + status: 401, + code: "SESSION_REQUIRED", + }, + { "WWW-Authenticate": "Bearer" }, + ); + return; + } + if (session) assertCapabilities(session, matched.definition); + + if (matched.definition.id === "system.docs") { + sendScalarDocs(response); + return; + } + + const body = ["POST", "DELETE"].includes(request.method ?? "") + ? await readJson(request, options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT) + : {}; + validateTransportInput(matched, body, request, url); + + if (matched.definition.id === "run.events") { + const run = runs.get(matched.params.runId!); + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + Connection: "keep-alive", + }); + for (const event of run.events) response.write(`event: progress\ndata: ${JSON.stringify(event)}\n\n`); + response.write( + `event: status\ndata: ${JSON.stringify({ status: run.status, result: run.result, error: run.error })}\n\n`, + ); + response.end(); + return; + } + + const invoke = async (observer: OperationObserver): Promise => { + const id = matched.definition.id; + if (id === "system.health") return { body: { status: "ok", version: VERSION } }; + if (id === "system.capabilities") { + return { + body: { + operations: operationCatalog.map((operation) => ({ + id: operation.id, + mutation: operation.mutation, + longRunning: operation.longRunning, + capabilities: operation.capabilities, + cli: operation.cli, + http: operation.http, + })), + }, + }; + } + if (id === "system.openapi") return { body: openapi }; + if (id === "session.pair") { + const code = string(body, "code"); + if (!code) throw new Error("Pairing code is required."); + const requested = strings(body, "capabilities"); + const paired = sessions.pair(code, requested); + return { + body: { + session: { + id: paired.session.id, + capabilities: [...paired.session.capabilities], + expiresAt: paired.session.expiresAt.toISOString(), + }, + token: paired.token, + }, + headers: { + "Set-Cookie": `ct_session=${paired.token}; HttpOnly; SameSite=Strict; Path=/api/v1; Max-Age=28800${options.secureTransport ? "; Secure" : ""}`, + }, + }; + } + if (id === "workspace.list") { + return { body: { operation: id, workspaces: workspaces.workspaces } }; + } + if (id === "run.get") return { body: runs.get(matched.params.runId!) }; + if (id === "run.cancel") return { body: runs.cancel(matched.params.runId!) }; + if (id === "workspace.init") { + const directory = string(body, "directory"); + if (!directory) throw new Error("directory is required."); + const rootId = string(body, "rootId"); + const target = rootId + ? await workspaces.resolveSafeWithin(workspaces.get(rootId), directory) + : await workspaces.resolveAnySafe(directory); + return { + status: 201, + body: await runInitWorkspace({ + directory: target, + template: string(body, "template"), + host: string(body, "host"), + environment: string(body, "environment"), + protected: bool(body, "protected"), + git: bool(body, "git"), + yes: true, + }), + }; + } + + const workspaceId = matched.params.workspaceId; + if (!workspaceId) throw new Error("Workspace route is missing workspaceId."); + const workspace = workspaces.get(workspaceId); + const runtimeEnv: NodeJS.ProcessEnv = { ...process.env }; + const project = await requestProject(workspace, workspaces, body); + const projectDependencies = { env: runtimeEnv, cwd: () => workspace.path }; + const sessionForProject = () => authedSession(runtimeEnv); + const workspacePath = async (name: string): Promise => { + const value = string(body, name); + return value ? workspaces.resolveSafeWithin(workspace, value) : undefined; + }; + if ( + [ + "plan", + "apply", + "coverage", + "refresh", + "state.list", + "state.remove", + "destroy", + "adopt.resource", + "adopt.groups", + "adopt.grants", + "use.resource", + "release.managed", + "release.external", + ].includes(id) + ) { + const resolved = await resolveApplicationProject(project, projectDependencies); + project.configPath = await workspaces.resolveSafeWithin(workspace, resolved.configPath); + project.statePath = await workspaces.resolveSafeWithin(workspace, resolved.statePath); + } + const generatedConfig = async (digest: string) => { + if (!options.generator) { + throw new Error("No trusted process-input generator is configured on this server."); + } + const snapshot = (await getInputSnapshot(workspace.path, digest)).value; + if (!options.generator.supportedSchemaVersions.includes(snapshot.schemaVersion)) { + throw new Error( + `Generator ${options.generator.id} does not support schema ${snapshot.schemaVersion}.`, + ); + } + const validation = await options.generator.validate(snapshot); + if (!validation.valid) { + throw new Error( + `Process input rejected by ${options.generator.id}: ${validation.errors + .map((item) => `${item.path} ${item.message}`) + .join(", ")}`, + ); + } + return options.generator.generate(snapshot); + }; + + if (id === "environment.list") { + return { body: await listEnvironments({ cwd: workspace.path }, { env: runtimeEnv }) }; + } + if (id === "auth.status") { + return { + body: await runAuthStatus( + { cwd: workspace.path, environment: project.environment, all: bool(body, "all") }, + { + env: runtimeEnv, + project: projectDependencies, + readToken: (host) => readToken(host, runtimeEnv), + authedSession: sessionForProject, + }, + ), + }; + } + if (id === "auth.login") { + if ( + !options.secureTransport && + !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(request.socket.localAddress ?? "") + ) { + throw new Error("Remote credential submission requires HTTPS."); + } + let host = string(body, "host"); + let token = string(body, "token"); + const continuationId = string(body, "continuationId"); + if (continuationId) { + const totp = string(body, "totp"); + if (!totp) throw new Error("totp is required with continuationId."); + const continuation = loginContinuationStore.take(continuationId); + host = continuation.host; + token = await continuePasswordLogin(continuation, totp); + } else if (!token) { + if (!host) throw new Error("host is required."); + const username = string(body, "username"); + const password = string(body, "password"); + if (!username || !password) throw new Error("Provide token, or username and password."); + const started = await beginPasswordLogin(host, username, password); + if (started.kind === "totp") { + const stored = loginContinuationStore.put(started.continuation, 5 * 60_000); + return { + status: 202, + body: { + operation: "auth", + action: "login", + status: "totp-required", + continuationId: stored.id, + expiresAt: stored.expiresAt!.toISOString(), + }, + }; + } + token = started.token; + } + if (!host) throw new Error("host is required."); + return { body: await runAuthLogin({ host, token }) }; + } + if (id === "auth.logout") { + return { body: await runAuthLogout({ cwd: workspace.path, environment: project.environment }) }; + } + if (id === "input.validate") { + const base = validateProcessInput(body.document); + if (!base.valid || !options.generator) return { body: base }; + const document = body.document as ProcessInputDocument; + if (!options.generator.supportedSchemaVersions.includes(document.schemaVersion)) { + return { + body: { + ...base, + valid: false, + errors: [{ path: "/schemaVersion", message: `is not supported by ${options.generator.id}` }], + generator: options.generator.id, + }, + }; + } + const validation = await options.generator.validate(document); + return { body: { ...base, ...validation, generator: options.generator.id } }; + } + if (id === "input.snapshot") { + const document = body.document as ProcessInputDocument | undefined; + if (!document) throw new Error("document is required."); + return { + status: 201, + body: await createInputSnapshot({ ...document, cwd: workspace.path, persist: true }), + }; + } + if (id === "input.list") return { body: await listInputSnapshots(workspace.path) }; + if (id === "input.get") + return { body: await getInputSnapshot(workspace.path, matched.params.digest!) }; + if (id === "plan") { + const snapshotDigest = string(body, "snapshotDigest"); + const generated = snapshotDigest ? await generatedConfig(snapshotDigest) : null; + return { + body: await runPlan(project, { + project: projectDependencies, + authedSession: sessionForProject, + observer, + ...(generated ? { loadConfig: async () => ({ ...generated, configDir: workspace.path }) } : {}), + }), + }; + } + if (id === "apply" && matched.projection.action === "prepare") { + const snapshotDigest = string(body, "snapshotDigest"); + const generated = snapshotDigest ? await generatedConfig(snapshotDigest) : null; + const prepared = await prepareApply( + { ...project, backupDir: await workspacePath("backupDir"), refresh: bool(body, "refresh") }, + { + project: projectDependencies, + authedSession: sessionForProject, + observer, + store: applyStore, + lock: mutationLock, + env: runtimeEnv, + ...(generated ? { loadConfig: async () => ({ ...generated, configDir: workspace.path }) } : {}), + }, + ); + preparedWorkspace.set(prepared.id, { workspaceId, ...(snapshotDigest ? { snapshotDigest } : {}) }); + return { status: 201, body: { ...prepared, snapshotDigest: snapshotDigest ?? null } }; + } + if (id === "apply" && matched.projection.action === "execute") { + const operationId = matched.params.operationId!; + const binding = preparedWorkspace.get(operationId); + if (!binding || binding.workspaceId !== workspaceId) + throw new Error("Prepared apply does not belong to this workspace."); + if (binding.snapshotDigest) await getInputSnapshot(workspace.path, binding.snapshotDigest); + const result = await executePreparedApply( + { id: operationId }, + confirmationProof(body.confirmation), + { + store: applyStore, + lock: mutationLock, + observer, + env: runtimeEnv, + }, + ); + preparedWorkspace.delete(operationId); + return { body: result }; + } + if (id === "coverage") { + return { + body: await runCoverage( + { + ...project, + type: string(body, "type"), + declarable: bool(body, "declarable"), + blocked: bool(body, "blocked"), + }, + { project: projectDependencies, authedSession: sessionForProject, observer }, + ), + }; + } + if (id === "refresh") { + return { + body: await runRefresh( + { ...project, group: string(body, "group"), all: bool(body, "all") }, + { project: projectDependencies, authedSession: sessionForProject, observer }, + ), + }; + } + if (id === "state.list") { + return { body: await listState(project, { project: projectDependencies }) }; + } + if (id === "state.remove") { + return { + body: await removeStateEntry( + { + ...project, + type: matched.params.type!, + key: matched.params.key!, + force: bool(body, "force"), + dryRun: bool(body, "dryRun"), + }, + { project: projectDependencies, lock: mutationLock }, + ), + }; + } + if (id === "destroy" && matched.projection.action === "prepare") { + const prepared = await prepareDestroy( + { + ...project, + targets: strings(body, "targets"), + memberFields: strings(body, "memberFields"), + backupDir: await workspacePath("backupDir"), + }, + { + project: projectDependencies, + authedSession: sessionForProject, + observer, + store: destroyStore, + lock: mutationLock, + env: runtimeEnv, + }, + ); + preparedWorkspace.set(prepared.id, { workspaceId }); + return { status: 201, body: prepared }; + } + if (id === "destroy" && matched.projection.action === "execute") { + const operationId = matched.params.operationId!; + const binding = preparedWorkspace.get(operationId); + if (!binding || binding.workspaceId !== workspaceId) + throw new Error("Prepared destroy does not belong to this workspace."); + const result = await executePreparedDestroy( + { id: operationId }, + confirmationProof(body.confirmation), + { store: destroyStore, lock: mutationLock, observer, env: runtimeEnv }, + ); + preparedWorkspace.delete(operationId); + return { body: result }; + } + if (id === "adopt.resource") { + return { + body: await runAdoptResource( + { + ...project, + type: string(body, "type")!, + id: string(body, "id")!, + key: string(body, "key"), + rekey: bool(body, "rekey"), + dryRun: bool(body, "dryRun"), + }, + { project: projectDependencies, authedSession: sessionForProject }, + ), + }; + } + if (id === "adopt.groups") { + return { + body: await runAdoptGroups( + { + ...project, + ids: strings(body, "ids") ?? [], + groupType: string(body, "groupType"), + childrenOf: string(body, "childrenOf"), + dryRun: bool(body, "dryRun"), + }, + { + project: projectDependencies, + authedSession: sessionForProject, + lock: mutationLock, + observer, + }, + ), + }; + } + if (id === "adopt.grants") { + return { + body: await runAdoptGrants( + { + ...project, + domainType: string(body, "domainType"), + domainId: string(body, "domainId"), + group: string(body, "group"), + allDeclarable: bool(body, "allDeclarable"), + }, + { project: projectDependencies, authedSession: sessionForProject, observer }, + ), + }; + } + if (id === "use.resource") { + return { + body: await runUseResource( + { + ...project, + type: string(body, "type")!, + id: string(body, "id")!, + key: string(body, "key")!, + owner: string(body, "owner"), + acceptChanges: bool(body, "acceptChanges"), + dryRun: bool(body, "dryRun"), + }, + { + project: projectDependencies, + authedSession: sessionForProject, + lock: mutationLock, + }, + ), + }; + } + if ( + (id === "release.managed" || id === "release.external") && + matched.projection.action === "prepare" + ) { + const prepared = await prepareRelease( + { + ...project, + type: matched.params.type!, + key: matched.params.key!, + kind: id === "release.managed" ? "managed" : "external", + force: bool(body, "force"), + }, + { project: projectDependencies, store: releaseStore, lock: mutationLock }, + ); + preparedWorkspace.set(prepared.id, { workspaceId }); + return { status: 201, body: prepared }; + } + if ( + (id === "release.managed" || id === "release.external") && + matched.projection.action === "execute" + ) { + const operationId = matched.params.operationId!; + const binding = preparedWorkspace.get(operationId); + if (!binding || binding.workspaceId !== workspaceId) { + throw new Error("Prepared release does not belong to this workspace."); + } + const result = await executePreparedRelease( + { id: operationId }, + releaseConfirmationProof(body.confirmation), + { project: projectDependencies, store: releaseStore, lock: mutationLock }, + ); + preparedWorkspace.delete(operationId); + return { body: result }; + } + if (id === "ownership.check") { + const root = string(body, "root")!; + const environment = string(body, "environment")!; + return { + body: await checkOwnership({ + root: await workspaces.resolveSafeWithin(workspace, root), + environment, + cwd: workspace.path, + }), + }; + } + throw new Error(`No handler for operation ${id}.`); + }; + + const idempotencyKey = request.headers["idempotency-key"]; + const cacheKey = + matched.definition.mutation && typeof idempotencyKey === "string" && session + ? `${session.id}:${matched.definition.id}:${matched.projection.action ?? "run"}:${idempotencyKey}` + : null; + const requestDigest = createHash("sha256") + .update(`${request.method}\0${url.pathname}\0${stableJson(body)}`) + .digest("hex"); + const cached = cacheKey ? idempotency.get(cacheKey) : undefined; + if (cached) { + if (cached.requestDigest !== requestDigest) { + throw new CtApplicationError( + "IDEMPOTENCY_CONFLICT", + "The Idempotency-Key was already used with a different request.", + ); + } + sendJson( + response, + cached.result.status ?? 200, + successBody(matched.definition.id, requestId, cached.result.body), + { + ...cached.result.headers, + "Idempotency-Replayed": "true", + }, + ); + return; + } + + const { run, observer } = runs.create(matched.definition.id); + if (matched.definition.longRunning && request.headers.prefer === "respond-async") { + const accepted: HandlerResult = { + status: 202, + body: { operation: matched.definition.id, runId: run.id, status: "running" }, + headers: { Location: `/api/v1/runs/${run.id}` }, + }; + if (cacheKey) idempotency.set(cacheKey, { requestDigest, result: accepted }); + sendJson( + response, + accepted.status!, + successBody(matched.definition.id, requestId, accepted.body), + accepted.headers, + ); + void invoke(observer).then( + (result) => runs.succeed(run.id, result.body), + (error) => runs.fail(run.id, problem(error, requestId).body), + ); + return; + } + const result = await invoke(observer); + runs.succeed(run.id, result.body); + result.headers = { ...result.headers, "X-Operation-Run-Id": run.id }; + if (cacheKey) idempotency.set(cacheKey, { requestDigest, result }); + sendJson( + response, + result.status ?? matched.projection.successStatus ?? 200, + successBody(matched.definition.id, requestId, result.body), + result.headers, + ); + } catch (error) { + const output = problem(error, requestId); + sendJson(response, output.status, { ...output.body, status: output.status }); + } + }); + + return { + server, + pairingCode: sessions.pairingCode, + pairingExpiresAt: sessions.pairingExpiresAt.toISOString(), + workspaces: workspaces.workspaces, + openapi, + }; +} diff --git a/src/server/generator.ts b/src/server/generator.ts new file mode 100644 index 0000000..42833ab --- /dev/null +++ b/src/server/generator.ts @@ -0,0 +1,29 @@ +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ProcessInputGenerator } from "../application/operations/input.js"; + +function isGenerator(value: unknown): value is ProcessInputGenerator { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + Array.isArray(candidate.supportedSchemaVersions) && + typeof candidate.validate === "function" && + typeof candidate.generate === "function" + ); +} + +/** Load only the module path explicitly configured by the server operator. */ +export async function loadTrustedProcessGenerator(path: string): Promise { + const module = (await import(pathToFileURL(resolve(path)).href)) as { + default?: unknown; + generator?: unknown; + }; + const generator = module.default ?? module.generator; + if (!isGenerator(generator)) { + throw new Error( + `Trusted generator ${path} must export { id, supportedSchemaVersions, validate(), generate() }.`, + ); + } + return generator; +} diff --git a/src/server/openapi.ts b/src/server/openapi.ts new file mode 100644 index 0000000..2a136f7 --- /dev/null +++ b/src/server/openapi.ts @@ -0,0 +1,139 @@ +import { operationCatalog, type JsonSchema, type OperationDefinition } from "../operations/catalog.js"; +import { VERSION } from "../version.js"; + +function openApiPath(path: string): string { + return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); +} + +function routeParameters(definition: OperationDefinition, routePath: string): Record[] { + const parameters: Record[] = []; + const pathNames = [...routePath.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1]!); + for (const name of pathNames) { + const declared = definition.parameters.find((parameter) => parameter.name === name); + parameters.push({ + name, + in: "path", + required: true, + description: declared?.description, + schema: declared?.schema ?? { type: "string" }, + }); + } + for (const parameter of definition.parameters) { + if (!parameter.http || parameter.http.in === "body" || parameter.http.in === "path") continue; + parameters.push({ + name: parameter.http.name ?? parameter.name, + in: parameter.http.in, + required: parameter.required ?? false, + description: parameter.description, + schema: parameter.schema, + }); + } + return parameters; +} + +function requestBody( + definition: OperationDefinition, + action: string | undefined, +): Record | undefined { + const body = definition.parameters.filter( + (parameter) => + parameter.http?.in === "body" && (!parameter.actions || parameter.actions.includes(action ?? "")), + ); + if (body.length === 0) return undefined; + const properties = Object.fromEntries(body.map((parameter) => [parameter.name, parameter.schema])); + const required = body.filter((parameter) => parameter.required).map((parameter) => parameter.name); + const schema: JsonSchema = { type: "object", properties, additionalProperties: false }; + if (required.length > 0) schema.required = required; + return { required: true, content: { "application/json": { schema } } }; +} + +export function generateOpenApi( + catalog: readonly OperationDefinition[] = operationCatalog, +): Record { + const paths: Record> = {}; + for (const definition of catalog) { + for (const route of definition.http ?? []) { + const path = openApiPath(route.path); + const item = (paths[path] ??= {}); + const body = requestBody(definition, route.action); + const isPublic = definition.capabilities.length === 0; + const responseSchema = + definition.id === "system.openapi" + ? definition.resultSchema + : { + type: "object", + required: ["apiVersion", "requestId", "operation", "result"], + properties: { + apiVersion: { type: "string", const: "v1" }, + requestId: { type: "string" }, + operation: { type: "string", const: definition.id }, + result: definition.resultSchema, + }, + additionalProperties: false, + }; + const responseMediaType = route.responseMediaType ?? "application/json"; + item[route.method.toLowerCase()] = { + operationId: route.action ? `${definition.id}.${route.action}` : definition.id, + summary: definition.summary, + ...(definition.description ? { description: definition.description } : {}), + tags: [definition.id.split(".")[0]], + parameters: routeParameters(definition, route.path), + ...(body ? { requestBody: body } : {}), + security: isPublic ? [] : [{ bearerSession: [] }, { cookieSession: [] }], + responses: { + [String(route.successStatus ?? 200)]: { + description: "Successful operation", + content: { [responseMediaType]: { schema: responseSchema } }, + }, + "400": { $ref: "#/components/responses/Problem" }, + "401": { $ref: "#/components/responses/Problem" }, + "403": { $ref: "#/components/responses/Problem" }, + "409": { $ref: "#/components/responses/Problem" }, + "429": { $ref: "#/components/responses/Problem" }, + }, + "x-ct-capabilities": definition.capabilities, + "x-ct-long-running": definition.longRunning, + }; + } + } + return { + openapi: "3.1.0", + info: { + title: "ct-cli Extension API", + version: `1.0.0+ct.${VERSION}`, + description: + "Versioned transport projection of ct-cli application operations. Most CLI users continue to use ct.config.ts directly. Process-input snapshots are an optional browser-form workflow: immutable JSON input is selected by its SHA-256 digest, translated by an operator-installed trusted generator, and then processed by the same plan/apply engine.", + }, + // Catalog routes already carry their full versioned path. Keeping the server at the origin + // avoids clients composing URLs such as /api/v1/api/v1/health. + servers: [{ url: "/" }], + paths, + components: { + securitySchemes: { + bearerSession: { type: "http", scheme: "bearer" }, + cookieSession: { type: "apiKey", in: "cookie", name: "ct_session" }, + }, + schemas: { + Problem: { + type: "object", + required: ["type", "title", "status", "code", "detail", "requestId"], + properties: { + type: { type: "string", format: "uri-reference" }, + title: { type: "string" }, + status: { type: "integer" }, + code: { type: "string" }, + detail: { type: "string" }, + requestId: { type: "string" }, + details: { type: "object", additionalProperties: true }, + }, + }, + }, + responses: { + Problem: { + description: "Stable structured error", + content: { "application/problem+json": { schema: { $ref: "#/components/schemas/Problem" } } }, + }, + }, + }, + }; +} diff --git a/src/server/runs.ts b/src/server/runs.ts new file mode 100644 index 0000000..072780e --- /dev/null +++ b/src/server/runs.ts @@ -0,0 +1,74 @@ +import { randomUUID } from "node:crypto"; +import type { OperationEvent } from "../application/contracts.js"; +import type { OperationObserver } from "../application/ports.js"; + +export type RunStatus = "running" | "succeeded" | "failed" | "cancelled"; + +export interface OperationRun { + id: string; + operation: string; + status: RunStatus; + events: OperationEvent[]; + result?: unknown; + error?: unknown; + createdAt: string; + completedAt?: string; +} + +export class OperationRunStore { + private readonly runs = new Map(); + private readonly controllers = new Map(); + + create(operation: string): { run: OperationRun; observer: OperationObserver } { + const run: OperationRun = { + id: randomUUID(), + operation, + status: "running", + events: [], + createdAt: new Date().toISOString(), + }; + this.runs.set(run.id, run); + const controller = new AbortController(); + this.controllers.set(run.id, controller); + return { + run, + observer: { + emit: (event) => { + if (controller.signal.aborted) throw new DOMException("Operation was cancelled.", "AbortError"); + run.events.push(event); + }, + }, + }; + } + + get(id: string): OperationRun { + const run = this.runs.get(id); + if (!run) throw new Error(`Unknown operation run "${id}".`); + return run; + } + + succeed(id: string, result: unknown): void { + const run = this.get(id); + if (run.status === "cancelled") return; + run.status = "succeeded"; + run.result = result; + run.completedAt = new Date().toISOString(); + } + + fail(id: string, error: unknown): void { + const run = this.get(id); + if (run.status === "cancelled") return; + run.status = "failed"; + run.error = error; + run.completedAt = new Date().toISOString(); + } + + cancel(id: string): OperationRun { + const run = this.get(id); + if (run.status !== "running") throw new Error(`Operation run is already ${run.status}.`); + run.status = "cancelled"; + run.completedAt = new Date().toISOString(); + this.controllers.get(id)?.abort(); + return run; + } +} diff --git a/src/server/scalar-docs.ts b/src/server/scalar-docs.ts new file mode 100644 index 0000000..76bb949 --- /dev/null +++ b/src/server/scalar-docs.ts @@ -0,0 +1,32 @@ +const SCALAR_VERSION = "1.67.0"; + +export function renderScalarDocs(nonce: string): string { + return ` + + + + + ct-cli Extension API + + +
+ + + + +`; +} diff --git a/src/server/session.ts b/src/server/session.ts new file mode 100644 index 0000000..e95c40b --- /dev/null +++ b/src/server/session.ts @@ -0,0 +1,90 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; + +export type SessionCapability = "read" | "plan" | "mutate" | "credentials"; + +export interface ApiSession { + id: string; + capabilities: ReadonlySet; + expiresAt: Date; +} + +const ALL_CAPABILITIES: readonly SessionCapability[] = ["read", "plan", "mutate", "credentials"]; + +function token(bytes = 32): string { + return randomBytes(bytes).toString("base64url"); +} + +export class SessionManager { + readonly pairingCode: string; + readonly pairingExpiresAt: Date; + private pairingUsed = false; + private readonly sessions = new Map(); + + constructor( + private readonly now: () => Date = () => new Date(), + pairingTtlMs = 5 * 60_000, + private readonly sessionTtlMs = 8 * 60 * 60_000, + ) { + this.pairingCode = String(Number.parseInt(randomBytes(4).toString("hex"), 16) % 1_000_000).padStart( + 6, + "0", + ); + this.pairingExpiresAt = new Date(this.now().getTime() + pairingTtlMs); + } + + pair(code: string, requested?: readonly string[]): { token: string; session: ApiSession } { + const supplied = Buffer.from(code); + const expected = Buffer.from(this.pairingCode); + const matches = supplied.length === expected.length && timingSafeEqual(supplied, expected); + if (this.pairingUsed || this.pairingExpiresAt.getTime() <= this.now().getTime() || !matches) { + throw new Error("Invalid or expired pairing code."); + } + const capabilities = new Set( + requested === undefined + ? ALL_CAPABILITIES + : requested.filter((value): value is SessionCapability => + ALL_CAPABILITIES.includes(value as SessionCapability), + ), + ); + if (capabilities.size === 0) throw new Error("Pairing requested no supported capabilities."); + this.pairingUsed = true; + const rawToken = token(); + const session: ApiSession = { + id: token(12), + capabilities, + expiresAt: new Date(this.now().getTime() + this.sessionTtlMs), + }; + this.sessions.set(rawToken, session); + return { token: rawToken, session }; + } + + authenticate(rawToken: string | undefined): ApiSession | null { + if (!rawToken) return null; + const session = this.sessions.get(rawToken); + if (!session) return null; + if (session.expiresAt.getTime() <= this.now().getTime()) { + this.sessions.delete(rawToken); + return null; + } + return session; + } +} + +export class RateLimiter { + private readonly buckets = new Map(); + + constructor( + private readonly limit = 120, + private readonly windowMs = 60_000, + ) {} + + allow(key: string, now = Date.now()): boolean { + const bucket = this.buckets.get(key); + if (!bucket || now - bucket.start >= this.windowMs) { + this.buckets.set(key, { start: now, count: 1 }); + return true; + } + bucket.count += 1; + return bucket.count <= this.limit; + } +} diff --git a/src/server/workspaces.ts b/src/server/workspaces.ts new file mode 100644 index 0000000..dd27d40 --- /dev/null +++ b/src/server/workspaces.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; +import { realpath, stat } from "node:fs/promises"; +import { basename, isAbsolute, relative, resolve, sep } from "node:path"; + +export interface Workspace { + id: string; + name: string; + path: string; +} + +function workspaceId(path: string): string { + return createHash("sha256").update(path).digest("hex").slice(0, 16); +} + +function contained(root: string, candidate: string): boolean { + const value = relative(root, candidate); + return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value)); +} + +export class WorkspaceRegistry { + readonly workspaces: readonly Workspace[]; + + private constructor(workspaces: Workspace[]) { + this.workspaces = workspaces; + } + + static async create(paths: readonly string[]): Promise { + if (paths.length === 0) throw new Error("Configure at least one workspace root."); + const unique = new Set(); + for (const path of paths) { + const normalized = await realpath(resolve(path)); + if (!(await stat(normalized)).isDirectory()) + throw new Error(`Workspace root is not a directory: ${path}`); + unique.add(normalized); + } + return new WorkspaceRegistry( + [...unique].map((path) => ({ id: workspaceId(path), name: basename(path) || path, path })), + ); + } + + get(id: string): Workspace { + const workspace = this.workspaces.find((candidate) => candidate.id === id); + if (!workspace) throw new Error(`Unknown workspace "${id}".`); + return workspace; + } + + resolveWithin(workspace: Workspace, input: string = "."): string { + const candidate = resolve(workspace.path, input); + if (!contained(workspace.path, candidate)) { + throw new Error(`Path escapes configured workspace ${workspace.name}.`); + } + return candidate; + } + + async resolveSafeWithin(workspace: Workspace, input: string = "."): Promise { + const candidate = this.resolveWithin(workspace, input); + let cursor = candidate; + while (true) { + try { + const physical = await realpath(cursor); + if (!contained(workspace.path, physical)) { + throw new Error(`Path escapes configured workspace ${workspace.name} through a symlink.`); + } + return candidate; + } catch (caught) { + if (!( + typeof caught === "object" && + caught !== null && + (caught as NodeJS.ErrnoException).code === "ENOENT" + )) { + throw caught; + } + const parent = resolve(cursor, ".."); + if (parent === cursor) throw new Error("Could not establish workspace path containment."); + cursor = parent; + } + } + } + + resolveAny(input: string): string { + const candidate = resolve(input); + if (!this.workspaces.some((workspace) => contained(workspace.path, candidate))) { + throw new Error("Target is outside every configured workspace root."); + } + return candidate; + } + + async resolveAnySafe(input: string): Promise { + const candidate = this.resolveAny(input); + const workspace = this.workspaces.find((entry) => contained(entry.path, candidate)); + if (!workspace) throw new Error("Target is outside every configured workspace root."); + return this.resolveSafeWithin(workspace, candidate); + } +} diff --git a/src/state/state.ts b/src/state/state.ts index 5311b83..67bf69b 100644 --- a/src/state/state.ts +++ b/src/state/state.ts @@ -1,9 +1,9 @@ /** - * The state file: the set of **explicitly managed** resources. + * The state file: explicitly managed ct-cli resources plus read-only external bindings. * - * Everything not in here is invisible to the tool — never shown, never changed, - * never proposed for deletion. It maps a logical key → CT id + the last-known - * snapshot of the fields we manage (the desired-state baseline for diffing). + * `resources` maps owned logical keys to ids and managed-field snapshots; + * `externals` maps consumer keys to ids and minimal hard-identity snapshots. Only + * the first partition participates in apply/destroy. * * The file belongs to the config repo (eqrm/ct-structure) and is meant to be * committed. Default path is `ct-state.json` in the cwd; override with @@ -43,11 +43,27 @@ export interface ManagedResource { memberFields?: Record; } +/** A read-only host binding. It is never part of desired/apply/destroy inputs. */ +export interface ExternalResource { + type: string; + id: number; + key: string; + /** Optional coordination hint; ownership checks derive the real owner from visible managed states. */ + owner?: string; + /** Minimal registry-defined hard identity. Display-only fields are deliberately not persisted. */ + identity: Record; + /** Creation time of this binding. Verification and identity acceptance never change it. */ + boundAt: string; +} + export interface State { - version: 1; + /** Version 1 is accepted on in-memory test/adapter inputs; files always load/save as version 2. */ + version: 1 | 2; host: string; /** Keyed by logical key (e.g. "mainz", "mainz_kids_lead"). */ resources: Record; + /** Keyed by the same globally unique logical-key namespace as {@link resources}. */ + externals?: Record; } export const DEFAULT_STATE_PATH = "ct-state.json"; @@ -66,7 +82,7 @@ export function resolveStatePath( } export function emptyState(host: string): State { - return { version: 1, host, resources: {} }; + return { version: 2, host, resources: {}, externals: {} }; } /** @@ -109,7 +125,7 @@ function validateState(parsed: unknown, path: string): State { throw new Error(`Malformed state file ${path}: expected a JSON object at the top level.`); } const obj = parsed as Record; - if (obj.version !== 1) { + if (obj.version !== 1 && obj.version !== 2) { throw new Error(`Unsupported state file version ${String(obj.version)} in ${path}`); } if (typeof obj.host !== "string" || obj.host === "") { @@ -118,9 +134,63 @@ function validateState(parsed: unknown, path: string): State { if (typeof obj.resources !== "object" || obj.resources === null || Array.isArray(obj.resources)) { throw new Error(`Malformed state file ${path}: "resources" must be an object.`); } + if ( + obj.version === 2 && + (typeof obj.externals !== "object" || obj.externals === null || Array.isArray(obj.externals)) + ) { + throw new Error(`Malformed state file ${path}: "externals" must be an object in version 2.`); + } + validateManagedEntries(obj.resources as Record, path); + if (obj.version === 2) validateExternalEntries(obj.externals as Record, path); return obj as unknown as State; } +function entryObject(value: unknown, label: string, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`Malformed state file ${path}: ${label} must be an object.`); + } + return value as Record; +} + +function validateCommonEntry( + key: string, + value: unknown, + label: string, + path: string, +): Record { + const entry = entryObject(value, label, path); + if (entry.key !== key) { + throw new Error(`Malformed state file ${path}: ${label}.key must equal its map key "${key}".`); + } + if (typeof entry.type !== "string" || entry.type === "") { + throw new Error(`Malformed state file ${path}: ${label}.type must be a non-empty string.`); + } + if (typeof entry.id !== "number" || !Number.isSafeInteger(entry.id) || entry.id < 0) { + throw new Error(`Malformed state file ${path}: ${label}.id must be a non-negative safe integer.`); + } + return entry; +} + +function validateManagedEntries(resources: Record, path: string): void { + for (const [key, value] of Object.entries(resources)) { + const entry = validateCommonEntry(key, value, `resources.${key}`, path); + entryObject(entry.fields, `resources.${key}.fields`, path); + } +} + +function validateExternalEntries(externals: Record, path: string): void { + for (const [key, value] of Object.entries(externals)) { + const entry = validateCommonEntry(key, value, `externals.${key}`, path); + entryObject(entry.identity, `externals.${key}.identity`, path); + if (typeof entry.boundAt !== "string" || entry.boundAt === "") { + throw new Error(`Malformed state file ${path}: externals.${key}.boundAt must be a non-empty string.`); + } + if (entry.owner !== undefined && (typeof entry.owner !== "string" || entry.owner === "")) { + throw new Error(`Malformed state file ${path}: externals.${key}.owner must be a non-empty string.`); + } + } +} + /** * In-place, version-preserving migrations for state loaded from disk. * @@ -149,11 +219,58 @@ function migrateState(state: State): State { delete fields.shortName; } } + if (state.version === 1) { + state.version = 2; + state.externals = {}; + } else if (!state.externals) { + state.externals = {}; + } + assertStateKeyUniqueness(state); return state; } export async function saveState(path: string, state: State): Promise { - await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + assertStateKeyUniqueness(state); + const persisted: State = { ...state, version: 2, externals: state.externals ?? {} }; + await writeFile(path, `${JSON.stringify(persisted, null, 2)}\n`, "utf8"); +} + +/** External entries, normalized for legacy in-memory callers. */ +export function externalResources(state: State): Record { + return state.externals ?? (state.externals = {}); +} + +/** Managed then external, matching the resolver's precedence. */ +export function findByKey(state: State, key: string): ManagedResource | ExternalResource | undefined { + return state.resources[key] ?? externalResources(state)[key]; +} + +export function findExternalByTypeId(state: State, type: string, id: number): ExternalResource | undefined { + return Object.values(externalResources(state)).find((r) => r.type === type && r.id === id); +} + +export function assertStateKeyUniqueness(state: State): void { + for (const key of Object.keys(externalResources(state))) { + if (state.resources[key]) { + throw new Error(`Logical key "${key}" is used by both a managed and an external entry.`); + } + } + const seen = new Map(); + for (const [kind, entries] of [ + ["managed", Object.values(state.resources)], + ["external", Object.values(externalResources(state))], + ] as const) { + for (const entry of entries) { + const identity = `${entry.type}\0${entry.id}`; + const prior = seen.get(identity); + if (prior) { + throw new Error( + `${entry.type} #${entry.id} appears more than once in state (${prior} and ${kind} "${entry.key}").`, + ); + } + seen.set(identity, `${kind} "${entry.key}"`); + } + } } /** Find a managed entry by CT type + id (id may legitimately be 0). */ @@ -219,6 +336,19 @@ export type UpsertAction = "created" | "updated"; * A key already taken by a *different* resource is a conflict, not an overwrite. */ export function upsert(state: State, input: UpsertInput, now: string): UpsertAction { + const externalCollision = externalResources(state)[input.key]; + if (externalCollision) { + throw new Error( + `Logical key "${input.key}" is already used by external ${externalCollision.type} #${externalCollision.id}. ` + + `Remove or rekey that binding first.`, + ); + } + const externalAlias = findExternalByTypeId(state, input.type, input.id); + if (externalAlias) { + throw new Error( + `${input.type} #${input.id} is already external as "${externalAlias.key}". Remove that binding before adopting it.`, + ); + } const existing = findByTypeId(state, input.type, input.id); const collision = state.resources[input.key]; if (collision && !(collision.type === input.type && collision.id === input.id)) { diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index cc6735f..3da63d6 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -49,6 +49,10 @@ function makeClient(childrenResponse: ChildrenResponse = "array") { // dispatcher with other tests exercising member statuses, and to prove it's never fetched for a // plain group adopt (see the assertion in the --with-dynamic capture test below). const campuses = [{ id: 0, name: "Mainz" }]; + const groupStatuses = [ + { id: 1, name: "active", nameTranslated: "Aktiv" }, + { id: 2, name: "pending", nameTranslated: "Entwurf" }, + ]; const memberStatuses = [{ id: 1, name: "Aktiv" }]; // Global role catalog (/group/roles), each row carrying its `groupTypeId` — used to portablize a // `role.id` groupTypeRoleId into a (group-type, role-name) marker (#76). Role 7 is a "Leiter" on @@ -137,7 +141,10 @@ function makeClient(childrenResponse: ChildrenResponse = "array") { m = /^\/groups\/(\d+)\/memberfields$/.exec(path); if (m) return memberFields[Number(m[1])] ?? []; if (path === "/group/grouptypes") return groupTypes; + if (path === "/group/grouptypes/5") return groupTypes[0]; if (path === "/campuses") return campuses; + if (path === "/campuses/0") return campuses[0]; + if (path === "/person/masterdata") return { groupStatuses }; if (path === "/group/roles") return roles; if (path === "/group/memberstatus") return memberStatuses; m = /^\/dynamicgroups\/(\d+)\/ruleset$/.exec(path); @@ -381,10 +388,8 @@ describe("ct adopt group --with-dynamic", () => { expect(block).toContain("dynamic: true,"); // groupType is reverse-sugared to its logical key against the mocked catalog... expect(block).toContain('groupType: "team",'); - // ...but groupStatusId is NOT (#67: no group-status catalog exists) — it stays numeric, with no - // TODO comment (a TODO only fires when a catalog exists but the id doesn't match anything in it). - expect(block).toContain("groupStatusId: 1,"); - expect(block).not.toContain("status:"); + expect(block).toContain('status: "active",'); + expect(block).not.toContain("groupStatusId: 1,"); expect(block).not.toContain("TODO"); // And the group-status "catalog" is never fetched at all — there is no such catalog to fetch. expect(client.get).not.toHaveBeenCalledWith("/group/memberstatus"); @@ -463,8 +468,10 @@ describe("ct adopt group --with-dynamic --portable-rulesets (#76 Stage 3)", () = expect(and[2]!.oneof![1]).toEqual([ { __ctRef: true, kind: "group-type-role", groupType: "team", role: "Leiter" }, ]); - // groupStatusId has no catalog (#67) → left numeric, untouched. - expect(and[3]!.oneof![1]).toEqual([1, 2]); + expect(and[3]!.oneof![1]).toEqual([ + { __ctRef: true, kind: "group-status", key: "active" }, + { __ctRef: true, kind: "group-status", key: "pending" }, + ]); }); it("names every dimension it left numeric, with the reason (#101)", async () => { @@ -485,7 +492,7 @@ describe("ct adopt group --with-dynamic --portable-rulesets (#76 Stage 3)", () = // Detail is id-free: formatPortablizeWarnings prints the ids once, ahead of it, so a detail // naming one id would be stamped across every id merged into the line. expect(warned).toMatch(/ctgroup\.id: 999 left numeric — not under management/); - expect(warned).toMatch(/ctgroup\.groupStatusId: 1, 2 left numeric — group statuses have no REST catalog/); + expect(warned).not.toMatch(/ctgroup\.groupStatusId/); }); it("is ON by default since #101: a plain --with-dynamic capture emits ref markers", async () => { @@ -548,15 +555,14 @@ describe("ct adopt group — idiomatic snippet round-trips to a no-op (#52 item } // The printed block is a `// group` header + one idiomatic multi-line `group({ ... });` snippet - // with campusId/groupTypeId reverse-sugared to campus/groupType keys; groupStatusId has no - // catalog to reverse-sugar against (#67), so it stays numeric — not a TODO, just plain data. + // with every catalog-backed id reverse-sugared to its logical key. const block = writes.join(""); const snippet = block.replace(/^\/\/ group\n/, "").trim(); expect(snippet.startsWith("group({")).toBe(true); expect(snippet).toContain('campus: "mainz"'); // id 0 reverse-resolved expect(snippet).toContain('groupType: "team"'); - expect(snippet).toContain("groupStatusId: 1"); - expect(snippet).not.toContain("status:"); // never the group-status sugar (#67) + expect(snippet).toContain('status: "active"'); + expect(snippet).not.toContain("groupStatusId: 1"); expect(snippet).not.toContain("TODO"); // everything resolved — a clean, hand-edit-free paste // Paste it VERBATIM into a config (only wrapping boilerplate + the `ct.` receiver added). @@ -566,6 +572,20 @@ describe("ct adopt group — idiomatic snippet round-trips to a no-op (#52 item // Load it through the real loader and plan against the state the adopt just wrote. const { resources } = await loadConfig(configPath); const state = await loadState(statePath, HOST); + state.externals!.mainz = { + type: "campus", + id: 0, + key: "mainz", + identity: { name: "Mainz" }, + boundAt: "t", + }; + state.externals!.team = { + type: "group-type", + id: 5, + key: "team", + identity: { name: "Team" }, + boundAt: "t", + }; const { plan } = await buildPlan(client as unknown as Pick, state, resources, { configDir: workDir, }); diff --git a/tests/application/apply-operation.test.ts b/tests/application/apply-operation.test.ts index a69a633..378e9eb 100644 --- a/tests/application/apply-operation.test.ts +++ b/tests/application/apply-operation.test.ts @@ -10,6 +10,7 @@ import { PreparedOperationStore } from "../../src/application/prepared-operation import type { Clock } from "../../src/application/ports.js"; import type { Plan } from "../../src/engine/types.js"; import { emptyState } from "../../src/state/state.js"; +import { ExternalReferenceError } from "../../src/resolve/external.js"; const host = "https://example.church.tools"; const statePath = "/project/ct-state.prod.json"; @@ -28,6 +29,7 @@ const resourcePlan: Plan = { function harness(options: { protected?: boolean; environment?: string | null } = {}) { let now = new Date("2026-08-25T20:00:00.000Z"); let stateFile = "state-v1"; + let configFile = "config-v1"; const clock: Clock = { now: () => now }; const store = new PreparedOperationStore(clock, { nextId: () => "prepared-1", @@ -45,7 +47,7 @@ function harness(options: { protected?: boolean; environment?: string | null } = const dependencies: ApplyOperationDependencies = { clock, store, - readStateFile: async () => stateFile, + readStateFile: async (path) => (path === statePath ? stateFile : configFile), resolveProject: vi.fn(async () => ({ cwd: "/project", configPath: "/project/ct.config.ts", @@ -85,6 +87,9 @@ function harness(options: { protected?: boolean; environment?: string | null } = changeState(value: string) { stateFile = value; }, + changeConfig(value: string) { + configFile = value; + }, }; } @@ -93,6 +98,34 @@ async function expectCode(promise: Promise, code: CtApplicationError["c } describe("prepared apply operation", () => { + it("blocks before backup and writes when an external prerequisite diagnostic is raised", async () => { + const test = harness(); + const diagnostic = new ExternalReferenceError({ + reason: "EXTERNAL_BINDING_MISSING", + type: "group", + key: "shared", + site: 'group "consumer".parents', + context: { host, consumer: "consumer", environment: "prod" }, + evidence: ["No persisted external binding exists."], + consequence: "Apply is blocked before writes.", + remediation: [{ command: "ct use group 77 --key shared", description: "Bind the live group." }], + verification: "ct plan --env prod", + }); + test.dependencies.buildPlan = vi.fn(async () => { + throw diagnostic; + }); + + await expect(prepareApply({}, test.dependencies)).rejects.toMatchObject({ + code: "EXTERNAL_REFERENCE_BLOCKED", + details: { + reason: "EXTERNAL_BINDING_MISSING", + remediation: [{ command: "ct use group 77 --key shared" }], + }, + }); + expect(test.backup).not.toHaveBeenCalled(); + expect(test.execute).not.toHaveBeenCalled(); + }); + it("requires the exact protected environment and writes the backup before resources", async () => { const test = harness(); const prepared = await prepareApply({}, test.dependencies); @@ -203,4 +236,18 @@ describe("prepared apply operation", () => { expect(test.backup).not.toHaveBeenCalled(); expect(test.execute).not.toHaveBeenCalled(); }); + + it("binds execution to the exact config digest", async () => { + const test = harness({ protected: false, environment: "dev" }); + const prepared = await prepareApply({}, test.dependencies); + expect(prepared.bindings.configDigest).toMatch(/^[a-f0-9]{64}$/); + expect(prepared.bindings.planDigest).toMatch(/^[a-f0-9]{64}$/); + test.changeConfig("config-v2"); + + await expectCode( + executePreparedApply(prepared, { type: "yes" }, test.dependencies), + "PLAN_CONFIRMATION_MISMATCH", + ); + expect(test.backup).not.toHaveBeenCalled(); + }); }); diff --git a/tests/application/destroy-operation.test.ts b/tests/application/destroy-operation.test.ts index a4e1d12..cb32836 100644 --- a/tests/application/destroy-operation.test.ts +++ b/tests/application/destroy-operation.test.ts @@ -62,6 +62,7 @@ function harness() { }; return { dependencies, + state, request, events, changeState(value: string) { @@ -75,6 +76,22 @@ async function expectCode(promise: Promise, code: CtApplicationError["c } describe("prepared destroy operation", () => { + it("cannot target an external binding and performs no ChurchTools read or write", async () => { + const test = harness(); + test.state.externals!.shared = { + type: "group", + id: 77, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + await expect(prepareDestroy({ targets: ["shared"] }, test.dependencies)).rejects.toThrow( + /not managed.*Nothing to destroy/, + ); + expect(test.request).not.toHaveBeenCalled(); + expect(test.dependencies.authedSession).not.toHaveBeenCalled(); + }); + it("exposes the exact proposal and requires the protected environment before deleting", async () => { const test = harness(); const prepared = await prepareDestroy({ targets: ["area"] }, test.dependencies); diff --git a/tests/application/input-operation.test.ts b/tests/application/input-operation.test.ts new file mode 100644 index 0000000..c0841dc --- /dev/null +++ b/tests/application/input-operation.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createInputSnapshot, + getInputSnapshot, + listInputSnapshots, + validateProcessInput, +} from "../../src/application/operations/input.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe("versioned process input", () => { + it("validates required envelope fields", () => { + expect(validateProcessInput({ schemaVersion: "", payload: {} })).toMatchObject({ + valid: false, + digest: null, + errors: expect.arrayContaining([ + { path: "/schemaVersion", message: expect.any(String) }, + { path: "/clientRevision", message: expect.any(String) }, + ]), + }); + }); + + it("uses a canonical digest independent of object key order", async () => { + const left = await createInputSnapshot({ + schemaVersion: "1", + clientRevision: "a", + payload: { b: 2, a: 1 }, + persist: false, + }); + const right = await createInputSnapshot({ + schemaVersion: "1", + clientRevision: "a", + payload: { a: 1, b: 2 }, + persist: false, + }); + expect(left.value.digest).toBe(right.value.digest); + }); + + it("persists immutable snapshots and returns the original record on duplicate writes", async () => { + const cwd = await mkdtemp(join(tmpdir(), "ct-input-")); + directories.push(cwd); + const request = { schemaVersion: "1", clientRevision: "r1", payload: { ok: true }, cwd }; + const first = await createInputSnapshot(request, { now: () => new Date("2026-01-01T00:00:00Z") }); + const second = await createInputSnapshot(request, { now: () => new Date("2027-01-01T00:00:00Z") }); + expect(second.value).toEqual(first.value); + expect((await getInputSnapshot(cwd, first.value.digest)).value).toEqual(first.value); + expect((await listInputSnapshots(cwd)).snapshots).toEqual([first.value]); + }); +}); diff --git a/tests/application/ownership-operation.test.ts b/tests/application/ownership-operation.test.ts new file mode 100644 index 0000000..275a119 --- /dev/null +++ b/tests/application/ownership-operation.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { checkOwnership } from "../../src/application/operations/ownership.js"; +import { RESOURCES } from "../../src/resources/registry.js"; + +const host = "https://example.church.tools"; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function project( + root: string, + name: string, + state: { resources?: Record; externals?: Record }, +): Promise { + const directory = join(root, name); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "ct.envs.json"), + JSON.stringify({ environments: { prod: { host, state: "ct-state.prod.json" } } }), + ); + await writeFile( + join(directory, "ct-state.prod.json"), + JSON.stringify({ version: 2, host, resources: state.resources ?? {}, externals: state.externals ?? {} }), + ); +} + +function managed(key: string, id: number) { + return { + type: "group", + key, + id, + fields: { name: "Shared", groupTypeId: 2 }, + adoptedAt: "t", + updatedAt: "t", + }; +} + +function external(key: string, id: number, owner?: string) { + return { + type: "group", + key, + id, + ...(owner ? { owner } : {}), + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; +} + +describe("checkOwnership", () => { + it("analyses every registry type through the same generic identity contract", async () => { + const root = await mkdtemp(join(tmpdir(), "ct-ownership-")); + roots.push(root); + const ownerResources: Record = {}; + const consumerExternals: Record = {}; + let id = 1; + for (const [type, spec] of Object.entries(RESOURCES)) { + const key = type.replaceAll("-", "_"); + const fields = { name: `Shared ${type}`, groupTypeId: 2 }; + ownerResources[key] = { + type, + key, + id, + fields, + adoptedAt: "t", + updatedAt: "t", + }; + consumerExternals[key] = { + type, + key, + id, + owner: "master", + identity: spec.external.identity(fields), + boundAt: "t", + }; + id += 1; + } + await project(root, "master", { resources: ownerResources }); + await project(root, "consumer", { externals: consumerExternals }); + const result = await checkOwnership({ root, environment: "prod" }); + expect(result.value.conflicts).toBe(0); + expect(result.value.findings.filter((finding) => finding.reason === "OWNERSHIP_OK")).toHaveLength( + Object.keys(RESOURCES).length, + ); + }); + + it("reports one visible owner plus read-only consumers as ok", async () => { + const root = await mkdtemp(join(tmpdir(), "ct-ownership-")); + roots.push(root); + await project(root, "master", { resources: { shared: managed("shared", 7) } }); + await project(root, "consumer", { externals: { shared: external("shared", 7, "master") } }); + const result = await checkOwnership({ root, environment: "prod" }); + expect(result.value.conflicts).toBe(0); + expect(result.value.findings).toContainEqual(expect.objectContaining({ reason: "OWNERSHIP_OK" })); + }); + + it("detects duplicate owners and key mismatches with rekey remediation", async () => { + const root = await mkdtemp(join(tmpdir(), "ct-ownership-")); + roots.push(root); + await project(root, "owner-a", { resources: { shared: managed("shared", 7) } }); + await project(root, "owner-b", { resources: { alias: managed("alias", 7) } }); + const result = await checkOwnership({ root, environment: "prod" }); + expect(result.value.findings.map((finding) => finding.reason)).toEqual( + expect.arrayContaining(["DUPLICATE_OWNER", "KEY_MISMATCH"]), + ); + expect( + result.value.findings.find((finding) => finding.reason === "KEY_MISMATCH")?.remediation?.[0], + ).toContain("ct state rekey group alias shared --env prod"); + expect( + result.value.findings.find((finding) => finding.reason === "DUPLICATE_OWNER")?.remediation?.[0], + ).toContain("ct unadopt group alias --env prod"); + }); + + it("does not search ignored build or node_modules directories", async () => { + const root = await mkdtemp(join(tmpdir(), "ct-ownership-")); + roots.push(root); + await project(root, "visible", { resources: { shared: managed("shared", 7) } }); + await project(join(root, "node_modules"), "hidden", { resources: { alias: managed("alias", 7) } }); + const result = await checkOwnership({ root, environment: "prod" }); + expect(result.value.projects.map((item) => item.name)).toEqual(["visible"]); + }); +}); diff --git a/tests/application/release-operation.test.ts b/tests/application/release-operation.test.ts new file mode 100644 index 0000000..72fa6dc --- /dev/null +++ b/tests/application/release-operation.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { + executePreparedRelease, + prepareRelease, + type PreparedReleaseExecution, +} from "../../src/application/operations/release.js"; +import { PreparedOperationStore } from "../../src/application/prepared-operation-store.js"; +import { emptyState } from "../../src/state/state.js"; + +const host = "https://example.church.tools"; + +function project(environment: string | null = "prod") { + return { + cwd: "/project", + configPath: "/project/ct.config.ts", + statePath: "/project/state.json", + environmentsPath: "/project/ct.envs.json", + configDisplayPath: "ct.config.ts", + stateDisplayPath: "state.json", + environment, + protected: false, + host, + }; +} + +describe("release operations", () => { + it("enforces environment proof inside the application operation", async () => { + const state = emptyState(host); + state.externals!.shared = { + type: "group", + id: 7, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + const saveState = vi.fn(); + const store = new PreparedOperationStore(); + const dependencies = { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + saveState, + store, + }; + const prepared = await prepareRelease({ kind: "external", type: "group", key: "shared" }, dependencies); + expect(prepared.confirmation).toEqual({ type: "environment", expected: "prod" }); + expect(prepared.preview.operation).toBe("unuse"); + + await expect(executePreparedRelease(prepared, undefined, dependencies)).rejects.toMatchObject({ + code: "STATE_RELEASE_CONFIRMATION_REQUIRED", + }); + await expect( + executePreparedRelease(prepared, { type: "environment", value: "dev" }, dependencies), + ).rejects.toMatchObject({ code: "STATE_RELEASE_CONFIRMATION_REQUIRED" }); + expect(saveState).not.toHaveBeenCalled(); + expect(state.externals?.shared).toBeDefined(); + + const result = await executePreparedRelease( + prepared, + { type: "environment", value: "prod" }, + dependencies, + ); + expect(result.operation).toBe("unuse"); + expect(result.value).toMatchObject({ kind: "external", removed: true, churchToolsContacted: false }); + expect(state.externals?.shared).toBeUndefined(); + expect(saveState).toHaveBeenCalledOnce(); + }); + + it("uses exact logical-key proof for a legacy project without --env", async () => { + const state = emptyState(host); + state.resources.owned = { + type: "campus", + id: 0, + key: "owned", + fields: { name: "Owned" }, + adoptedAt: "t", + updatedAt: "t", + }; + const store = new PreparedOperationStore(); + const dependencies = { + resolveProject: vi.fn(async () => project(null)), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + saveState: vi.fn(), + store, + }; + const prepared = await prepareRelease({ kind: "managed", type: "campus", key: "owned" }, dependencies); + expect(prepared.confirmation).toEqual({ type: "key", expected: "owned" }); + await executePreparedRelease(prepared, { type: "key", value: "owned" }, dependencies); + expect(state.resources.owned).toBeUndefined(); + }); +}); diff --git a/tests/application/state-operation.test.ts b/tests/application/state-operation.test.ts index 1587e57..7fb24e9 100644 --- a/tests/application/state-operation.test.ts +++ b/tests/application/state-operation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { listState, removeStateEntry } from "../../src/application/operations/state.js"; +import { listState, rekeyStateEntry, removeStateEntry } from "../../src/application/operations/state.js"; import { emptyState } from "../../src/state/state.js"; const host = "https://example.church.tools"; @@ -69,4 +69,122 @@ describe("state operations", () => { expect(saveState).not.toHaveBeenCalled(); expect(state.resources.mainz).toBeDefined(); }); + + it("fails closed when a safe removal cannot inspect config", async () => { + const state = emptyState(host); + state.externals!.shared = { + type: "group", + id: 7, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + await expect( + removeStateEntry( + { type: "group", key: "shared", expectedKind: "external", requireReadableConfig: true }, + { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => { + throw new Error("broken config"); + }), + saveState: vi.fn(), + }, + ), + ).rejects.toThrow(/Could not read the config/); + expect(state.externals?.shared).toBeDefined(); + }); + + it("refuses to remove a binding that changed while confirmation was pending", async () => { + const state = emptyState(host); + state.externals!.shared = { + type: "group", + id: 8, + key: "shared", + identity: { name: "Changed", groupTypeId: 2 }, + boundAt: "t", + }; + await expect( + removeStateEntry( + { + type: "group", + key: "shared", + expectedKind: "external", + expectedEntry: { + type: "group", + id: 7, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }, + }, + { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + saveState: vi.fn(), + }, + ), + ).rejects.toThrow(/changed while confirmation was pending/); + expect(state.externals?.shared).toBeDefined(); + }); + + it("lists, removes and rekeys external entries through the shared key namespace", async () => { + const state = emptyState(host); + state.externals!.shared = { + type: "group", + id: 7, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + const saveState = vi.fn(); + const dependencies = { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + saveState, + }; + const listed = await listState({}, dependencies); + expect(listed.value.entries).toContainEqual( + expect.objectContaining({ kind: "external", ownership: "read-only" }), + ); + const rekeyed = await rekeyStateEntry( + { type: "group", oldKey: "shared", newKey: "shared_group" }, + dependencies, + ); + expect(rekeyed.value.kind).toBe("external"); + expect(state.externals?.shared_group?.key).toBe("shared_group"); + const removed = await removeStateEntry({ type: "group", key: "shared_group" }, dependencies); + expect(removed.value.kind).toBe("external"); + expect(state.externals?.shared_group).toBeUndefined(); + }); + + it("rejects rekey collisions across managed and external entries", async () => { + const state = emptyState(host); + state.resources.owned = { + type: "campus", + id: 0, + key: "owned", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; + state.externals!.shared = { + type: "group", + id: 7, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + await expect( + rekeyStateEntry( + { type: "group", oldKey: "shared", newKey: "owned" }, + { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + }, + ), + ).rejects.toThrow(/unique across managed and external/); + }); }); diff --git a/tests/application/use-operation.test.ts b/tests/application/use-operation.test.ts new file mode 100644 index 0000000..5b02eae --- /dev/null +++ b/tests/application/use-operation.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + discoverExternalCandidates, + inspectExternalCandidate, + runUseResource, + type UseOperationDependencies, +} from "../../src/application/operations/use.js"; +import type { CtApplicationError } from "../../src/application/errors.js"; +import { emptyState } from "../../src/state/state.js"; + +const host = "https://example.church.tools"; +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +function project(directory = "/project") { + return { + cwd: directory, + configPath: join(directory, "ct.config.ts"), + statePath: join(directory, "state.json"), + environmentsPath: join(directory, "ct.envs.json"), + configDisplayPath: "ct.config.ts", + stateDisplayPath: "state.json", + environment: "prod", + protected: false, + host, + }; +} + +function dependencies(state = emptyState(host), row: Record = { id: 7, name: "Mainz" }) { + const saveState = vi.fn(); + const client = { + get: vi.fn<(path?: string) => Promise>>().mockResolvedValue(row), + getAll: vi.fn(async () => ({ data: [row] })), + }; + return { + state, + saveState, + client, + value: { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + saveState, + authedSession: vi.fn(async () => ({ client, me: { id: 1 } })) as never, + clock: { now: () => new Date("2026-08-27T12:00:00.000Z") }, + } satisfies UseOperationDependencies, + }; +} + +describe("runUseResource", () => { + it("creates a read-only binding and leaves managed resources untouched", async () => { + const deps = dependencies(); + const result = await runUseResource({ type: "campus", id: 7, key: "mainz", owner: "master" }, deps.value); + expect(result.value).toMatchObject({ action: "created", written: true, churchToolsWritten: false }); + expect(deps.state.resources).toEqual({}); + expect(deps.state.externals?.mainz).toEqual({ + type: "campus", + key: "mainz", + id: 7, + owner: "master", + identity: { name: "Mainz" }, + boundAt: "2026-08-27T12:00:00.000Z", + }); + }); + + it("is byte-stable for an unchanged binding and does not update boundAt", async () => { + const directory = await mkdtemp(join(tmpdir(), "ct-use-idempotent-")); + dirs.push(directory); + const client = { get: vi.fn(async () => ({ id: 7, name: "Mainz", shorty: "MZ" })) }; + const deps: UseOperationDependencies = { + resolveProject: vi.fn(async () => project(directory)), + authedSession: vi.fn(async () => ({ client, me: { id: 1 } })) as never, + clock: { now: () => new Date("2026-08-27T12:00:00.000Z") }, + }; + await runUseResource({ type: "campus", id: 7, key: "mainz" }, deps); + const before = await readFile(join(directory, "state.json"), "utf8"); + deps.clock = { now: () => new Date("2026-08-28T12:00:00.000Z") }; + const second = await runUseResource({ type: "campus", id: 7, key: "mainz" }, deps); + const after = await readFile(join(directory, "state.json"), "utf8"); + expect(second.value.action).toBe("no-op"); + expect(after).toBe(before); + }); + + it("requires explicit confirmation for hard identity changes, but not display-only changes", async () => { + const state = emptyState(host); + state.externals!.team = { + type: "group", + key: "team", + id: 9, + identity: { name: "Team", groupTypeId: 2 }, + boundAt: "t", + }; + const deps = dependencies(state, { + id: 9, + name: "Team renamed", + information: { groupTypeId: 2, campusId: 99, groupStatusId: 4 }, + }); + await expect(runUseResource({ type: "group", id: 9, key: "team" }, deps.value)).rejects.toMatchObject({ + code: "EXTERNAL_CONFIRMATION_REQUIRED", + } satisfies Partial); + expect(deps.saveState).not.toHaveBeenCalled(); + + const accepted = await runUseResource( + { type: "group", id: 9, key: "team", acceptChanges: true }, + deps.value, + ); + expect(accepted.value.action).toBe("identity-updated"); + expect(state.externals!.team!.identity).toEqual({ name: "Team renamed", groupTypeId: 2 }); + }); + + it("rejects managed/external key, id and alias collisions", async () => { + const state = emptyState(host); + state.resources.mainz = { + type: "campus", + key: "mainz", + id: 7, + fields: { name: "Mainz" }, + adoptedAt: "t", + updatedAt: "t", + }; + const deps = dependencies(state); + await expect(runUseResource({ type: "campus", id: 7, key: "mainz" }, deps.value)).rejects.toThrow( + /already managed/, + ); + state.externals!.berlin = { + type: "campus", + key: "berlin", + id: 8, + identity: { name: "Berlin" }, + boundAt: "t", + }; + await expect(runUseResource({ type: "campus", id: 8, key: "other" }, deps.value)).rejects.toThrow( + /already external as "berlin"/, + ); + }); + + it("retains the existing consumer key as the interactive proposal", async () => { + const state = emptyState(host); + state.externals!.carefully_named = { + type: "campus", + key: "carefully_named", + id: 7, + identity: { name: "Mainz" }, + boundAt: "t", + }; + const deps = dependencies(state); + const result = await inspectExternalCandidate({ type: "campus", id: 7 }, deps.value); + expect(result.value.suggestedKey).toBe("carefully_named"); + }); + + it("reports both old and new targets before an explicitly confirmed rebind", async () => { + const state = emptyState(host); + state.externals!.mainz = { + type: "campus", + key: "mainz", + id: 6, + identity: { name: "Old Mainz" }, + boundAt: "t", + }; + const deps = dependencies(state, { id: 7, name: "New Mainz" }); + deps.client.get.mockImplementation(async (path?: string) => + path?.endsWith("/6") ? { id: 6, name: "Old Mainz" } : { id: 7, name: "New Mainz" }, + ); + await expect(runUseResource({ type: "campus", id: 7, key: "mainz" }, deps.value)).rejects.toMatchObject({ + code: "EXTERNAL_CONFIRMATION_REQUIRED", + details: { + action: "rebound", + oldId: 6, + newId: 7, + previousLive: expect.objectContaining({ id: 6, name: "Old Mainz" }), + live: expect.objectContaining({ id: 7, name: "New Mainz" }), + }, + }); + expect(deps.saveState).not.toHaveBeenCalled(); + }); +}); + +describe("discoverExternalCandidates", () => { + it("returns every fuzzy match with registry-defined identity and display data", async () => { + const deps = dependencies(); + deps.client.getAll.mockResolvedValue({ + data: [ + { id: 1, name: "OJAHR Fuzzies", information: { groupTypeId: 4, campusId: 2 } }, + { id: 2, name: "OJAHR Fuzzies Alumni", information: { groupTypeId: 5, campusId: 3 } }, + ], + }); + const result = await discoverExternalCandidates({ type: "group", search: "fuzz" }, deps.value); + expect(result.value.candidates).toHaveLength(2); + expect(result.value.candidates[0]).toMatchObject({ + id: 1, + identity: { name: "OJAHR Fuzzies", groupTypeId: 4 }, + display: { campusId: 2 }, + }); + }); +}); diff --git a/tests/auth-login-bootstrap.test.ts b/tests/auth-login-bootstrap.test.ts index 6ace7be..0aebac7 100644 --- a/tests/auth-login-bootstrap.test.ts +++ b/tests/auth-login-bootstrap.test.ts @@ -6,6 +6,8 @@ import { LoginError, loginHint, envVarHint, + beginPasswordLogin, + continuePasswordLogin, type LoginPrompts, } from "../src/auth/login.js"; import { isSecureStorageAvailable } from "../src/auth/tokenStore.js"; @@ -91,6 +93,20 @@ const TOTP_CHALLENGE = { status: 200, body: { data: { personId: 42, status: "tot const TOKEN_RESPONSE = { status: 200, body: { data: TOKEN } }; describe("loginWithPassword (#138)", () => { + it("can split a TOTP login into an opaque server-held continuation", async () => { + const { fetchImpl, calls } = stubFetch([ + { ...TOTP_CHALLENGE, setCookie: ["ChurchTools_ct=sess1; Path=/"] }, + { status: 200, body: { data: { personId: 42, status: "success" } } }, + TOKEN_RESPONSE, + ]); + const started = await beginPasswordLogin(HOST, "ada", PASSWORD, { fetchImpl }); + expect(started.kind).toBe("totp"); + if (started.kind !== "totp") throw new Error("expected TOTP continuation"); + expect(started.continuation).not.toHaveProperty("password"); + await expect(continuePasswordLogin(started.continuation, TOTP, { fetchImpl })).resolves.toBe(TOKEN); + expect(calls[1]?.cookie).toContain("ChurchTools_ct=sess1"); + }); + it("logs in without 2FA and returns the personal login token", async () => { const { fetchImpl, calls } = stubFetch([ { ...OK_LOGIN, setCookie: ["ChurchTools_ct=sess1; Path=/; HttpOnly"] }, diff --git a/tests/blueprint.test.ts b/tests/blueprint.test.ts index f2690de..407c325 100644 --- a/tests/blueprint.test.ts +++ b/tests/blueprint.test.ts @@ -28,10 +28,12 @@ describe("campus blueprint", () => { } }); - it("rejects a blueprint whose managed parent is undeclared (typo guard)", async () => { + it("retains an undeclared hierarchy key for plan-time external binding validation", async () => { const broken = (ct: ConfigContext) => { ct.group({ key: "g", name: "g", groupTypeId: 2, parents: ["missing"] }); }; - await expect(evaluateConfig(broken)).rejects.toThrow(/not declared/i); + await expect(evaluateConfig(broken)).resolves.toMatchObject({ + resources: [expect.objectContaining({ parents: ["missing"] })], + }); }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 19c6075..e9d33a6 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -14,6 +14,9 @@ describe("ct program", () => { "plan", "apply", "destroy", + "server", + "input", + "environment", "completion", ]), ); diff --git a/tests/comment-viewer-resource.test.ts b/tests/comment-viewer-resource.test.ts index 12f2fe5..561be58 100644 --- a/tests/comment-viewer-resource.test.ts +++ b/tests/comment-viewer-resource.test.ts @@ -190,7 +190,12 @@ describe("the same config means the same thing on two hosts (#151)", () => { /** A client whose permission reads are empty and whose viewer catalog is host-specific. */ function mockClient(viewers: { id: number; name: string }[] = [], newId = 555) { const calls: { method: string; path: string; body?: unknown }[] = []; - const get = vi.fn(async (path: string) => (path === PATH ? viewers : [])); + const get = vi.fn(async (path: string) => { + if (path === PATH) return viewers; + const match = /^\/person\/commentviewers\/(\d+)$/.exec(path); + if (match) return viewers.find((viewer) => viewer.id === Number(match[1])); + return []; + }); const request = vi.fn(async (method: string, path: string, body?: unknown) => { calls.push({ method, path, body }); if (method === "POST" && path === PATH) return { id: newId }; @@ -262,13 +267,18 @@ describe("the same config means the same thing on two hosts (#151)", () => { expect(put?.body).toEqual({ authId: 113, type: "grant", dataId: [555] }); }); - it("still falls back to the live catalog for a viewer this config does not own", async () => { - // The compatibility half of the promotion: a name ref against an unmanaged viewer keeps working - // exactly as it did in #102, so no existing config has to change. + it("resolves an explicitly bound external viewer without managing it", async () => { const { client } = mockClient([{ id: 2, name: "Dienstbereich" }]); - const { items, fetchErrors } = await buildPermissionPlan(client, emptyState(HOST), [viewerScoped]); + const state = emptyState(HOST); + state.externals!.dienstbereich = { + type: "comment-viewer", + id: 2, + key: "dienstbereich", + identity: { name: "Dienstbereich" }, + boundAt: "t", + }; + const { items, fetchErrors } = await buildPermissionPlan(client, state, [viewerScoped]); expect(fetchErrors).toEqual([]); - // Catalog-resolved: already host-correct, so no managed identity is carried for re-resolution. expect(items[0]?.diff.toPut).toEqual([{ authId: 113, dataId: [2], type: "grant" }]); }); }); diff --git a/tests/completion.test.ts b/tests/completion.test.ts index baf36c4..43cf64f 100644 --- a/tests/completion.test.ts +++ b/tests/completion.test.ts @@ -82,10 +82,21 @@ describe("completion candidates", () => { it("reflects the real command tree, nested commands included", async () => { const program = buildProgram(); expect(await complete(program, "ct ")).toEqual( - expect.arrayContaining(["auth", "state", "plan", "apply", "destroy", "completion"]), + expect.arrayContaining([ + "auth", + "use", + "unuse", + "unadopt", + "ownership", + "state", + "plan", + "apply", + "destroy", + "completion", + ]), ); expect(await complete(program, "ct auth ")).toEqual(expect.arrayContaining(["login", "logout"])); - expect(await complete(program, "ct state ")).toEqual(expect.arrayContaining(["list", "rm"])); + expect(await complete(program, "ct state ")).toEqual(expect.arrayContaining(["list", "rm", "rekey"])); expect(await complete(program, "ct apply -")).toEqual(expect.arrayContaining(["--auto-approve"])); }); }); @@ -185,6 +196,25 @@ describe("dynamic completion", () => { ); }); + it("completes use/rekey types and external state keys from the generic registry/state", async () => { + process.env.CT_STATE = join(dir, "ct-state.json"); + await writeFile( + process.env.CT_STATE, + JSON.stringify({ + version: 2, + host: "https://x.church.tools", + resources: { owned: { type: "group", id: 8, key: "owned", fields: {} } }, + externals: { shared: { type: "group", id: 7, key: "shared", identity: {}, boundAt: "t" } }, + }), + ); + expect(await complete(buildProgram(), "ct use ")).toEqual( + expect.arrayContaining(["campus", "group", "group-role"]), + ); + expect(await complete(buildProgram(), "ct state rekey group ")).toEqual(["owned", "shared"]); + expect(await complete(buildProgram(), "ct unuse group ")).toEqual(["shared"]); + expect(await complete(buildProgram(), "ct unadopt group ")).toEqual(["owned"]); + }); + it("completes a path option from the filesystem", async () => { await writeFile(join(dir, "ct.config.ts"), ""); const candidates = await complete(buildProgram(), `ct plan --config ${dir}/ct`); diff --git a/tests/context.test.ts b/tests/context.test.ts index 0bea769..8b52eb0 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { createContext, evaluateConfig, type ConfigContext } from "../src/config/context.js"; import { isKnownType } from "../src/engine/graph.js"; import { RESOURCES } from "../src/resources/registry.js"; +import { ref } from "../src/resolve/refs.js"; describe("config context", () => { it("builds desired resources from DSL calls, separating key/parent from fields", () => { @@ -107,20 +108,17 @@ describe("config context", () => { expect(() => ct.group({ key: "h", name: "H", parents: [1] as never })).toThrow(/array of string/); }); - describe("group-status sugar removed (#67)", () => { - it("fails fast, with an actionable error, when `status` is declared", () => { - const { ct } = createContext(); - expect(() => ct.group({ key: "g", name: "G", status: "active" })).toThrow( - 'group "g": "status" cannot be resolved by name — group statuses have no REST catalog ' + - "(GET /group/memberstatus is a different dimension: member statuses, string ids — verified " + - '2026-07-10). Declare a numeric "groupStatusId" instead (e.g. "groupStatusId: 1").', - ); + describe("group-status sugar (#157)", () => { + it("sugars a technical status name into a logical reference", () => { + const { ct, resources } = createContext(); + ct.group({ key: "g", name: "G", status: "active" }); + expect(resources[0]?.fields.groupStatusId).toEqual(ref.status("active")); }); - it("still fails fast even when a numeric groupStatusId is also given", () => { + it("rejects declaring logical and numeric forms together", () => { const { ct } = createContext(); expect(() => ct.group({ key: "g", name: "G", status: "active", groupStatusId: 1 })).toThrow( - /"status" cannot be resolved by name/, + /either "status".*or "groupStatusId"/, ); }); @@ -131,7 +129,7 @@ describe("config context", () => { }); }); - it("rejects a hierarchy parent that is not a declared group", async () => { + it("rejects a declared non-group parent but permits a plan-time external group key", async () => { await expect( evaluateConfig((ct) => { ct.campus({ key: "mz", name: "Mainz" }); @@ -141,9 +139,9 @@ describe("config context", () => { await expect( evaluateConfig((ct) => { - ct.group({ key: "kids", name: "Kids", parents: ["ghost"] }); // never declared + ct.group({ key: "kids", name: "Kids", parents: ["shared_parent"] }); }), - ).rejects.toThrow(/not declared in this config/); + ).resolves.toMatchObject({ resources: [expect.objectContaining({ parents: ["shared_parent"] })] }); }); it("rejects a duplicate logical key", () => { diff --git a/tests/external-registry.test.ts b/tests/external-registry.test.ts new file mode 100644 index 0000000..1dc91e0 --- /dev/null +++ b/tests/external-registry.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { RESOURCES } from "../src/resources/registry.js"; + +describe("generic external registry contract", () => { + it.each(Object.entries(RESOURCES))( + "%s supplies identity, display and one unique ref kind", + (type, spec) => { + const sample = { + id: 7, + name: "Example", + shorty: "EX", + groupTypeId: 2, + information: { groupTypeId: 2, campusId: 3, groupStatusId: 1 }, + sortKey: 4, + degreeNameA: "A", + degreeNameB: "B", + type: "participant", + }; + expect(spec.external.refKind).toBeTruthy(); + expect(spec.external.identity(sample)).toHaveProperty("name", "Example"); + expect(spec.external.display(sample)).toEqual(expect.any(Object)); + expect(spec.collectionPath).toBeTruthy(); + expect(spec.itemPath(7)).toBeTruthy(); + expect(type).toBeTruthy(); + }, + ); + + it("does not maintain two type lists: every registry entry has a distinct logical ref kind", () => { + const kinds = Object.values(RESOURCES).map((spec) => spec.external.refKind); + expect(new Set(kinds).size).toBe(Object.keys(RESOURCES).length); + }); + + it.each([ + ["campus", ["name"], ["shorty"]], + ["group", ["groupTypeId", "name"], ["campusId", "groupStatusId"]], + ["group-type", ["name"], ["nameTranslated"]], + ["age-group", ["name"], ["nameTranslated", "sortKey"]], + ["target-group", ["name"], ["nameTranslated", "sortKey"]], + ["relationship-type", ["name"], ["degreeNameA", "degreeNameB"]], + ["person-status", ["name"], ["isMember", "shorty"]], + ["department", ["name"], ["shorty"]], + ["security-level", ["name"], ["level"]], + ["comment-viewer", ["name"], ["sortKey"]], + ["group-role", ["groupTypeId", "name"], ["type"]], + ] as const)("%s pins the issue-defined hard/display identity boundary", (type, hard, display) => { + const sample = { + id: 7, + name: "Example", + shorty: "EX", + nameTranslated: "Example translated", + groupTypeId: 2, + information: { groupTypeId: 2, campusId: 3, groupStatusId: 1 }, + campusId: 3, + groupStatusId: 1, + sortKey: 4, + degreeNameA: "Parent", + degreeNameB: "Child", + isMember: true, + type: "participant", + }; + expect(Object.keys(RESOURCES[type]!.external.identity(sample)).sort()).toEqual([...hard].sort()); + expect(Object.keys(RESOURCES[type]!.external.display(sample)).sort()).toEqual([...display].sort()); + }); +}); diff --git a/tests/init.test.ts b/tests/init.test.ts index de6871d..ff20a60 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -95,9 +95,10 @@ describe("initializeConfigRepository", () => { protected: true, }); await expect(loadState(join(directory, statePath), "https://example.church.tools")).resolves.toEqual({ - version: 1, + version: 2, host: "https://example.church.tools", resources: {}, + externals: {}, }); await expect(access(join(directory, "ct-state.json"))).rejects.toMatchObject({ code: "ENOENT" }); }); diff --git a/tests/operation-catalog.test.ts b/tests/operation-catalog.test.ts new file mode 100644 index 0000000..e038ffa --- /dev/null +++ b/tests/operation-catalog.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { buildProgram } from "../src/index.js"; +import { operationCatalog } from "../src/operations/catalog.js"; +import { generateOpenApi } from "../src/server/openapi.js"; + +function commandAt(path: readonly string[]) { + let commands = buildProgram().commands; + let found; + for (const segment of path) { + found = commands.find((command) => command.name() === segment || command.aliases().includes(segment)); + if (!found) return undefined; + commands = found.commands; + } + return found; +} + +describe("transport-neutral operation catalog", () => { + it("projects every non-adapter-specific operation through CLI and HTTP", () => { + for (const operation of operationCatalog.filter((candidate) => !candidate.adapterSpecific)) { + expect(operation.cli, operation.id).toBeDefined(); + expect(operation.http?.length, operation.id).toBeGreaterThan(0); + } + }); + + it("constructs every declared CLI command path", () => { + for (const operation of operationCatalog.filter((candidate) => candidate.cli)) { + expect( + commandAt(operation.cli!.path), + `${operation.id}: ${operation.cli!.path.join(" ")}`, + ).toBeDefined(); + } + }); + + it("mechanically validates declared CLI parameter bindings", () => { + for (const operation of operationCatalog.filter((candidate) => candidate.cli)) { + const command = commandAt(operation.cli!.path)!; + for (const parameter of operation.parameters.filter((candidate) => candidate.cli)) { + if (parameter.cli!.kind === "option") { + expect( + command.options.some((option) => option.long === parameter.cli!.name), + `${operation.id}.${parameter.name}: ${parameter.cli!.name}`, + ).toBe(true); + } else { + expect( + command.registeredArguments.some((argument) => argument.name() === parameter.cli!.name), + `${operation.id}.${parameter.name}: ${parameter.cli!.name}`, + ).toBe(true); + } + } + } + }); + + it("declares every HTTP projection in generated OpenAPI", () => { + const document = generateOpenApi() as { paths: Record> }; + for (const operation of operationCatalog) { + for (const route of operation.http ?? []) { + const path = route.path.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); + expect(document.paths[path]?.[route.method.toLowerCase()]?.operationId).toBe( + route.action ? `${operation.id}.${route.action}` : operation.id, + ); + } + } + }); + + it("keeps sensitive parameters out of URL bindings", () => { + for (const operation of operationCatalog) { + for (const parameter of operation.parameters.filter((candidate) => candidate.sensitive)) { + expect(parameter.http?.in, `${operation.id}.${parameter.name}`).toBe("body"); + } + } + }); + + it("documents snapshot selection while keeping trusted generator paths out of REST", () => { + for (const id of ["plan", "apply"]) { + const operation = operationCatalog.find((candidate) => candidate.id === id)!; + const snapshot = operation.parameters.find((parameter) => parameter.name === "snapshotDigest")!; + const generator = operation.parameters.find((parameter) => parameter.name === "generatorPath")!; + expect(snapshot.schema.pattern).toBe("^[a-f0-9]{64}$"); + expect(snapshot.http?.in).toBe("body"); + expect(generator.cli?.name).toBe("--generator"); + expect(generator.http).toBeUndefined(); + expect(operation.description).toContain("ct.config.ts"); + } + + const document = generateOpenApi() as { + paths: Record< + string, + Record< + string, + { + description?: string; + requestBody?: { + content: { "application/json": { schema: { properties: Record } } }; + }; + } + > + >; + }; + const plan = document.paths["/api/v1/workspaces/{workspaceId}/plans"]?.post; + const prepare = document.paths["/api/v1/workspaces/{workspaceId}/applies"]?.post; + const execute = document.paths["/api/v1/workspaces/{workspaceId}/applies/{operationId}/execute"]?.post; + expect(plan?.description).toContain("immutable browser form data"); + expect(plan?.requestBody?.content["application/json"].schema.properties).toHaveProperty("snapshotDigest"); + expect(prepare?.requestBody?.content["application/json"].schema.properties).toHaveProperty( + "snapshotDigest", + ); + expect(execute?.requestBody?.content["application/json"].schema.properties).not.toHaveProperty( + "snapshotDigest", + ); + }); +}); diff --git a/tests/permission-pending-domain.test.ts b/tests/permission-pending-domain.test.ts index 0f6996c..ec947d0 100644 --- a/tests/permission-pending-domain.test.ts +++ b/tests/permission-pending-domain.test.ts @@ -334,13 +334,12 @@ describe("group_role symmetry: a same-run group DOES go pending and completes in }); }); -describe("pending domain: a TRUE typo (key absent from config AND state AND catalog) still hard-errors (#69)", () => { - it("throws the resolver's unchanged notFound message — not a pending block", async () => { - // "strucktur" is neither declared, nor in state, nor a live catalog match → genuinely unresolvable. +describe("pending domain: a TRUE typo (key absent from config and both state partitions) still hard-errors (#69)", () => { + it("reports the missing explicit external binding — not a pending block", async () => { const typoPerm: DesiredPermission = { ...strukturPerm, domainId: ref.groupType("strucktur") }; const { client } = mockClient({ "/group/grouptypes": [{ id: STRUKTUR_TYPE_ID, name: "Struktur" }] }); await expect(buildPermissionPlan(client, emptyState(HOST), [typoPerm], strukturType)).rejects.toThrow( - /Cannot resolve group-type:strucktur referenced at group_type_role "struktur_roles".domainId/, + /resource:\s+group-type "strucktur"[\s\S]*neither contains[\s\S]*ct use/, ); }); }); diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index 884540b..1e94947 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -8,12 +8,28 @@ import type { State } from "../src/state/state.js"; const bundledForTest = { ...CATALOG }; const state: State = { - version: 1, + version: 2, host: "h", resources: { kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, other: { type: "group", id: 7, key: "other", fields: {}, adoptedAt: "t", updatedAt: "t" }, }, + externals: { + core: { + type: "person-status", + id: 6, + key: "core", + identity: { name: "5 - Core" }, + boundAt: "t", + }, + group_active: { + type: "person-status", + id: 4, + key: "group_active", + identity: { name: "3 - Group Active" }, + boundAt: "t", + }, + }, }; describe("desiredTuples", () => { @@ -471,11 +487,7 @@ describe("buildPermissionPlan", () => { it("resolves a status domain by person-status name and reconciles the -1 ALL sentinel idempotently", async () => { const client = { get: vi.fn(async (path: string) => { - if (path === "/statuses") - return [ - { id: 4, name: "3 - Group Active" }, - { id: 6, name: "5 - Core" }, - ]; + if (path === "/statuses/6") return { id: 6, name: "5 - Core" }; if (path === "/permissions/status") return [ { @@ -494,13 +506,13 @@ describe("buildPermissionPlan", () => { { key: "core_login", domainType: "status", - domainId: ref.personStatus("5 - Core"), + domainId: ref.personStatus("core"), grants: [{ right: "churchcore:login to external system", scope: [-1] }], }, ]); expect(fetchErrors).toEqual([]); expect(warnings).toEqual([]); - expect(items[0]?.domainId).toBe(6); // resolved from the /statuses catalog + expect(items[0]?.domainId).toBe(6); expect(items[0]?.diff.toPut).toEqual([]); // live -1 row matches the declaration expect(items[0]?.diff.toDelete).toEqual([]); }); @@ -508,7 +520,7 @@ describe("buildPermissionPlan", () => { it("proposes the status grant on a status that does not carry it yet", async () => { const client = { get: vi.fn(async (path: string) => { - if (path === "/statuses") return [{ id: 4, name: "3 - Group Active" }]; + if (path === "/statuses/4") return { id: 4, name: "3 - Group Active" }; if (path === "/permissions/status") return []; throw new Error(`unexpected path ${path}`); }), @@ -517,7 +529,7 @@ describe("buildPermissionPlan", () => { { key: "group_active_login", domainType: "status", - domainId: ref.personStatus("3 - Group Active"), + domainId: ref.personStatus("group_active"), grants: [{ right: "churchcore:login to external system", scope: [-1] }], }, ]); diff --git a/tests/permission-scope-refs.test.ts b/tests/permission-scope-refs.test.ts index d2b97c1..1a270f4 100644 --- a/tests/permission-scope-refs.test.ts +++ b/tests/permission-scope-refs.test.ts @@ -50,10 +50,20 @@ function stateWithKoblenz(id: number): State { }; } +function stateWithExternal(type: string, key: string, id: number, identity: Record): State { + const state = emptyState(HOST); + state.externals![key] = { type, key, id, identity, boundAt: "t" }; + return state; +} + /** A client whose `/permissions/*` reads are empty and whose `/campuses` catalog is host-specific. */ function mockClient(campuses: { id: number; name: string }[] = [], newId = 555) { const calls: { method: string; path: string; body?: unknown }[] = []; - const get = vi.fn(async (path: string) => (path === "/campuses" ? campuses : [])); + const get = vi.fn(async (path: string) => { + if (path === "/campuses") return campuses; + const id = /^\/campuses\/(\d+)$/.exec(path)?.[1]; + return id ? campuses.find((campus) => campus.id === Number(id)) : []; + }); const request = vi.fn(async (method: string, path: string, body?: unknown) => { calls.push({ method, path, body }); if (method === "POST" && path === "/campuses") return { id: newId }; @@ -114,11 +124,10 @@ describe("campus-scoped grants are portable across hosts (#98)", () => { } }); - it("falls back to the live /campuses catalog for a campus this config does not manage", async () => { - // Not in state → resolved by name against the host's catalog. The id is already host-correct, so - // there is no managed identity to re-resolve at apply time and the tuple keeps no scopeKey. + it("resolves a read-only campus only through its explicit external binding", async () => { const { client } = mockClient([{ id: 23, name: "Koblenz" }]); - const tuples = await tuplesFor(campusScoped, emptyState(HOST), [], client); + const state = stateWithExternal("campus", "koblenz", 23, { name: "Koblenz" }); + const tuples = await tuplesFor(campusScoped, state, [], client); expect(tuples).toEqual([{ authId: 124, dataId: [23], type: "grant" }]); }); @@ -243,7 +252,9 @@ describe("scope-dimension validation (#98)", () => { domainId: 1, grants: [{ right: VIEW_STATION, scope: [ref.campus("nowhere")] }], }; - await expect(tuplesFor(perm, emptyState(HOST))).rejects.toThrow(/Cannot resolve campus:nowhere/); + await expect(tuplesFor(perm, emptyState(HOST))).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING", type: "campus", key: "nowhere" }, + }); }); it("still accepts a numeric dataId on any dimension (the #49 escape hatch is untouched)", async () => { @@ -296,10 +307,9 @@ describe("department scopes are a read-but-not-managed ref catalog (cdb_bereich, grants: [{ right: "churchdb:view alldata", scope: [{ department: key }] }], }); - it("resolves a department by name against the live catalog", async () => { - // No scopeKey: a catalog-resolved id is already host-correct and has no managed identity to - // re-resolve at apply time — it behaves exactly like the numeric escape hatch from there on. - expect(await tuplesFor(perm("equippers_koblenz"), emptyState(HOST), [], client)).toEqual([ + it("resolves a department through an explicit external binding", async () => { + const state = stateWithExternal("department", "equippers_koblenz", 7, { name: "Equippers Koblenz" }); + expect(await tuplesFor(perm("equippers_koblenz"), state, [], client)).toEqual([ { authId: 102, dataId: [7], type: "grant" }, ]); }); @@ -307,27 +317,21 @@ describe("department scopes are a read-but-not-managed ref catalog (cdb_bereich, it("hard-errors on an unknown department, and NOW advises declaring it (#108)", async () => { // Before #108 this said departments could not be declared or adopted. They can: `ct.department` // creates one through the legacy master-data endpoint, so the generic advice is correct again. - await expect(tuplesFor(perm("nope"), emptyState(HOST), [], client)).rejects.toThrow( - /no managed resource and no live department at \/departments matches key "nope".*Declare\/adopt it/s, - ); - }); - - it("is never treated as a managed resource, even if a same-keyed resource is in state", async () => { - const state: State = { - version: 1, - host: HOST, - resources: { - equippers_koblenz: { - type: "group", - id: 999, - key: "equippers_koblenz", - fields: {}, - adoptedAt: "t", - updatedAt: "t", - }, - }, + await expect(tuplesFor(perm("nope"), emptyState(HOST), [], client)).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING", type: "department", key: "nope" }, + }); + }); + + it("is not shadowed by an unrelated managed resource under another key", async () => { + const state = stateWithExternal("department", "equippers_koblenz", 7, { name: "Equippers Koblenz" }); + state.resources.some_group = { + type: "group", + id: 999, + key: "some_group", + fields: {}, + adoptedAt: "t", + updatedAt: "t", }; - // The group must not shadow the department catalog — that would be the misgrant #98 is about. expect(await tuplesFor(perm("equippers_koblenz"), state, [], client)).toEqual([ { authId: 102, dataId: [7], type: "grant" }, ]); @@ -344,7 +348,11 @@ describe("security-level scopes resolve by name (cc_securitylevel, #110)", () => { id: 3, name: "Stufe 3 (Hoch)", sortKey: 3 }, ]; const client = { - get: vi.fn(async (path: string) => (path === "/securitylevels" ? levels : [])), + get: vi.fn(async (path: string) => { + if (path === "/securitylevels") return levels; + const id = /^\/securitylevels\/(\d+)$/.exec(path)?.[1]; + return id ? levels.find((level) => level.id === Number(id)) : []; + }), } as unknown as CtClient; const perm = (scope: unknown[]): DesiredPermission => ({ @@ -354,9 +362,9 @@ describe("security-level scopes resolve by name (cc_securitylevel, #110)", () => grants: [{ right: "churchdb:security level person", scope: scope as never }], }); - it("resolves a level by its slugged name against the live catalog", async () => { - // No scopeKey: catalog-resolved ids are host-correct already and have no managed identity. - expect(await tuplesFor(perm([{ securityLevel: "stufe_3_hoch" }]), emptyState(HOST), [], client)).toEqual([ + it("resolves a level through its explicit external binding", async () => { + const state = stateWithExternal("security-level", "stufe_3_hoch", 3, { name: "Stufe 3 (Hoch)" }); + expect(await tuplesFor(perm([{ securityLevel: "stufe_3_hoch" }]), state, [], client)).toEqual([ { authId: 125, dataId: [3], type: "grant" }, ]); }); @@ -372,7 +380,7 @@ describe("security-level scopes resolve by name (cc_securitylevel, #110)", () => it("hard-errors on a level name this host does not have, instead of granting the wrong one", async () => { await expect( tuplesFor(perm([{ securityLevel: "stufe_9" }]), emptyState(HOST), [], client), - ).rejects.toThrow(/no live security-level at \/securitylevels matches key "stufe_9"/); + ).rejects.toMatchObject({ details: { reason: "EXTERNAL_BINDING_MISSING", key: "stufe_9" } }); }); }); @@ -386,7 +394,11 @@ describe("comment-viewer scopes resolve by name (cdb_comment_viewer, #102)", () { id: 2, name: "Admins", sortKey: 2 }, ]; const client = { - get: vi.fn(async (path: string) => (path === "/person/commentviewers" ? viewers : [])), + get: vi.fn(async (path: string) => { + if (path === "/person/commentviewers") return viewers; + const id = /^\/person\/commentviewers\/(\d+)$/.exec(path)?.[1]; + return id ? viewers.find((viewer) => viewer.id === Number(id)) : []; + }), } as unknown as CtClient; const perm = (scope: unknown[]): DesiredPermission => ({ @@ -397,23 +409,25 @@ describe("comment-viewer scopes resolve by name (cdb_comment_viewer, #102)", () }); it("resolves a viewer by name", async () => { - expect( - await tuplesFor(perm([{ commentViewer: "gemeindeleitung" }]), emptyState(HOST), [], client), - ).toEqual([{ authId: 113, dataId: [1], type: "grant" }]); + const state = stateWithExternal("comment-viewer", "gemeindeleitung", 1, { name: "Gemeindeleitung" }); + expect(await tuplesFor(perm([{ commentViewer: "gemeindeleitung" }]), state, [], client)).toEqual([ + { authId: 113, dataId: [1], type: "grant" }, + ]); }); it("resolves the id-0 row — a falsy id must not read as 'not found'", async () => { // "Alle" is id 0 on a real instance. Anything treating 0 as missing would silently drop the scope // (or worse, fall through to a different row), so this is pinned deliberately. - expect(await tuplesFor(perm([{ commentViewer: "alle" }]), emptyState(HOST), [], client)).toEqual([ + const state = stateWithExternal("comment-viewer", "alle", 0, { name: "Alle" }); + expect(await tuplesFor(perm([{ commentViewer: "alle" }]), state, [], client)).toEqual([ { authId: 113, dataId: [0], type: "grant" }, ]); }); it("hard-errors on a viewer name this host does not have", async () => { - await expect(tuplesFor(perm([{ commentViewer: "nope" }]), emptyState(HOST), [], client)).rejects.toThrow( - /no live comment-viewer at \/person\/commentviewers matches key "nope"/, - ); + await expect( + tuplesFor(perm([{ commentViewer: "nope" }]), emptyState(HOST), [], client), + ).rejects.toMatchObject({ details: { reason: "EXTERNAL_BINDING_MISSING", key: "nope" } }); }); }); diff --git a/tests/person-status-resource.test.ts b/tests/person-status-resource.test.ts index d0bfb78..aebc1dc 100644 --- a/tests/person-status-resource.test.ts +++ b/tests/person-status-resource.test.ts @@ -116,7 +116,11 @@ describe("ct.personStatus in the config DSL", () => { }); describe("resolving a personStatus reference", () => { - const client = { get: vi.fn(async () => [{ id: 3, name: "3 - Group Active" }]) }; + const client = { + get: vi.fn(async (path: string) => + path === "/statuses/3" ? { id: 3, name: "3 - Group Active" } : [{ id: 3, name: "3 - Group Active" }], + ), + }; it("prefers a MANAGED status in state over the live /statuses catalog", async () => { const state: State = { @@ -137,8 +141,16 @@ describe("resolving a personStatus reference", () => { expect(await resolver.resolve(ref.personStatus("group_active"), "site")).toBe(8); }); - it("still falls back to the /statuses catalog for a status this config does not own", async () => { - const resolver = new Resolver({ client: client as never, state: emptyState(HOST), desired: [] }); + it("resolves an explicitly bound external status", async () => { + const state = emptyState(HOST); + state.externals!["3_group_active"] = { + type: "person-status", + id: 3, + key: "3_group_active", + identity: { name: "3 - Group Active" }, + boundAt: "t", + }; + const resolver = new Resolver({ client: client as never, state, desired: [] }); expect(await resolver.resolve(ref.personStatus("3_group_active"), "site")).toBe(3); }); }); diff --git a/tests/plan-partial-honesty.test.ts b/tests/plan-partial-honesty.test.ts index 8bc943f..98296c1 100644 --- a/tests/plan-partial-honesty.test.ts +++ b/tests/plan-partial-honesty.test.ts @@ -95,12 +95,20 @@ describe("plan degradation under 429 (#126)", () => { adoptedAt: "t", updatedAt: "t", }; + state.externals!.parent = { + type: "group", + id: 20, + key: "parent", + identity: { name: "Parent" }, + boundAt: "t", + }; const desired: DesiredResource[] = [ { type: "group", key: "child", fields: { name: "Child" }, dependsOn: [], parents: ["parent"] }, ]; const client = { get: async (path: string): Promise => { if (path === "/groups/10") return { name: "Child" } as T; + if (path === "/groups/20") return { name: "Parent" } as T; throw new CtApiError("GET /groups/hierarchies failed (HTTP 429)", 429, null); }, }; diff --git a/tests/portable-refs.test.ts b/tests/portable-refs.test.ts index a38fb85..25b162a 100644 --- a/tests/portable-refs.test.ts +++ b/tests/portable-refs.test.ts @@ -36,12 +36,20 @@ function fakeHost(catalogs: Record, postIds: Record => {}; describe("buildPlan reference resolution", () => { - it("resolves a catalog groupType ref to a number so the diff stays number↔number", async () => { + it("resolves a bound external groupType ref to a number so the diff stays number↔number", async () => { const { resources } = await evaluateConfig((ct) => { ct.group({ key: "kids", name: "Kids", groupType: "ministry_team" }); }); - const client = fakeHost({ "/group/grouptypes": [{ id: 2, name: "Ministry Team" }] }); - const { plan } = await buildPlan(client, emptyState("h"), resources); + const state = emptyState("h"); + state.externals!.ministry_team = { + type: "group-type", + key: "ministry_team", + id: 2, + identity: { name: "Ministry Team" }, + boundAt: "t", + }; + const client = fakeHost({ "/group/grouptypes/2": { id: 2, name: "Ministry Team" } }); + const { plan } = await buildPlan(client, state, resources); const item = plan.items.find((i) => i.key === "kids")!; expect(item.action).toBe("create"); expect(item.changes).toContainEqual({ field: "groupTypeId", from: undefined, to: 2, source: "config" }); @@ -63,9 +71,9 @@ describe("buildPlan reference resolution", () => { ct.group({ key: "kids", name: "Kids", groupType: "ghost_type" }); }); const client = fakeHost({ "/group/grouptypes": [{ id: 2, name: "Ministry Team" }] }); - await expect(buildPlan(client, emptyState("h"), resources)).rejects.toThrow( - /Cannot resolve group-type:ghost_type referenced at group "kids"/, - ); + await expect(buildPlan(client, emptyState("h"), resources)).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING", type: "group-type", key: "ghost_type" }, + }); }); }); @@ -175,12 +183,20 @@ describe("permission domainId resolution", () => { }); const client = { get: async (path: string): Promise => { - if (path === "/group/grouptypes") return [{ id: 9, name: "Ministry Team" }] as T; + if (path === "/group/grouptypes/9") return { id: 9, name: "Ministry Team" } as T; if (path === "/permissions/group_type_role") return [] as T; throw new CtApiError(`not found: ${path}`, 404, null); }, }; - const { items } = await buildPermissionPlan(client, emptyState("h"), permissions); + const state = emptyState("h"); + state.externals!.ministry_team = { + type: "group-type", + key: "ministry_team", + id: 9, + identity: { name: "Ministry Team" }, + boundAt: "t", + }; + const { items } = await buildPermissionPlan(client, state, permissions); expect(items).toHaveLength(1); expect(items[0]?.domainId).toBe(9); // resolved from the catalog, not a raw number }); @@ -192,12 +208,20 @@ describe("permission domainId resolution", () => { }); const client = { get: async (path: string): Promise => { - if (path === "/group/grouptypes") return [{ id: 9, name: "Ministry Team" }] as T; + if (path === "/group/grouptypes/9") return { id: 9, name: "Ministry Team" } as T; if (path === "/permissions/group_type_role") return [] as T; throw new CtApiError(`not found: ${path}`, 404, null); }, }; - await expect(buildPermissionPlan(client, emptyState("h"), permissions)).rejects.toThrow( + const state = emptyState("h"); + state.externals!.ministry_team = { + type: "group-type", + key: "ministry_team", + id: 9, + identity: { name: "Ministry Team" }, + boundAt: "t", + }; + await expect(buildPermissionPlan(client, state, permissions)).rejects.toThrow( /Duplicate permission target after resolution: group_type_role #9/, ); }); @@ -258,9 +282,16 @@ describe("acceptance: one config, two hosts", () => { async function planFor(groupTypeId: number, state: State) { const { resources, permissions } = await evaluateConfig(config); const catalogs = { - "/group/grouptypes": [{ id: groupTypeId, name: "Ministry Team" }], + [`/group/grouptypes/${groupTypeId}`]: { id: groupTypeId, name: "Ministry Team" }, "/permissions/group_type_role": [], }; + state.externals!.ministry_team = { + type: "group-type", + key: "ministry_team", + id: groupTypeId, + identity: { name: "Ministry Team" }, + boundAt: "t", + }; const client = fakeHost(catalogs); const resolver = new Resolver({ client, state, desired: resources, host: state.host }); const { plan } = await buildPlan(client, state, resources, { resolver }); @@ -323,7 +354,7 @@ describe("portable ruleset snapshot files (#76)", () => { }; writeFileSync(join(dir, rulesetFile), JSON.stringify(authoredRuleset)); - /** State with only the (already-managed) dynamic group — the campus resolves from the live `/campuses` catalog. */ + /** State with the managed dynamic group plus this host's explicit external campus binding. */ function stateWithGroup(host: string): State { const s = emptyState(host); s.resources.all_mainz = { @@ -334,6 +365,13 @@ describe("portable ruleset snapshot files (#76)", () => { adoptedAt: "t", updatedAt: "t", }; + s.externals!.mainz = { + type: "campus", + id: host.includes("dev") ? 42 : 7, + key: "mainz", + identity: { name: "Mainz" }, + boundAt: "t", + }; return s; } @@ -362,7 +400,7 @@ describe("portable ruleset snapshot files (#76)", () => { // group) so the manual ruleset is a create → a PUT on apply, whose body we can inspect. const client = fakeHost({ "/groups/100": { name: "All", groupTypeId: 1 }, - "/campuses": [{ id: campusId, name: "Mainz" }], + [`/campuses/${campusId}`]: { id: campusId, name: "Mainz" }, }); const { plan } = await buildPlan(client, state, resources, { configDir: dir }); await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: () => "t" }); @@ -393,7 +431,7 @@ describe("portable ruleset snapshot files (#76)", () => { }; const client = fakeHost({ "/groups/100": { name: "All", groupTypeId: 1 }, - "/campuses": [{ id: campusId, name: "Mainz" }], + [`/campuses/${campusId}`]: { id: campusId, name: "Mainz" }, "/dynamicgroups/100/ruleset": [liveRuleset], "/dynamicgroups/100/status": { dynamicGroupStatus: "manual" }, }); diff --git a/tests/query-refs.test.ts b/tests/query-refs.test.ts index 2b965a9..7d5a857 100644 --- a/tests/query-refs.test.ts +++ b/tests/query-refs.test.ts @@ -17,7 +17,7 @@ import { } from "../src/config/query-refs.js"; import { q, churchQuery } from "../src/config/query.js"; import { normalizeRuleset } from "../src/engine/dynamic.js"; -import { deepMapRefs, refKey, type Ref, type RefKind } from "../src/resolve/refs.js"; +import { deepMapRefs, ref, refKey, type Ref, type RefKind } from "../src/resolve/refs.js"; const here = dirname(fileURLToPath(import.meta.url)); @@ -31,6 +31,7 @@ describe("VAR_REF_KINDS catalog (#76 Stage 1)", () => { "ctgroup.id": "group", "ctgroup.campusId": "campus", "ctgroup.groupTypeId": "group-type", + "ctgroup.groupStatusId": "group-status", "person.campusId": "campus", }); }); @@ -40,7 +41,7 @@ describe("VAR_REF_KINDS catalog (#76 Stage 1)", () => { // the (group-type, role-name) special case, not a lone name-based kind. groupStatusId has no REST // catalog (#67); isArchived/dateOfDeath are boolean/date literals. expect(VAR_REF_KINDS["role.id"]).toBeUndefined(); - expect(VAR_REF_KINDS["ctgroup.groupStatusId"]).toBeUndefined(); + expect(VAR_REF_KINDS["ctgroup.groupStatusId"]).toBe("group-status"); expect(VAR_REF_KINDS["person.isArchived"]).toBeUndefined(); expect(VAR_REF_KINDS["person.dateOfDeath"]).toBeUndefined(); }); @@ -93,16 +94,20 @@ describe("portablizeRuleset (#76 Stage 2)", () => { ]); }); - it("never REWRITES a catalog-less var (groupStatusId), but does report it left numeric (#101)", () => { + it("rewrites known groupStatusId values and preserves unknown ids with a warning (#157)", () => { const ruleset = { query: churchQuery(q.oneof("ctgroup.groupStatusId", [1, 2, 4])) }; const { ruleset: out, warnings } = portablizeRuleset(ruleset, { - idToKeyByKind: { group: new Map([[1, "nope"]]) }, + idToKeyByKind: { + "group-status": new Map([ + [1, "active"], + [2, "pending"], + ]), + }, }); const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; - expect(filter.oneof[1]).toEqual([1, 2, 4]); // never rewritten — no logical form exists - // …but silence here is what #101 was filed about: the ids ARE host-specific, so they are reported. - expect(warnings.map((w) => w.id)).toEqual([1, 2, 4]); - expect(new Set(warnings.map((w) => w.reason))).toEqual(new Set(["no-ref-kind"])); + expect(filter.oneof[1]).toEqual([ref.status("active"), ref.status("pending"), 4]); + expect(warnings.map((w) => w.id)).toEqual([4]); + expect(new Set(warnings.map((w) => w.reason))).toEqual(new Set(["unmanaged"])); }); it("does not mutate its input ruleset", () => { @@ -277,6 +282,11 @@ describe("portablizeRuleset (#76 Stage 2)", () => { [112, "bereich_kids"], [8, "team_kidsdienst"], ]), + "group-status": new Map([ + [1, "active"], + [2, "pending"], + [4, "finished"], + ]), }; const opts = { idToKeyByKind, roleCatalog, groupTypeIdToKey }; @@ -321,11 +331,14 @@ describe("portablizeRuleset (#76 Stage 2)", () => { id, ); } + for (const [id, key] of idToKeyByKind["group-status"]!) { + keyToId.set(refKey(ref.status(key)), id); + } const back = deepMapRefs(portable, (r: Ref) => keyToId.get(refKey(r))); expect(back).toEqual(normalized); }); - it("leaves the unmanaged group id (1246) and the groupStatusId lists numeric, and reports BOTH (#101)", () => { + it("leaves only the unmanaged group id numeric; known group statuses become refs (#157)", () => { const { ruleset: portable, warnings } = portablizeRuleset(normalized, opts); const json = JSON.stringify(portable); expect(json).toContain("1246"); // unmanaged group id survives numeric @@ -335,12 +348,8 @@ describe("portablizeRuleset (#76 Stage 2)", () => { reason: "unmanaged", detail: "not under management — `ct adopt group ` for each (then re-adopt) makes them portable", }); - // groupStatusId is never REWRITTEN (no catalog exists) but is still a host-specific id in a - // cross-host file, so #101 reports it rather than letting the capture look fully portable. - expect( - warnings.filter((w) => w.var === "ctgroup.groupStatusId").every((w) => w.reason === "no-ref-kind"), - ).toBe(true); - expect(warnings.some((w) => w.var === "ctgroup.groupStatusId")).toBe(true); + expect(json).toContain('"kind":"group-status"'); + expect(warnings.some((w) => w.var === "ctgroup.groupStatusId")).toBe(false); }); it("scanUnportablized reports the same ids from the ALREADY-PORTABLIZED file (#101 plan-time check)", () => { @@ -363,9 +372,8 @@ describe("portablizeRuleset (#76 Stage 2)", () => { expect(left.filter((w) => w.var === "ctgroup.id").every((w) => w.reason === "left-numeric")).toBe(true); expect(left.filter((w) => w.var === "role.id").every((w) => w.reason === "left-numeric")).toBe(true); expect(left.some((w) => /is not under management|no \/group\/roles row/.test(w.detail))).toBe(false); - // The catalog-less dimension keeps its own reason: that one IS derivable without any lookup. expect( - left.filter((w) => w.var === "ctgroup.groupStatusId").every((w) => w.reason === "no-ref-kind"), + left.filter((w) => w.var === "ctgroup.groupStatusId").every((w) => w.reason === "left-numeric"), ).toBe(true); }); }); diff --git a/tests/release-command.test.ts b/tests/release-command.test.ts new file mode 100644 index 0000000..f050023 --- /dev/null +++ b/tests/release-command.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { emptyState, loadState, saveState } from "../src/state/state.js"; +import { unadoptCommand, unuseCommand } from "../src/commands/release.js"; + +const HOST = "https://example.church.tools"; +const saved = { host: process.env.CT_HOST, envs: process.env.CT_ENVS, config: process.env.CT_CONFIG }; + +describe("ct unuse / ct unadopt", () => { + let directory: string; + let statePath: string; + let configPath: string; + let envsPath: string; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "ct-release-")); + statePath = join(directory, "state.json"); + configPath = join(directory, "ct.config.ts"); + envsPath = join(directory, "ct.envs.json"); + delete process.env.CT_HOST; + process.env.CT_ENVS = envsPath; + process.env.CT_CONFIG = configPath; + await writeFile( + envsPath, + JSON.stringify({ environments: { prod: { host: HOST, state: statePath, protected: true } } }), + ); + await writeFile(configPath, "export default () => {};"); + const state = emptyState(HOST); + state.resources.owned = { + type: "group", + key: "owned", + id: 10, + fields: { name: "Owned" }, + adoptedAt: "t", + updatedAt: "t", + }; + state.externals!.shared = { + type: "group", + key: "shared", + id: 20, + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + await saveState(statePath, state); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + process.exitCode = 0; + if (saved.host === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = saved.host; + if (saved.envs === undefined) delete process.env.CT_ENVS; + else process.env.CT_ENVS = saved.envs; + if (saved.config === undefined) delete process.env.CT_CONFIG; + else process.env.CT_CONFIG = saved.config; + await rm(directory, { recursive: true, force: true }); + }); + + it("unuses only an external after exact environment confirmation", async () => { + await unuseCommand().parseAsync(["group", "shared", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }); + const state = await loadState(statePath, HOST); + expect(state.externals?.shared).toBeUndefined(); + expect(state.resources.owned).toBeDefined(); + }); + + it("unadopts only a managed entry after exact environment confirmation", async () => { + await unadoptCommand().parseAsync(["group", "owned", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }); + const state = await loadState(statePath, HOST); + expect(state.resources.owned).toBeUndefined(); + expect(state.externals?.shared).toBeDefined(); + }); + + it("refuses missing or mismatching confirmation without changing state", async () => { + await unuseCommand().parseAsync(["group", "shared", "--env", "prod"], { from: "user" }); + expect(process.exitCode).toBe(1); + expect((await loadState(statePath, HOST)).externals?.shared).toBeDefined(); + + process.exitCode = 0; + await unuseCommand().parseAsync(["group", "shared", "--env", "prod", "--confirm-env", "dev"], { + from: "user", + }); + expect(process.exitCode).toBe(1); + expect((await loadState(statePath, HOST)).externals?.shared).toBeDefined(); + }); + + it("fails closed while the external key is referenced, unless --force is explicit", async () => { + await writeFile( + configPath, + `export default (ct) => { ct.groupRole({ key: "reader", group: "shared", role: "Reader", grants: [] }); };`, + ); + await expect( + unuseCommand().parseAsync(["group", "shared", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }), + ).rejects.toThrow(/still declared or referenced/); + expect((await loadState(statePath, HOST)).externals?.shared).toBeDefined(); + + await unuseCommand().parseAsync( + ["group", "shared", "--env", "prod", "--confirm-env", "prod", "--force"], + { from: "user" }, + ); + expect((await loadState(statePath, HOST)).externals?.shared).toBeUndefined(); + }); + + it("recognizes hierarchy references outside permission declarations", async () => { + await writeFile( + configPath, + `export default (ct) => { ct.group({ key: "child", name: "Child", parents: ["shared"] }); };`, + ); + await expect( + unuseCommand().parseAsync(["group", "shared", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }), + ).rejects.toThrow(/still declared or referenced/); + expect((await loadState(statePath, HOST)).externals?.shared).toBeDefined(); + }); + + it("rejects crossing the managed/external boundary", async () => { + await expect( + unuseCommand().parseAsync(["group", "owned", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }), + ).rejects.toThrow(/is managed, not external/); + await expect( + unadoptCommand().parseAsync(["group", "shared", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }), + ).rejects.toThrow(/is external, not managed/); + }); + + it("keeps dry-run side-effect free and requires no confirmation", async () => { + await unuseCommand().parseAsync(["group", "shared", "--env", "prod", "--dry-run"], { + from: "user", + }); + expect((await loadState(statePath, HOST)).externals?.shared).toBeDefined(); + }); +}); diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts index 16b3659..361368d 100644 --- a/tests/resolver.test.ts +++ b/tests/resolver.test.ts @@ -22,6 +22,17 @@ function stateWith(resources: State["resources"]): State { return { ...emptyState("https://x.church.tools"), resources }; } +function stateWithExternals( + externals: NonNullable, + host = "https://x.church.tools", +): State { + return { ...emptyState(host), externals }; +} + +function external(type: string, key: string, id: number, identity: Record) { + return { type, key, id, identity, boundAt: "t" }; +} + const NO_DESIRED: DesiredResource[] = []; describe("Resolver.resolve", () => { @@ -35,39 +46,85 @@ describe("Resolver.resolve", () => { expect(client.calls).toEqual({}); // state hit, no /campuses fetch }); - it("resolves a campus from the live catalog by slug(name)", async () => { - const client = fakeClient({ - "/campuses": [ - { id: 3, name: "Berlin", shorty: "BE" }, - { id: 5, name: "Mainz" }, - ], - }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + it("resolves an external campus only after validating its bound id live", async () => { + const client = fakeClient({ "/campuses/5": { id: 5, name: "Mainz", shorty: "MZ" } }); + const state = stateWithExternals({ mainz: external("campus", "mainz", 5, { name: "Mainz" }) }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); expect(await r.resolve(ref.campus("mainz"), "site")).toBe(5); + expect(client.calls).toEqual({ "/campuses/5": 1 }); }); - it("resolves a group type from the live catalog", async () => { - const client = fakeClient({ "/group/grouptypes": [{ id: 2, name: "Ministry Team" }] }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + it("resolves an external group type", async () => { + const client = fakeClient({ "/group/grouptypes/2": { id: 2, name: "Ministry Team" } }); + const state = stateWithExternals({ + ministry_team: external("group-type", "ministry_team", 2, { name: "Ministry Team" }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); expect(await r.resolve(ref.groupType("ministry_team"), "site")).toBe(2); }); + it("blocks a changed hard identity with a field diff and acceptance command", async () => { + const client = fakeClient({ + "/groups/9": { id: 9, name: "Renamed", information: { groupTypeId: 3, campusId: 99 } }, + }); + const state = stateWithExternals({ + team: external("group", "team", 9, { name: "Team", groupTypeId: 2 }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED, host: "hostA" }); + await expect(r.resolve(ref.group("team"), "ruleset")).rejects.toMatchObject({ + details: { + reason: "EXTERNAL_IDENTITY_MISMATCH", + identityDiff: expect.arrayContaining([ + expect.objectContaining({ field: "name", expected: "Team", actual: "Renamed" }), + expect.objectContaining({ field: "groupTypeId", expected: 2, actual: 3 }), + ]), + }, + message: expect.stringContaining("ct use group 9 --key team"), + }); + }); + + it("ignores display-only changes while validating a bound external", async () => { + const client = fakeClient({ + "/groups/9": { id: 9, name: "Team", information: { groupTypeId: 2, campusId: 99, groupStatusId: 4 } }, + }); + const state = stateWithExternals({ + team: external("group", "team", 9, { name: "Team", groupTypeId: 2 }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); + await expect(r.resolve(ref.group("team"), "ruleset")).resolves.toBe(9); + }); + + it("blocks a stale external id and directs repair to the owner, not ct use", async () => { + const client = fakeClient({}); + const state = stateWithExternals({ + team: { ...external("group", "team", 9, { name: "Team", groupTypeId: 2 }), owner: "master" }, + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); + await expect(r.resolve(ref.group("team"), "ruleset")).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_STALE" }, + message: expect.not.stringContaining("ct use group 9"), + }); + }); + // PERSON statuses DO have a flat catalog (`GET /statuses`), unlike GROUP statuses in the test below (#90). - it("resolves a person status from the /statuses catalog by slug(name)", async () => { + it("resolves externally bound person statuses, including id 0", async () => { const client = fakeClient({ - "/statuses": [ - { id: 0, name: "Unbekannt" }, - { id: 4, name: "3 - Group Active" }, - { id: 6, name: "5 - Core" }, - ], + "/statuses/0": { id: 0, name: "Unbekannt" }, + "/statuses/4": { id: 4, name: "3 - Group Active" }, + "/statuses/6": { id: 6, name: "5 - Core" }, }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + const state = stateWithExternals({ + unbekannt: external("person-status", "unbekannt", 0, { name: "Unbekannt" }), + "3_group_active": external("person-status", "3_group_active", 4, { name: "3 - Group Active" }), + "5 - Core": external("person-status", "5 - Core", 6, { name: "5 - Core" }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); expect(await r.resolve(ref.personStatus("3_group_active"), "site")).toBe(4); // Exact-name fallback, for a name that does not survive slugging cleanly. expect(await r.resolve(ref.personStatus("5 - Core"), "site")).toBe(6); // Status id 0 must come back as 0, not be mistaken for "unresolved". expect(await r.resolve(ref.personStatus("unbekannt"), "site")).toBe(0); - expect(client.calls["/statuses"]).toBe(1); // one fetch, memoized across all three + expect(client.calls["/statuses/0"]).toBe(1); }); it("errors on a person-status ref with no catalog match", async () => { @@ -76,36 +133,28 @@ describe("Resolver.resolve", () => { await expect(r.resolve(ref.personStatus("5_core"), "site")).rejects.toThrow(/5_core/); }); - it("has no group-status catalog — a group-status ref is a hard error, never resolved against /group/memberstatus (#67)", async () => { - // /group/memberstatus IS mocked here (as a member-statuses catalog would be on a live host), to - // prove the resolver never even looks at it for a group-status ref — group statuses have no - // REST catalog to resolve against (a different, unrelated dimension from member statuses). - const client = fakeClient({ - "/group/memberstatus": [ - { id: 1, name: "Active" }, - { id: 2, name: "Candidate" }, - ], + it("resolves group statuses by technical name across hosts with different ids (#157)", async () => { + const clientA = fakeClient({ + "/person/masterdata": { groupStatuses: [{ id: 41, name: "active", nameTranslated: "Aktiv" }] }, }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); - await expect(r.resolve(ref.status("candidate"), "site")).rejects.toThrow( - /Cannot resolve group-status:candidate referenced at site on hostA/, - ); - expect(client.calls).toEqual({}); // /group/memberstatus never fetched for a group-status ref + const clientB = fakeClient({ + "/person/masterdata": { groupStatuses: [{ id: 7, name: "active", nameTranslated: "Aktiv" }] }, + }); + const resolverA = new Resolver({ client: clientA, state: emptyState("hostA"), desired: NO_DESIRED }); + const resolverB = new Resolver({ client: clientB, state: emptyState("hostB"), desired: NO_DESIRED }); + expect(await resolverA.resolve(ref.status("active"), "site")).toBe(41); + expect(await resolverB.resolve(ref.status("active"), "site")).toBe(7); + expect(clientA.calls).toEqual({ "/person/masterdata": 1 }); + expect(clientB.calls).toEqual({ "/person/masterdata": 1 }); }); - it("gives the same actionable no-catalog message as the eval-time guard, not the generic 'declare/adopt it' advice (#67 reviewer follow-up)", async () => { - // A `groupStatusId: ref.status(...)` value bypasses the eval-time guard in context.ts (the - // id-field escape hatch accepts any Ref) and reaches the resolver directly. The generic - // notFound() advice ("Declare/adopt it, fix the key/name, or use a numeric id") is wrong here — - // there is no group-status resource type and no catalog to adopt against — so this must be the - // SAME message context.ts's eval-time guard uses, not the generic one. - const client = fakeClient({}); + it("keeps numeric ids as the backward-compatible escape hatch and errors on unknown logical names", async () => { + const client = fakeClient({ "/person/masterdata": { groupStatuses: [{ id: 1, name: "active" }] } }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); await expect(r.resolve(ref.status("candidate"), 'group "g".groupStatusId')).rejects.toThrow( - 'Cannot resolve group-status:candidate referenced at group "g".groupStatusId on hostA: group statuses ' + - "have no REST catalog (GET /group/memberstatus is a different dimension: member statuses, string ids " + - '— verified 2026-07-10). Declare a numeric "groupStatusId" instead (e.g. "groupStatusId: 1").', + /no live group-status at \/person\/masterdata matches key "candidate"/, ); + expect(await r.resolveValue(99, "site")).toBe(99); }); it("returns a pending marker for a same-run-declared managed target (not yet in state)", async () => { @@ -125,17 +174,19 @@ describe("Resolver.resolve", () => { ], }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); - await expect(r.resolve(ref.campus("mainz"), 'group "g"')).rejects.toThrow( - /Ambiguous campus:mainz referenced at group "g" on hostA: 2 live campuss match/, - ); + await expect(r.resolve(ref.campus("mainz"), 'group "g"')).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_AMBIGUOUS" }, + message: expect.stringContaining("ct use campus 2 --key mainz"), + }); }); it("throws a clear error on an unknown reference (kind + key + site + host)", async () => { const client = fakeClient({ "/campuses": [{ id: 1, name: "Berlin" }] }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostB" }); - await expect(r.resolve(ref.campus("mainz"), 'group "g".campusId')).rejects.toThrow( - /Cannot resolve campus:mainz referenced at group "g".campusId on hostB/, - ); + await expect(r.resolve(ref.campus("mainz"), 'group "g".campusId')).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING" }, + message: expect.stringContaining('resource: campus "mainz"'), + }); }); it("resolves a group_role (group, role) pair to the pairing domainId via the group's role list (#25)", async () => { @@ -166,12 +217,12 @@ describe("Resolver.resolve", () => { ); }); - it("errors when a group_role names a group that isn't managed", async () => { - const client = fakeClient({}); + it("errors when a group_role names a group without an external binding", async () => { + const client = fakeClient({ "/groups": [] }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - await expect(r.resolve(ref.groupRole("ghost", "Leiter"), 'perm "p"')).rejects.toThrow( - /no managed group named "ghost".*pass a numeric id/is, - ); + await expect(r.resolve(ref.groupRole("ghost", "Leiter"), 'perm "p"')).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING", type: "group", key: "ghost" }, + }); }); it("errors when a group_role names a same-run-declared (not-yet-created) group", async () => { @@ -183,17 +234,21 @@ describe("Resolver.resolve", () => { ); }); - it("errors on a group ref with no managed match (groups have no catalog)", async () => { - const client = fakeClient({}); + it("errors on an unbound group ref without consuming discovery", async () => { + const client = fakeClient({ "/groups": [] }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - await expect(r.resolve(ref.group("ghost"), "site")).rejects.toThrow(/no managed group named "ghost"/); + await expect(r.resolve(ref.group("ghost"), "site")).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING" }, + }); }); - it("falls back to an exact-name secondary match when the slug misses", async () => { + it("reports an exact-name discovery match but still requires ct use", async () => { const client = fakeClient({ "/group/grouptypes": [{ id: 8, name: "K-9" }] }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - // slug("K-9") === "k_9", so ref.groupType("k_9") hits the slug path; "K-9" hits the exact path. - expect(await r.resolve(ref.groupType("K-9"), "site")).toBe(8); + await expect(r.resolve(ref.groupType("K-9"), "site")).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING" }, + message: expect.stringContaining("ct use group-type 8 --key K-9"), + }); }); }); @@ -213,8 +268,16 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { ]; it("resolves a (group-type, role) pair to its groupTypeRoleId, disambiguating same-named roles", async () => { - const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + const client = fakeClient({ + "/group/grouptypes/12": groupTypesCatalog[0], + "/group/grouptypes/2": groupTypesCatalog[1], + "/group/roles": rolesCatalog, + }); + const state = stateWithExternals({ + local_lead: external("group-type", "local_lead", 12, { name: "Local Lead" }), + team: external("group-type", "team", 2, { name: "Team" }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); // Same role NAME ("Leiter"), different group type → different id: the pair disambiguates. expect(await r.resolve(ref.groupTypeRole("local_lead", "Leiter"), "site")).toBe(84); expect(await r.resolve(ref.groupTypeRole("team", "Leiter"), "site")).toBe(16); @@ -241,8 +304,9 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { }); it("errors clearly when no role of that name exists on the group type (lists candidates)", async () => { - const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); + const client = fakeClient({ "/group/grouptypes/2": groupTypesCatalog[1], "/group/roles": rolesCatalog }); + const state = stateWithExternals({ team: external("group-type", "team", 2, { name: "Team" }) }); + const r = new Resolver({ client, state, desired: NO_DESIRED, host: "hostA" }); await expect(r.resolve(ref.groupTypeRole("team", "Ghost"), 'ruleset "r"')).rejects.toThrow( /group-type-role\(groupType=team, role=Ghost\) referenced at ruleset "r" on hostA: group type #2 has no role named "Ghost".*available: "Leiter", "Organisator".*pass a numeric id/is, ); @@ -250,13 +314,16 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { it("errors listing candidates when two roles on the same group type share the name (ambiguous)", async () => { const client = fakeClient({ - "/group/grouptypes": groupTypesCatalog, + "/group/grouptypes/12": groupTypesCatalog[0], "/group/roles": [ { id: 84, name: "Leiter", groupTypeId: 12 }, { id: 800, name: "Leiter", groupTypeId: 12 }, // duplicate on the SAME group type ], }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); + const state = stateWithExternals({ + local_lead: external("group-type", "local_lead", 12, { name: "Local Lead" }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED, host: "hostA" }); await expect(r.resolve(ref.groupTypeRole("local_lead", "Leiter"), "site")).rejects.toThrow( /Ambiguous group-type-role\(groupType=local_lead, role=Leiter\).*2 roles on group type #12 match — "Leiter" \(#84\), "Leiter" \(#800\)/, ); @@ -265,9 +332,9 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { it("errors when the group-type key itself cannot be resolved", async () => { const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostB" }); - await expect(r.resolve(ref.groupTypeRole("ghost_type", "Leiter"), "site")).rejects.toThrow( - /Cannot resolve group-type:ghost_type referenced at site on hostB/, - ); + await expect(r.resolve(ref.groupTypeRole("ghost_type", "Leiter"), "site")).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING", type: "group-type", key: "ghost_type" }, + }); }); it("rejects a same-run-declared (not-yet-created) group type — id only exists once it does", async () => { @@ -283,12 +350,14 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { describe("Resolver.resolveValue", () => { it("deep-rewrites refs to ids and fetches each catalog at most once", async () => { const client = fakeClient({ - "/campuses": [ - { id: 5, name: "Mainz" }, - { id: 6, name: "Berlin" }, - ], + "/campuses/5": { id: 5, name: "Mainz" }, + "/campuses/6": { id: 6, name: "Berlin" }, }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + const state = stateWithExternals({ + mainz: external("campus", "mainz", 5, { name: "Mainz" }), + berlin: external("campus", "berlin", 6, { name: "Berlin" }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); const value = { campusId: ref.campus("mainz"), query: { @@ -305,7 +374,8 @@ describe("Resolver.resolveValue", () => { query: { or: [{ "==": [{ var: "ctgroup.campusId" }, 5] }, { "==": [{ var: "ctgroup.campusId" }, 6] }] }, untouched: 42, }); - expect(client.calls["/campuses"]).toBe(1); // cached across the two mainz refs + the berlin ref + expect(client.calls["/campuses/5"]).toBe(1); // cached across the two mainz refs + expect(client.calls["/campuses/6"]).toBe(1); }); it("returns the original reference untouched when there are no refs", async () => { @@ -343,10 +413,13 @@ describe("catalogs are read PAGINATED (#99 review)", () => { [{ id: 42, name: "Koblenz" }], // page 2 — invisible to a single `get` ]; - it("resolves a campus that lives past CT's default first page", async () => { + it("discovers a campus past the default first page but still refuses an ephemeral binding", async () => { const client = pagingClient({ "/campuses": campusPages }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - expect(await r.resolve(ref.campus("koblenz"), "site")).toBe(42); + await expect(r.resolve(ref.campus("koblenz"), "site")).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING" }, + message: expect.stringContaining("ct use campus 42 --key koblenz"), + }); expect(client.calls["/campuses"]).toBe(1); // still fetched once per run }); diff --git a/tests/server/api.test.ts b/tests/server/api.test.ts new file mode 100644 index 0000000..97e05b3 --- /dev/null +++ b/tests/server/api.test.ts @@ -0,0 +1,282 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createCtApiServer, type CtApiServer } from "../../src/server/app.js"; + +interface Running { + api: CtApiServer; + base: string; + directory: string; +} + +const running: Running[] = []; + +async function start(options: { origins?: string[]; bodyLimitBytes?: number } = {}): Promise { + const directory = await mkdtemp(join(tmpdir(), "ct-api-")); + const api = await createCtApiServer({ + workspaceRoots: [directory], + allowedOrigins: options.origins, + bodyLimitBytes: options.bodyLimitBytes, + }); + await new Promise((resolve) => api.server.listen(0, "127.0.0.1", resolve)); + const address = api.server.address(); + if (!address || typeof address === "string") throw new Error("No TCP server address."); + const value = { api, base: `http://127.0.0.1:${address.port}`, directory }; + running.push(value); + return value; +} + +async function pair(target: Running): Promise { + const response = await fetch(`${target.base}/api/v1/pair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: target.api.pairingCode }), + }); + expect(response.status).toBe(200); + return ((await response.json()) as { result: { token: string } }).result.token; +} + +async function pairWith(target: Running, capabilities: string[]): Promise { + const response = await fetch(`${target.base}/api/v1/pair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: target.api.pairingCode, capabilities }), + }); + expect(response.status).toBe(200); + return ((await response.json()) as { result: { token: string } }).result.token; +} + +afterEach(async () => { + await Promise.all( + running.splice(0).map(async (target) => { + const closed = new Promise((resolve) => target.api.server.close(() => resolve())); + target.api.server.closeAllConnections(); + await closed; + await rm(target.directory, { recursive: true, force: true }); + }), + ); +}); + +describe("versioned REST API", () => { + it("serves public health and the generated OpenAPI document", async () => { + const target = await start(); + const health = await fetch(`${target.base}/api/v1/health`); + expect(health.status).toBe(200); + expect(await health.json()).toMatchObject({ + apiVersion: "v1", + operation: "system.health", + result: { status: "ok" }, + }); + + const spec = await fetch(`${target.base}/api/v1/openapi.json`); + expect(spec.status).toBe(200); + expect(await spec.json()).toMatchObject({ openapi: "3.1.0", servers: [{ url: "/" }] }); + }); + + it("serves a CSP-protected Scalar API reference backed by the generated contract", async () => { + const target = await start(); + const response = await fetch(`${target.base}/api/docs`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(response.headers.get("content-security-policy")).toContain("https://cdn.jsdelivr.net"); + + const html = await response.text(); + expect(html).toContain("@scalar/api-reference@1.67.0"); + expect(html).toContain("url: '/api/v1/openapi.json'"); + expect(html).toContain("disabled: true"); + expect(html).toContain("telemetry: false"); + + const spec = (await (await fetch(`${target.base}/api/v1/openapi.json`)).json()) as { + paths: Record }>>; + }; + expect(spec.paths["/api/docs"]?.get?.responses["200"]?.content).toHaveProperty("text/html"); + }); + + it("exchanges the pairing code once and requires the scoped session", async () => { + const target = await start(); + const workspace = target.api.workspaces[0]!; + expect((await fetch(`${target.base}/api/v1/workspaces`)).status).toBe(401); + + const token = await pair(target); + const second = await fetch(`${target.base}/api/v1/pair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: target.api.pairingCode }), + }); + expect(second.status).toBe(400); + + const response = await fetch(`${target.base}/api/v1/workspaces`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: { workspaces: [{ id: workspace.id }] }, + }); + }); + + it("rejects unlisted browser origins before pairing or authentication", async () => { + const target = await start({ origins: ["https://extension.example"] }); + const denied = await fetch(`${target.base}/api/v1/health`, { + headers: { Origin: "https://evil.example" }, + }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ code: "ORIGIN_DENIED" }); + }); + + it("enforces capability-scoped sessions", async () => { + const target = await start(); + const token = await pairWith(target, ["read"]); + const workspace = target.api.workspaces[0]!; + const response = await fetch(`${target.base}/api/v1/workspaces/${workspace.id}/input/snapshots`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + document: { schemaVersion: "1", clientRevision: "r1", payload: {} }, + }), + }); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: "CAPABILITY_DENIED" }); + }); + + it("stores immutable versioned input snapshots inside the configured workspace", async () => { + const target = await start(); + const token = await pair(target); + const workspace = target.api.workspaces[0]!; + const document = { schemaVersion: "1", clientRevision: "rev-7", payload: { title: "Example" } }; + const created = await fetch(`${target.base}/api/v1/workspaces/${workspace.id}/input/snapshots`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Idempotency-Key": "snapshot-retry", + }, + body: JSON.stringify({ document }), + }); + expect(created.status).toBe(201); + const payload = (await created.json()) as { result: { value: { digest: string } } }; + expect(payload.result.value.digest).toMatch(/^[a-f0-9]{64}$/); + + const replay = await fetch(`${target.base}/api/v1/workspaces/${workspace.id}/input/snapshots`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Idempotency-Key": "snapshot-retry", + }, + body: JSON.stringify({ document }), + }); + expect(replay.headers.get("Idempotency-Replayed")).toBe("true"); + expect((await replay.json()) as { result: unknown }).toMatchObject({ result: payload.result }); + + const conflict = await fetch(`${target.base}/api/v1/workspaces/${workspace.id}/input/snapshots`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Idempotency-Key": "snapshot-retry", + }, + body: JSON.stringify({ document: { ...document, clientRevision: "different" } }), + }); + expect(conflict.status).toBe(409); + expect(await conflict.json()).toMatchObject({ code: "IDEMPOTENCY_CONFLICT" }); + + const fetched = await fetch( + `${target.base}/api/v1/workspaces/${workspace.id}/input/snapshots/${payload.result.value.digest}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + expect(await fetched.json()).toMatchObject({ result: { value: document, persisted: true } }); + }); + + it("projects external-reference ownership and release operations through HTTP", async () => { + const target = await start(); + const token = await pair(target); + const workspace = target.api.workspaces[0]!; + const project = target.directory; + await writeFile(join(project, "ct.config.ts"), "export default () => {};\n"); + await writeFile( + join(project, "ct.envs.json"), + JSON.stringify({ + environments: { + prod: { host: "https://example.church.tools", state: "ct-state.prod.json" }, + }, + }), + ); + await writeFile( + join(project, "ct-state.prod.json"), + JSON.stringify({ + version: 2, + host: "https://example.church.tools", + resources: {}, + externals: { + shared: { + type: "group", + key: "shared", + id: 7, + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "2026-09-04T00:00:00.000Z", + }, + }, + }), + ); + + const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + const ownership = await fetch(`${target.base}/api/v1/workspaces/${workspace.id}/ownership/check`, { + method: "POST", + headers, + body: JSON.stringify({ root: ".", environment: "prod" }), + }); + expect(ownership.status).toBe(200); + expect(await ownership.json()).toMatchObject({ + operation: "ownership.check", + result: { value: { projects: [{}] } }, + }); + + const prepared = await fetch( + `${target.base}/api/v1/workspaces/${workspace.id}/releases/external/prepare/group/shared`, + { + method: "POST", + headers, + body: JSON.stringify({ environment: "prod", configPath: "ct.config.ts" }), + }, + ); + expect(prepared.status).toBe(201); + const preview = (await prepared.json()) as { + result: { id: string; confirmation: { type: string; expected: string } }; + }; + expect(preview.result.confirmation).toEqual({ type: "environment", expected: "prod" }); + + const executed = await fetch( + `${target.base}/api/v1/workspaces/${workspace.id}/releases/external/execute/${preview.result.id}`, + { + method: "POST", + headers, + body: JSON.stringify({ + environment: "prod", + configPath: "ct.config.ts", + confirmation: { type: "environment", value: "prod" }, + }), + }, + ); + expect(executed.status).toBe(200); + expect(await executed.json()).toMatchObject({ + operation: "release.external", + result: { operation: "unuse", value: { removed: true, churchToolsContacted: false } }, + }); + const state = JSON.parse(await readFile(join(project, "ct-state.prod.json"), "utf8")) as { + externals: Record; + }; + expect(state.externals).toEqual({}); + }); + + it("enforces request-size limits without reflecting request contents", async () => { + const target = await start({ bodyLimitBytes: 16 }); + const response = await fetch(`${target.base}/api/v1/pair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: "secret-that-is-too-large" }), + }); + expect(response.status).toBe(413); + expect(await response.text()).not.toContain("secret-that-is-too-large"); + }); +}); diff --git a/tests/server/security.test.ts b/tests/server/security.test.ts new file mode 100644 index 0000000..f234d46 --- /dev/null +++ b/tests/server/security.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { SessionManager } from "../../src/server/session.js"; +import { WorkspaceRegistry } from "../../src/server/workspaces.js"; +import { OperationRunStore } from "../../src/server/runs.js"; +import { isExactAllowedOrigin, serverCommand } from "../../src/commands/server.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe("REST security primitives", () => { + it("contains all client-selected paths in configured roots", async () => { + const root = await mkdtemp(join(tmpdir(), "ct-workspace-")); + directories.push(root); + const registry = await WorkspaceRegistry.create([root]); + const workspace = registry.workspaces[0]!; + expect(registry.resolveWithin(workspace, "instances/dev")).toBe(join(workspace.path, "instances/dev")); + expect(() => registry.resolveWithin(workspace, "../escape")).toThrow(/escapes/); + }); + + it("rejects symlink escapes below an allowed workspace", async () => { + const root = await mkdtemp(join(tmpdir(), "ct-workspace-")); + const outside = await mkdtemp(join(tmpdir(), "ct-outside-")); + directories.push(root, outside); + await symlink(outside, join(root, "escape")); + const registry = await WorkspaceRegistry.create([root]); + await expect(registry.resolveSafeWithin(registry.workspaces[0]!, "escape/file.json")).rejects.toThrow( + /symlink/, + ); + }); + + it("consumes pairing codes once and limits session capabilities", () => { + const sessions = new SessionManager(() => new Date("2026-01-01T00:00:00Z")); + const paired = sessions.pair(sessions.pairingCode, ["read"]); + expect([...paired.session.capabilities]).toEqual(["read"]); + expect(sessions.authenticate(paired.token)?.id).toBe(paired.session.id); + expect(() => sessions.pair(sessions.pairingCode)).toThrow(/Invalid or expired/); + }); + + it("supports cooperative cancellation through the shared observer", () => { + const runs = new OperationRunStore(); + const { run, observer } = runs.create("plan"); + runs.cancel(run.id); + expect(runs.get(run.id).status).toBe("cancelled"); + expect(() => observer.emit({ type: "phase-started", phase: "next" })).toThrow(/cancelled/); + }); + + it("refuses unsafe non-loopback bindings before opening a listener", async () => { + await expect(serverCommand().parseAsync(["--host", "0.0.0.0"], { from: "user" })).rejects.toThrow( + /requires --trusted-proxy/, + ); + await expect( + serverCommand().parseAsync( + [ + "--host", + "0.0.0.0", + "--trusted-proxy", + "--public-url", + "http://plain.example", + "--allow-origin", + "https://extension.example", + ], + { from: "user" }, + ), + ).rejects.toThrow(/must use HTTPS/); + }); + + it("accepts exact browser-extension origins but no paths or wildcards", () => { + expect(isExactAllowedOrigin("chrome-extension://abcdefghijklmnop")).toBe(true); + expect(isExactAllowedOrigin("https://extension.example")).toBe(true); + expect(isExactAllowedOrigin("https://extension.example/path")).toBe(false); + expect(() => isExactAllowedOrigin("*")).toThrow(); + }); +}); diff --git a/tests/state-rm-command.test.ts b/tests/state-rm-command.test.ts index 72e6f79..0f4af09 100644 --- a/tests/state-rm-command.test.ts +++ b/tests/state-rm-command.test.ts @@ -31,7 +31,11 @@ const originalHost = process.env.CT_HOST; const originalConfig = process.env.CT_CONFIG; async function run(args: string[]): Promise { - await stateCommand().parseAsync(["rm", ...args], { from: "user" }); + const key = args[1]; + await stateCommand().parseAsync( + ["rm", ...args, ...(key && !args.includes("--dry-run") ? ["--confirm-key", key] : [])], + { from: "user" }, + ); } /** A state file holding two adopted role definitions and one campus. */ @@ -92,6 +96,15 @@ afterEach(async () => { }); describe("ct state rm (#122)", () => { + it("requires typed confirmation before the low-level state mutation", async () => { + await stateCommand().parseAsync(["rm", "group", "youth", "--state", statePath], { + from: "user", + }); + expect(process.exitCode).toBe(1); + expect((await loadState(statePath, HOST)).resources.youth).toBeDefined(); + process.exitCode = 0; + }); + it("removes the entry and contacts nothing", async () => { await run(["group-role", "appmodule_write", "--state", statePath]); const state = await loadState(statePath, HOST); @@ -115,7 +128,7 @@ describe("ct state rm (#122)", () => { `export default (ct) => { ct.groupRole({ key: "youth_leiter", group: "youth", role: "Leiter", grants: ["churchcore:administer settings"] }); };`, ); await expect(run(["group", "youth", "--state", statePath])).rejects.toThrow( - /still declared in the config/, + /still declared or referenced in the config/, ); const state = await loadState(statePath, HOST); expect(state.resources.youth).toBeDefined(); @@ -127,7 +140,7 @@ describe("ct state rm (#122)", () => { `export default (ct) => { ct.groupRole({ key: "p", id: 77, grants: [{ right: "churchgroup:view group", scope: ["youth"] }] }); };`, ); await expect(run(["group", "youth", "--state", statePath])).rejects.toThrow( - /still declared in the config/, + /still declared or referenced in the config/, ); const state = await loadState(statePath, HOST); expect(state.resources.youth).toBeDefined(); @@ -149,7 +162,7 @@ describe("ct state rm (#122)", () => { `export default (ct) => { ct.roleDefinition({ key: "appmodule_write", name: "Write", groupTypeId: 2 }); };`, ); await expect(run(["group-role", "appmodule_write", "--state", statePath])).rejects.toThrow( - /still declared in the config/, + /still declared or referenced in the config/, ); const state = await loadState(statePath, HOST); expect(state.resources.appmodule_write).toBeDefined(); diff --git a/tests/state.test.ts b/tests/state.test.ts index 4aff229..878e737 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -155,10 +155,54 @@ describe("state.loadState", () => { }); it("rejects an unsupported version", async () => { - await writeFile(statePath, JSON.stringify({ version: 2, host: HOST, resources: {} }), "utf8"); + await writeFile(statePath, JSON.stringify({ version: 3, host: HOST, resources: {} }), "utf8"); await expect(loadState(statePath, HOST)).rejects.toThrow(/Unsupported state file version/); }); + it("migrates version 1 in memory to version 2 with an empty externals map", async () => { + await writeFile( + statePath, + JSON.stringify({ + version: 1, + host: HOST, + resources: { + mainz: { + type: "campus", + id: 0, + key: "mainz", + fields: { name: "Mainz" }, + adoptedAt: "t", + updatedAt: "t", + }, + }, + }), + "utf8", + ); + const state = await loadState(statePath, HOST); + expect(state.version).toBe(2); + expect(state.externals).toEqual({}); + expect(state.resources.mainz?.id).toBe(0); + }); + + it("rejects managed/external key collisions in version 2", async () => { + const common = { type: "campus", id: 0, key: "mainz" }; + await writeFile( + statePath, + JSON.stringify({ + version: 2, + host: HOST, + resources: { + mainz: { ...common, fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + externals: { + mainz: { ...common, identity: { name: "Mainz" }, boundAt: "t" }, + }, + }), + "utf8", + ); + await expect(loadState(statePath, HOST)).rejects.toThrow(/both a managed and an external/); + }); + it("migrates a pre-rename campus snapshot: shortName → shorty (#17 item 4)", async () => { // A campus adopted before the shortName→shorty rename (Phase 4, no version bump). const file = { diff --git a/tests/use-command.test.ts b/tests/use-command.test.ts new file mode 100644 index 0000000..d48053e --- /dev/null +++ b/tests/use-command.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const host = "https://example.church.tools"; +const request = vi.fn(async () => { + throw new Error("ct use must never write ChurchTools"); +}); +const get = vi.fn(async (path: string) => { + if (path === "/groups/4711") { + return { id: 4711, name: "OJAHR Fuzzies", information: { groupTypeId: 17, campusId: 2 } }; + } + throw new Error(`unexpected GET ${path}`); +}); + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client: { get, request }, me: { id: 1 } })), +})); + +const { useCommand } = await import("../src/commands/use.js"); + +describe("ct use", () => { + let directory: string; + let statePath: string; + const originalHost = process.env.CT_HOST; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "ct-use-command-")); + statePath = join(directory, "ct-state.json"); + process.env.CT_HOST = host; + request.mockClear(); + get.mockClear(); + }); + + afterEach(async () => { + if (originalHost === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = originalHost; + await rm(directory, { recursive: true, force: true }); + }); + + it("supports the deterministic form and is byte-idempotent without a ChurchTools write", async () => { + await useCommand().parseAsync(["group", "4711", "--key", "ojahr_fuzzies", "--state", statePath], { + from: "user", + }); + const first = await readFile(statePath, "utf8"); + await useCommand().parseAsync(["group", "4711", "--key", "ojahr_fuzzies", "--state", statePath], { + from: "user", + }); + expect(await readFile(statePath, "utf8")).toBe(first); + expect(JSON.parse(first).externals.ojahr_fuzzies).toMatchObject({ + type: "group", + id: 4711, + identity: { name: "OJAHR Fuzzies", groupTypeId: 17 }, + }); + expect(request).not.toHaveBeenCalled(); + }); + + it("refuses fuzzy selection without a TTY before contacting ChurchTools", async () => { + await expect( + useCommand().parseAsync(["group", "OJAHR", "--state", statePath], { from: "user" }), + ).rejects.toThrow(/Non-interactive use requires an exact numeric id and --key/); + expect(get).not.toHaveBeenCalled(); + }); +});