From 8a7652b32d173d5eb886a9296dee5d64160f49c2 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 13 Aug 2026 18:00:17 +0200 Subject: [PATCH 1/5] feat: coverage, bulk grant adoption, per-instance catalog, honest pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #100, #101, #102, #103, #104, #105. Also closes #98, which shipped in PR #99 but never auto-closed. A theme runs through all six: the tool was quiet in places where being quiet is the same as being wrong. A paged read that returned 10 of 645 rows, a ruleset that froze one host's ids into a cross-host file, a warning that told consumer repos to run a script they do not have. #100 — `ct get raw` follows pagination It issued one request and printed CT's default first page, so raw and the typed commands disagreed by 635 rows on the same path, in a valid-looking JSON array. A probe request now runs first with no paging params (so a single-object path is never given paging params it might 400 on); an array that reports more pages is re-read through `getAll`. `--no-paginate` / `--page ` / a caller's own `page=`/`limit=` keep the single-request probe — and then dropped rows are a loud WARNING, since silence is the one thing this must not do again. #101 — un-portablized ruleset ids are reported, not swallowed CT does not validate the ids inside a ruleset, so one carrying prod's `ctgroup.id` applied to dev does not error: the auto-group collects the wrong people and `ct plan` stays green, because the ruleset round-trips byte-identically against the host it was written for. - every id left numeric is now named with its REASON (unmanaged target / role unknown to /group/roles / role's group type unmanaged / no logical form exists), at adoption AND at plan time; - `ReverseResolver` reads its catalogs with `getAll`. It used a plain `get`, so only the first 10 rows of `/group/roles` were ever indexed — on eqrm prod that silently left 36 of 46 roles unportablizable. The forward resolver was fixed for this; the reverse side had the same bug; - portablization is ON by default (`--no-portable-rulesets` opts out); - `--strict-rulesets` refuses to write a ruleset that still carries one. #102 — opt-in `preserveUnknown` One `cc_html_template` grant made a 41-grant role undeclarable, because a partial declaration turns a clean no-op into a destructive plan. `preserveUnknown: true | [""]` keeps undeclared live grants instead of revoking them. Opt-in per declaration, the strict default unchanged, a dimension list never widening to unscoped rights, a dimension no right scopes by rejected at eval time (a typo that preserves nothing reads exactly like "nothing to preserve"), and every preserved grant rendered explicitly and counted apart from the change totals — "I forgot one" and "I left the module grants alone" must not look alike. #103 — `ct coverage` Answers "what exists here that I am not managing, and could I manage it?" — joining `/groups?include[]=roles`, `/dynamicgroups` and `/permissions/group_role` against state. Declarability is per (group, role), not per group, since one group routinely has two declarable roles and one blocked one. Inherited rows are excluded from authored counts; forgetting that inflated one hand-rolled audit from 590 to 714 grants. `--json` for CI gates, `--type` / `--declarable` / `--blocked` for the detail. #104 — bulk `ct adopt grants` `--group` / `--all-declarable` / `--write`. Emits the portable `group` + `role` domain form with a derived `_` key whenever the group is managed — the two edits a human forgets on the 30th paste. A block that would revoke live grants is skipped and summarised rather than printed: in bulk the per-block WARNING header stops being a safeguard and becomes something to scroll past. Nothing is capped silently. #105 — per-instance catalog + `ct refresh` `ct permissions catalog --refresh` captures the catalog from the repo's own host into `.ct/permission-catalog..json`; plan/apply load it over the bundled one, and the version-skew warning goes quiet when it does — a capture from the target host is authoritative for it, and a warning on every plan is one nobody reads. `ct refresh --group ` / `--all` re-evaluates a managed auto-group that did NOT change (which `apply --refresh` cannot reach), and apply now says outright that CT materializes membership on its own schedule, so an empty auto-group after a green apply is expected. The legacy scheduler ping runs every due job on the instance, so it stays documented and unfired. Also: `/coverage/` in .gitignore is now root-anchored. The bare form matched `src/coverage/` and would have dropped the new report module from every commit. Claude-Session: https://claude.ai/code/session_016TacHkv3oVczwXGHBwckq2 --- .gitignore | 5 +- README.md | 12 +- docs/handbuch/blueprints.md | 2 +- docs/handbuch/dynamic-groups.md | 92 ++++-- docs/handbuch/field-definitions.md | 2 +- docs/handbuch/permissions.md | 149 ++++++++- docs/runbook-manual-surface.md | 43 ++- src/api/ctClient.ts | 89 +++++- src/commands/adopt-grants.ts | 268 +++++++++++++--- src/commands/adopt-group.ts | 54 +++- src/commands/apply.ts | 20 ++ src/commands/coverage.ts | 132 ++++++++ src/commands/get.ts | 99 +++++- src/commands/permissions.ts | 75 +++++ src/commands/plan.ts | 5 + src/commands/refresh.ts | 131 ++++++++ src/config/context.ts | 49 ++- src/config/query-refs.ts | 121 +++++++- src/coverage/report.ts | 307 +++++++++++++++++++ src/engine/synthetic.ts | 22 +- src/index.ts | 6 + src/permissions/adopt.ts | 50 ++- src/permissions/catalog-store.ts | 138 +++++++++ src/permissions/catalog.ts | 84 ++++- src/permissions/grants.ts | 36 ++- src/permissions/plan.ts | 85 ++++- src/permissions/render.ts | 21 +- src/permissions/types.ts | 18 ++ src/resolve/reverse.ts | 17 +- tests/adopt-grants-bulk.test.ts | 201 ++++++++++++ tests/adopt-group-command.test.ts | 43 ++- tests/adopt.test.ts | 13 +- tests/coverage.test.ts | 177 +++++++++++ tests/get-command.test.ts | 115 ++++++- tests/permission-apply.test.ts | 6 +- tests/permission-catalog-store.test.ts | 145 +++++++++ tests/permission-preserve-unknown.test.ts | 143 +++++++++ tests/plan-detailed-exitcode-command.test.ts | 2 +- tests/query-refs.test.ts | 97 +++++- tests/refresh-command.test.ts | 112 +++++++ tests/synthetic-dynamic.test.ts | 57 ++++ 41 files changed, 3079 insertions(+), 164 deletions(-) create mode 100644 src/commands/coverage.ts create mode 100644 src/commands/permissions.ts create mode 100644 src/commands/refresh.ts create mode 100644 src/coverage/report.ts create mode 100644 src/permissions/catalog-store.ts create mode 100644 tests/adopt-grants-bulk.test.ts create mode 100644 tests/coverage.test.ts create mode 100644 tests/permission-catalog-store.test.ts create mode 100644 tests/permission-preserve-unknown.test.ts create mode 100644 tests/refresh-command.test.ts diff --git a/.gitignore b/.gitignore index 83942b2..a9c933d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,9 @@ ct-state.json # Generated typed client (regenerated from the live OpenAPI spec) src/api/schema.d.ts -# Test/coverage output -coverage/ +# Test/coverage output — ROOT-anchored: a bare `coverage/` also matches `src/coverage/` +# (the `ct coverage` report module, #103) and would silently drop it from every commit. +/coverage/ .vitest/ # MkDocs output for docs/handbuch/ (built by .github/workflows/docs.yml, never committed) diff --git a/README.md b/README.md index cf01ede..801fe3f 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,13 @@ commands. ct auth login --host https://mychurch.church.tools --token ct auth status # who am I? -ct get groups # JSON to stdout — pipe into jq +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 coverage # what the instance has that the config does not manage ct state list # what is managed ct plan # diff the config against ChurchTools (read-only) ct apply # create + update in dependency order (confirm + backup first) +ct refresh --group # make ChurchTools re-evaluate one auto-group now ``` `apply` reconciles **creates and updates** only, saving state after each action @@ -132,7 +134,13 @@ schema in scope, per-record values never. See References are logical throughout: `groupType: "ministry_team"` resolves to that instance's id at plan time, so the same config drives a dev and a prod instance -unchanged. Numeric ids remain an escape hatch everywhere. +unchanged. Numeric ids remain an escape hatch everywhere — and where one is left +in place, `ct` says so rather than letting a host-specific id travel silently to +another instance. + +`ct coverage` answers the other direction: what exists on the instance that the +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. ## Environments and CI diff --git a/docs/handbuch/blueprints.md b/docs/handbuch/blueprints.md index c72766f..3e9050b 100644 --- a/docs/handbuch/blueprints.md +++ b/docs/handbuch/blueprints.md @@ -4,7 +4,7 @@ sources: - src/config/context.ts - src/engine/graph.ts - src/engine/hierarchy.ts -sources_hash: e1bc9429a635e819 +sources_hash: f6c5a275456c1de4 reviewed: 2026-08-13 --- diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index 84a28f0..7347089 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,7 +6,7 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/commands/adopt-group.ts -sources_hash: 785c613f106cbd8a +sources_hash: e78e72e535b895ae reviewed: 2026-08-10 --- @@ -131,15 +131,16 @@ 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. -#### Auto-rewrite on capture: `--portable-rulesets` (opt-in, #76) +#### Auto-rewrite on capture (default since #101; was `--portable-rulesets`, #76) -Rather than hand-editing markers into a freshly captured file, let adopt do it: +Rather than hand-editing markers into a freshly captured file, adopt does it: ```sh -ct adopt group --with-dynamic --portable-rulesets +ct adopt group --with-dynamic # portablized (the default) +ct adopt group --with-dynamic --no-portable-rulesets # verbatim, this host's ids ``` -With the flag, `ct adopt group --with-dynamic` runs the captured (normalized) +`ct adopt group --with-dynamic` runs the captured (normalized) ruleset through `portablizeRuleset` (`src/config/query-refs.ts`) before writing `rulesets/.json`: every numeric id sitting in a known ChurchQuery `var` position that maps to a **managed** logical key is rewritten to its `{ __ctRef }` @@ -173,21 +174,46 @@ marker carries the group-type key + role name, and the resolver picks the one name slugs to the role. This corrects the earlier `role-def` mapping (#86), which was unresolvable on the real instance. -The flag is **default OFF**: auto-rewriting an id you _thought_ was managed would -silently change query semantics, so you opt in per invocation. Ids that don't map -to a managed key (an operational group outside the scaffold, or the catalog-less -`ctgroup.groupStatusId`, #67) stay numeric — the escape hatch — and adopt emits a -warning naming the file: +#### What could not be portablized is REPORTED, never swallowed (#101) + +Portablization only rewrites references to targets it can resolve. Anything +pointing at an unmanaged target keeps its raw numeric id — the escape hatch — +and that is the dangerous half, because **ChurchTools treats a ruleset as opaque +JSON and does not validate the ids inside it.** A ruleset carrying prod's +`ctgroup.id` applied to dev does not error: the auto-group simply collects the +wrong people, or nobody, and `ct plan` stays green because the ruleset +round-trips byte-identically against the host it was written for. That is +materially worse than a wrong permission scope, because an auto-group's payload +IS group membership — which is what carries grants. + +So every id left numeric is named, with its reason, **both at capture and at +plan time**: ```text -! left 2 unmanaged id(s) numeric in jugend.json — operational/unmanaged refs, not portable (escape hatch) +! rulesets/jugend.json keeps 3 host-specific id(s) — NOT portable to another host: + ctgroup.id: 1246 left numeric — group #1246 is not under management — `ct adopt group 1246` (then re-adopt) makes it portable + ctgroup.groupStatusId: 1, 2 left numeric — group statuses have no REST catalog (#67) — no logical form exists ``` -> **Interim caveat (when NOT using `--portable-rulesets`):** applying a -> raw-id prod snapshot to a _different_ environment is mechanically fine (CT -> accepts it; unknown ids → empty matches) but **semantically wrong** for that -> environment's memberships. Treat it as a known, documented gap — not a silent -> one — when rehearsing prod configs against dev. +```text +! dynamic group "jugend": ruleset carries 1 host-specific id(s) — not portable to another instance: + ctgroup.id: 1246 left numeric — group #1246 is not under management — … +``` + +The 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). + +**`--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 +decided every ruleset must be portable. + +> **Caveat when using `--no-portable-rulesets`:** applying a raw-id prod +> snapshot to a _different_ environment is mechanically fine (CT accepts it; +> unknown ids → empty matches) but **semantically wrong** for that environment's +> memberships. Adopt says so once per file; treat it as a known, documented gap — +> not a silent one — when rehearsing prod configs against dev. ### The `RuleSet` shape @@ -324,11 +350,35 @@ actually changed in this run. ct apply --refresh ``` -This is deliberately **per-group only** (`refreshChangedDynamicGroups` in -`src/commands/apply.ts`) — the all-groups `/dynamicgroups/refresh` endpoint -has a huge blast radius and is never called from here. A change to one -group's ruleset never triggers a recompute of every dynamic group in the -instance. +This is deliberately **per-group only** — the all-groups +`/dynamicgroups/refresh` endpoint has a huge blast radius and is never called +from here. A change to one group's ruleset never triggers a recompute of every +dynamic group in the instance. + +### `ct refresh` — re-evaluate a group that did NOT change (#105) + +`ct apply --refresh` only covers groups changed in that run, so it cannot +re-evaluate an existing group and does nothing at all on a no-op plan. That +leaves no lever for the most common confusion of all: **a freshly created +auto-group is legitimately empty after a green apply**, because ChurchTools +materializes membership on its own schedule. `ct apply` now says so in its +output rather than leaving you to guess whether the ruleset is wrong. + +```bash +ct refresh --env prod --group jugend # one managed dynamic group +ct refresh --env prod --all # every managed dynamic group +``` + +`--all` is required to fan out — refreshing recomputes membership, so it is +never the default. `ct refresh` only ever touches **managed** groups, refuses a +group that has no ruleset on this host (rather than POSTing into a 404), and +keeps going after a per-group failure (exiting non-zero). + +> **The scheduler ping is NOT fired by `ct`.** ChurchTools' admin cron page hits +> `GET https:///?q=cron&standby=true`, which runs **every due scheduled +> job on the instance** — far beyond auto-groups. It is documented in +> [`docs/runbook-manual-surface.md`](../runbook-manual-surface.md) as a manual +> escape hatch; `ct` deliberately never calls it. ## Full example diff --git a/docs/handbuch/field-definitions.md b/docs/handbuch/field-definitions.md index 623def0..057f431 100644 --- a/docs/handbuch/field-definitions.md +++ b/docs/handbuch/field-definitions.md @@ -3,7 +3,7 @@ title: Field definitions & security levels sources: - src/commands/get.ts - src/api/ctClient.ts -sources_hash: 24ea82f0326ed7d5 +sources_hash: 50d1537fe9c47a10 reviewed: 2026-08-13 --- diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index c9493eb..866048c 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -5,7 +5,7 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: 03c3ae24524afcc9 +sources_hash: 755e1575996e49dc reviewed: 2026-08-13 --- @@ -100,25 +100,48 @@ The catalog (`src/permissions/catalog.json`) is a snapshot of one instance's permission master data, captured at a specific ChurchTools version. Two things keep it honest: -**Regeneration — one command.** Point it at a live instance and it rewrites -`catalog.json` (rights + a fresh `$meta` provenance stamp): +**Refresh it for YOUR instance — `ct permissions catalog --refresh` (#105).** +This is the one to reach for from a consumer repo. It captures the catalog from +the instance you are targeting and writes it beside your config: + +```bash +ct permissions catalog --refresh --env prod # → .ct/permission-catalog..json +ct permissions catalog --env prod # show which catalog is active, and where it came from +``` + +**Commit that file.** Every subsequent `ct plan` / `ct apply` against that host +loads it in preference to the catalog bundled with the `ct` release, and says so +in its header (`permission catalog: .ct/permission-catalog..json`). A repo +that never runs the refresh is unaffected — the bundled catalog stays the +fallback. + +This exists because the bundled catalog is a snapshot of **one** instance's +ChurchTools version, and the staleness warning below used to tell you to run a +script that only exists in the ct-cli repo. A consumer repo could not act on its +own warning short of opening a PR upstream and waiting for a release. + +**Regenerating the BUNDLED catalog (ct-cli maintainers).** Inside this repo, to +move the shipped default forward: ```bash CT_HOST=https://your.church.tools CT_LOGINTOKEN= npm run regenerate:permission-catalog ``` -It logs in, calls the legacy `POST /index.php?q=churchauth/ajax` `func=getMasterData` -endpoint (the only source of the name↔authId map — see -`src/permissions/README.md`), records the instance's CT version, and writes the -file. It performs a single **read**; it never writes to the instance. Review -the `git diff` before committing. +Both paths read the same source: the legacy `POST /index.php?q=churchauth/ajax` +`func=getMasterData` endpoint, the only place the name↔authId map is exposed +(see `src/permissions/README.md`). Both perform a single **read**; neither +writes to the instance. Review the `git diff` before committing either file. **Staleness & unknown rights — `ct plan` warns (never fails).** `$meta.ctVersion` records the version the catalog was captured from. On every `plan`/`apply`: - If the live instance's CT version differs from `$meta.ctVersion`, `ct plan` - prints a warning — right names/authIds/scopeFields may have drifted; - regenerate to be sure. + prints a warning — right names/authIds/scopeFields may have drifted; capture + one for this instance to be sure. **This warning is suppressed once a + per-instance catalog is loaded** (#105): a capture taken from the host you are + planning against is authoritative for it, and a warning that fires on every + single plan is one nobody reads — including on the plan where a moved authId + would actually matter. - If a **live grant carries an `authId` the catalog cannot name** (a stale or foreign right), `ct plan` names the `authId` + domain and **leaves the grant untouched** — it is deliberately kept *out* of the diff so `ct apply` never @@ -409,6 +432,36 @@ them, read the live rows and emit a paste-ready config block: ct adopt grants group_type_role 42 # or: group_role / status, and the hyphenated group-type-role ``` +### Bulk adoption (#104) + +Adopting a whole instance one domain at a time meant dozens of invocations and +dozens of pastes, each needing its `key` renamed and its emitted numeric `id:` +swapped for the portable `group` + `role` pair — the two edits a human forgets +on the 30th paste. So: + +```bash +ct adopt grants --group kids # every role instance of one group +ct adopt grants --all-declarable # every declarable role instance on the host +ct adopt grants --all-declarable --write config/grants.ts # append instead of printing +``` + +In bulk mode: + +- the **portable domain form** is emitted whenever the group is managed — + `group: "kids"` + `role: "Leiter"` instead of the host-specific pairing + `id:` — and the key is derived as `_` (`kids_leiter`). + An unmanaged group falls back to `id:` with a comment saying why; +- a block that would **revoke live grants** is never emitted. In bulk the + per-block `WARNING` header stops being a safeguard and becomes something the + reader scrolls past, so such domains are skipped and listed instead; +- a role instance blocked by an undeclarable scope dimension is skipped and + named, with the dimension to pass to [`preserveUnknown`](#partial-ownership--preserveunknown-opt-in-102). + The single-domain form still emits it, deliberately, one domain at a time; +- nothing is capped silently — the run prints how many blocks it emitted **and** + how many it skipped, with the reason for each. + +### The single-domain form + It fetches `GET /permissions//`, runs the rows through the **same** normalization the planner uses (`normalizeActual`), and prints a `ct.groupRole` / `ct.groupTypeRole` block whose every emitted grant is guaranteed @@ -517,6 +570,82 @@ one `DELETE` per `toDelete` tuple against unchanged instance diffs to empty and issues no requests — the reconciliation is idempotent. +## Partial ownership — `preserveUnknown` (opt-in, #102) + +A declaration **owns its whole domain**: any live grant absent from `grants` is +revoked. That is the right default, and it does not move. But it also means one +unmanageable grant makes an entire role instance undeclarable — and on a real +instance the blocker is almost always module data (an HTML template, a calendar +category, a wiki category) sitting next to perfectly expressible structural +grants. On eqrm prod that cost 403 of 590 authored grants, several of them +blocked by a *single* `cc_html_template` row on a 41-grant role. + +`preserveUnknown` is the deliberate way out: + +```ts +ct.groupRole({ + key: "team_office_leiter", + group: "team_office", + role: "Leiter", + // Own the structural grants; leave anything on these dimensions alone. This role also carries + // cc_html_template grants that this scaffold has no business owning. + preserveUnknown: ["cc_html_template"], + grants: [ /* the 40 structural ones */ ], +}); +``` + +- **Opt-in per declaration.** There is no global switch, and no default change. +- **`true` preserves everything undeclared**; a **list of scope dimensions** is + the form to prefer — it keeps the escape hatch's blast radius to the + dimensions you consciously excluded, so a genuinely unexpected new grant on a + dimension you *do* manage still shows up as drift rather than being swallowed. +- A dimension list never widens to **unscoped** rights: you named dimensions to + leave alone, and "no dimension" is not one of them. +- A dimension no right in the catalog scopes by is an **eval-time error**, not a + silent no-op — a typo that preserves nothing would otherwise read exactly like + "there was nothing to preserve", right up until an apply revokes 41 grants. +- **Never invisible.** The plan renders every preserved grant and counts them + separately from the change totals, so "I forgot one" and "I deliberately left + the module grants alone" cannot look alike: + +``` + group_role #44675 (team_office_leiter): +0 grant(s), -0 remove(s), ~2 preserved + ~ authId=17 scope=[3] (grant) (preserved, not managed — preserveUnknown) + +Permission plan: 0 to grant, 0 to remove, 2 preserved (not managed). +``` + +`ct coverage` (below) names the exact dimensions blocking each role instance — +those are the strings to pass here. + +## What is declarable — `ct coverage` (#103) + +To ask an instance "what exists here that I am not managing, and could I manage +it?": + +```bash +ct coverage --env prod # totals, per-type table, declarability verdict +ct coverage --env prod --blocked # only the role instances something blocks, and what +ct coverage --env prod --json # for a CI gate +``` + +It joins `/groups?include[]=roles`, `/dynamicgroups` and +`/permissions/group_role` against your state file. Two details it gets right +that a hand-rolled audit easily does not: `?include[]=roles` turns one role +lookup per group into a handful of paged calls, and **inherited rows are +excluded** from the authored counts (forgetting that inflated one real audit +from 590 to 714 grants and made several role instances look unmanageable that +were not). + +Declarability is reported per **(group, role)**, not per group — one group +routinely has two declarable roles and one blocked one, and group granularity +would hide exactly that. A role instance is declarable when every authored grant +is either unscoped, uses the `-1` ALL sentinel, scopes by a dimension with a +[logical reference form](#scope-resolution) (`cdb_gruppe`, `cdb_station`, +`cdb_gruppentyp`, `cdb_bereich`), or scopes by a numeric-but-host-independent +dimension (`cc_securitylevel`). Anything else is module data with no resource +behind it, and is named as the blocker. + ## Example See [`examples/permissions.config.ts`](https://github.com/eqrm/ct-cli/blob/main/examples/permissions.config.ts) for diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index 2a3b556..cc589ee 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -51,10 +51,49 @@ in that instance's own config repo, in a runbook following this doc's structure. | Module-level settings, i18n | Out of tool scope by design — global instance configuration, not per-resource declarative structure | | Custom field / master-data field **VALUES on individual person/group records** | People/record data is the permanent people boundary (`assertNotPeople`). Note: the field **DEFINITIONS** (schema) are **no longer out of scope** — they moved to read-only supported above (#47/#48, [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md)); only the per-record *values* stay out of scope. | +## Forcing ChurchTools to evaluate auto-groups now + +**Supported path — `ct refresh` (#105).** `ct apply` writes the ruleset and flips +the status; ChurchTools materializes membership on its **own schedule**, so a +freshly created auto-group is legitimately empty right after a green apply. + +```bash +ct refresh --env prod --group # POST /dynamicgroups/{id}/refresh for one managed group +ct refresh --env prod --all # …for every managed dynamic group +``` + +**Manual escape hatch — the legacy scheduler ping. `ct` deliberately never fires +this.** ChurchTools' admin cron page hits: + +```bash +curl -s -o /dev/null "https:///?q=cron&standby=true" # returns a 1×1 image +``` + +That runs **every due scheduled job on the instance**, not just auto-group +evaluation — calendar syncs, mailings, whatever else is due. Its blast radius is +the whole instance, which is exactly why it stayed out of the tool: `ct refresh` +targets one auto-group at a time and only ever touches groups the config manages. +Reach for the curl only when you need CT's scheduler itself to tick, and know +what else is due when you do. + ## Permission catalog lifecycle (regeneration procedure) -**Scripted (#25) — the normal path.** Regenerate `src/permissions/catalog.json` -from a live instance with one command: +**From a consumer repo (#105) — the normal path.** Capture the catalog for the +instance you actually target, and commit it: + +```bash +ct permissions catalog --refresh --env prod # → .ct/permission-catalog..json +ct permissions catalog --env prod # which catalog is active, and where it came from +``` + +Every `ct plan`/`ct apply` against that host then uses it in preference to the +catalog bundled with the `ct` release (and the version-skew warning goes quiet, +since a capture from the target host is authoritative for it). Before this, the +warning told you to run a script that only exists in the ct-cli repo — so it was +unactionable exactly where it was printed, and printed on every single plan. + +**In the ct-cli repo (#25) — moving the shipped default forward.** Regenerate +`src/permissions/catalog.json` from a live instance with one command: ```bash CT_HOST=https://your.church.tools CT_LOGINTOKEN= npm run regenerate:permission-catalog diff --git a/src/api/ctClient.ts b/src/api/ctClient.ts index 3cce5e6..91c0432 100644 --- a/src/api/ctClient.ts +++ b/src/api/ctClient.ts @@ -195,6 +195,26 @@ export class CtClient { return { data: items, meta }; } + /** + * ONE request's raw envelope — the parsed `data` alongside the `meta` that carries CT's + * pagination block (#100). {@link request} unwraps `data` and drops `meta`, which is exactly what + * makes a single-page read indistinguishable from a complete one; `ct get raw` needs both to tell + * "this endpoint returned everything" from "this endpoint returned CT's default first page". + * `data` is the envelope's `data` when present, else the whole body (CT is inconsistent about the + * envelope), so a single-object endpoint round-trips unchanged. + */ + async getRaw(path: string): Promise<{ data: T; meta?: CtMeta }> { + const parsed = await this.requestEnvelope("GET", path); + if (parsed === undefined) { + return { data: undefined as T }; + } + if (Array.isArray(parsed)) { + return { data: parsed as T }; + } + const envelope = parsed as { data?: T; meta?: CtMeta }; + return { data: (envelope.data ?? envelope) as T, meta: envelope.meta }; + } + /** * Shared fetch + parse for {@link request} and {@link getAll}: performs the * HTTP call, throws a status/body-carrying {@link CtApiError} on failure, @@ -250,6 +270,50 @@ export class CtClient { } } + /** + * POST a form body to a LEGACY (non-`/api`) ChurchTools endpoint, riding the same session (#105). + * + * ChurchTools does not expose the permission master data over REST — the permission editor reads it + * from `index.php?q=churchauth/ajax`. This is the only door to it, so it is a narrow, deliberate + * escape hatch from the REST surface rather than a general-purpose method: it takes an `index.php` + * query name, not an arbitrary URL, and it is a read in practice (the one caller performs + * `getMasterData`). The CSRF header is sent because this is a POST, exactly as the browser does. + */ + async legacyPostForm(query: string, form: Record): Promise { + if (!this.cookie) { + throw new CtApiError("Not authenticated — run `ct auth login` first", 401, null); + } + if (!this.csrfToken) { + await this.refreshCsrfToken(); + } + const url = `${this.config.host}/index.php?q=${encodeURIComponent(query)}`; + const res = await fetchWithRetry( + url, + { + method: "POST", + headers: { + Accept: "application/json", + Cookie: this.cookie, + "CSRF-Token": this.csrfToken ?? "", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(form).toString(), + }, + // Not retried on 5xx: a legacy POST is not declared idempotent, and a 429 is still safe to retry. + { isIdempotent: false }, + ); + this.captureCookie(res); + if (!res.ok) { + throw new CtApiError(`POST index.php?q=${query} failed`, res.status, await safeBody(res)); + } + const text = await res.text(); + try { + return JSON.parse(text) as T; + } catch { + throw new CtApiError(`POST index.php?q=${query} returned a non-JSON body`, res.status, text); + } + } + private async refreshCsrfToken(): Promise { // A plain authenticated GET: it rides the session cookie and GET skips the CSRF branch in // request(), so this cannot recurse — and it reuses request()'s envelope unwrap + guarded 2xx @@ -294,7 +358,30 @@ async function safeBody(res: Response): Promise { } /** Append `page`/`limit` query params, respecting any query string the caller already has. */ -function withPageParams(path: string, page: number, limit: number): string { +export function withPageParams(path: string, page: number, limit: number): string { const separator = path.includes("?") ? "&" : "?"; return `${path}${separator}page=${page}&limit=${limit}`; } + +/** True when the caller's path already carries its own `page`/`limit` — a deliberate single-page probe. */ +export function hasOwnPageParams(path: string): boolean { + const query = path.split("?")[1]; + return query !== undefined && /(^|&)(page|limit)=/.test(query); +} + +/** + * Whether a pagination envelope says MORE rows exist than the response carried (#100). Both signals + * are checked because CT populates them inconsistently across endpoints: `current < lastPage` is the + * authoritative one, and `total > count` catches an endpoint that reports a total without page + * numbers. No pagination block at all ⇒ the endpoint is not a paged list, so nothing is missing. + */ +export function hasMorePages(meta: CtMeta | undefined, received: number): boolean { + const p = meta?.pagination; + if (!p) { + return false; + } + if (p.current !== undefined && p.lastPage !== undefined && p.current < p.lastPage) { + return true; + } + return p.total !== undefined && p.total > received; +} diff --git a/src/commands/adopt-grants.ts b/src/commands/adopt-grants.ts index 025058c..f716b52 100644 --- a/src/commands/adopt-grants.ts +++ b/src/commands/adopt-grants.ts @@ -1,16 +1,23 @@ +import { appendFile } from "node:fs/promises"; import { Command } from "commander"; import { authedSession } from "../api/session.js"; +import type { CtClient } from "../api/ctClient.js"; import { resolveConfig } from "../config.js"; import { prepareEnv } from "../env/context.js"; import { assertNotPeople } from "../engine/guard.js"; -import { emitAdoptedGrants } from "../permissions/adopt.js"; +import { buildAdoptedGrants, type AdoptedGrantsBlock } from "../permissions/adopt.js"; import type { DomainType, RawPermission } from "../permissions/grants.js"; -import { loadState } from "../state/state.js"; +import { declarability, decodeGroupsWithRoles, type RoleInstance } from "../coverage/report.js"; +import { slug } from "../resources/registry.js"; +import { loadState, type State } from "../state/state.js"; import { info, warn } from "../ui.js"; interface AdoptGrantsOptions { state?: string; env?: string; + group?: string; + allDeclarable?: boolean; + write?: string; } /** Accept the DSL's `group_role` and the hyphenated CLI-friendly `group-role`; reject anything else. */ @@ -23,55 +30,242 @@ function normalizeDomainType(raw: string): DomainType { } /** - * `ct adopt grants ` — read the live permission rows for a domain and print - * a paste-ready `ct.groupRole` / `ct.groupTypeRole` / `ct.status` config block. Grants are NOT state-tracked, so - * this prints config only; it never writes the state file (contrast `ct adopt `). + * `ct adopt grants` — read live permission rows and print paste-ready config blocks. Grants are NOT + * state-tracked, so this prints config only; it never writes the state file (contrast `ct adopt`). + * + * Single form (unchanged): `ct adopt grants group_role 44675`. + * + * Bulk forms (#104): adopting the declarable estate of a real instance meant 44 invocations and 44 + * manual pastes, each needing its `key` renamed and its emitted numeric `id:` swapped for the portable + * `group` + `role` pair — exactly the two edits a human forgets on the 30th paste. So: + * + * - `--group ` emits every role instance of one group, `--all-declarable` every declarable + * one on the host; + * - the portable `group` + `role` form is emitted by default whenever the group is managed, and the + * key is derived from (group key, role name) rather than being `group_role_44675`; + * - a block that would REVOKE live grants is never emitted silently in bulk — it is skipped and + * summarised, because the WARNING footer that protects the single form cannot protect a 44-block + * paste that nobody reads to the end. */ export function adoptGrantsCommand(): Command { return new Command("grants") .description( - "Print a paste-ready grants config block from a live domain's permission rows (does not write state)", + "Print paste-ready grants config block(s) from live permission rows (does not write state). " + + "One domain, or bulk via --group / --all-declarable.", ) - .argument("", "group_role | group_type_role | status") - .argument("", "the domainId of the permission domain object") + .argument("[domainType]", "group_role | group_type_role | status") + .argument("[domainId]", "the domainId of the permission domain object") .option( "-s, --state ", "state file path (or set CT_STATE) — used to resolve scope group ids to keys", ) .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") - .action(async (rawType: string, rawId: string, _localOpts: AdoptGrantsOptions, command: Command) => { - // `adopt` (the parent) also declares `-s/--state` and `-e/--env` for its own ` ` - // action. Commander does not merge a same-named parent+subcommand option into either level's - // plain `.opts()` (both come up empty for it); only `optsWithGlobals()` walks the whole - // command chain and merges correctly — read from there, not the local `opts` parameter (#51). - const opts = command.optsWithGlobals() as AdoptGrantsOptions; - const domainType = normalizeDomainType(rawType); - if (!/^\d+$/.test(rawId.trim())) { - throw new Error(`Invalid domainId "${rawId}" — expected a non-negative integer.`); - } - const domainId = Number.parseInt(rawId, 10); - const path = `/permissions/${domainType}/${domainId}`; - assertNotPeople(path); // belt-and-suspenders: the domain-type guard already excludes people + .option("--group ", "bulk: every role instance of this group (managed key or numeric id)") + .option("--all-declarable", "bulk: every role instance on the host whose grants are declarable today") + .option("--write ", "append the emitted block(s) to this file instead of printing to stdout") + .action( + async ( + rawType: string | undefined, + rawId: string | undefined, + _localOpts: AdoptGrantsOptions, + command: Command, + ) => { + // `adopt` (the parent) also declares `-s/--state` and `-e/--env` for its own ` ` + // action. Commander does not merge a same-named parent+subcommand option into either level's + // plain `.opts()` (both come up empty for it); only `optsWithGlobals()` walks the whole + // command chain and merges correctly — read from there, not the local `opts` parameter (#51). + const opts = command.optsWithGlobals() as AdoptGrantsOptions; + const bulk = opts.group !== undefined || opts.allDeclarable === true; + if (bulk && (rawType !== undefined || rawId !== undefined)) { + throw new Error( + "Specify either a pair or a bulk selector (--group / --all-declarable), not both.", + ); + } + if (opts.group !== undefined && opts.allDeclarable) { + throw new Error("Specify only one of: --group, --all-declarable."); + } - // 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 cmdEnv = await prepareEnv(opts); - const config = await resolveConfig(); - const statePath = cmdEnv.statePath; - const state = await loadState(statePath, config.host); + // 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 cmdEnv = await prepareEnv(opts); + const config = await resolveConfig(); + const statePath = cmdEnv.statePath; + const state = await loadState(statePath, config.host); + const { client } = await authedSession(); - const { client } = await authedSession(); - const rows = await client.get(path); + const emitted = bulk + ? await emitBulk(client, state, opts) + : [await emitSingle(client, state, rawType, rawId)]; - const block = emitAdoptedGrants({ domainType, domainId, rows, state }); + info(`Grants are not state-tracked — this prints config only and does NOT write ${statePath}.`); + const text = `${emitted.map((e) => e.block).join("\n\n")}\n`; + if (opts.write) { + await appendFile(opts.write, text, "utf8"); + info(`Appended ${emitted.length} block(s) to ${opts.write}. Run \`ct plan\` before applying.`); + } else { + info("Paste the block(s) below into your config, then run `ct plan`:"); + process.stdout.write(text); + } + if (emitted.some((e) => e.omitted > 0)) { + warn( + "Any grant left as a WARNING/NOTE comment in the block is still LIVE on the instance but absent " + + "from the declaration — applying the block will REVOKE it. Resolve every comment first; `ct plan` " + + "is only a no-op once none remain.", + ); + } + }, + ); +} + +/** The original single-domain form: `ct adopt grants `. */ +async function emitSingle( + client: Pick, + state: State, + rawType: string | undefined, + rawId: string | undefined, +): Promise { + if (rawType === undefined || rawId === undefined) { + throw new Error( + "Specify , or a bulk selector (--group / --all-declarable).", + ); + } + const domainType = normalizeDomainType(rawType); + if (!/^\d+$/.test(rawId.trim())) { + throw new Error(`Invalid domainId "${rawId}" — expected a non-negative integer.`); + } + const domainId = Number.parseInt(rawId, 10); + const path = `/permissions/${domainType}/${domainId}`; + assertNotPeople(path); // belt-and-suspenders: the domain-type guard already excludes people + const rows = await client.get(path); + return buildAdoptedGrants({ domainType, domainId, rows, state }); +} + +/** + * Bulk emission (#104). Selects role instances, then emits each one in the portable form. + * + * Blocks that would revoke live grants are dropped and summarised rather than printed: in bulk the + * per-block WARNING header stops being a safeguard and becomes noise the reader scrolls past. + */ +async function emitBulk( + client: Pick, + state: State, + opts: AdoptGrantsOptions, +): Promise { + const [groupRows, roleDefRows] = await Promise.all([ + // `?include[]=roles` turns one role lookup per group into a handful of paged calls (#103). + client.getAll>("/groups?include[]=roles"), + client.getAll>("/group/roles"), + ]); + const roleNamesById = new Map(); + for (const r of roleDefRows.data) { + const id = Number(r.id); + if (Number.isFinite(id) && typeof r.name === "string") roleNamesById.set(id, r.name); + } + const groups = decodeGroupsWithRoles(groupRows.data, roleNamesById); + const permissions = await client.get("/permissions/group_role"); + const rowsByDomainId = new Map(); + for (const row of Array.isArray(permissions) ? permissions : []) { + const list = rowsByDomainId.get(row.domainId); + if (list) list.push(row); + else rowsByDomainId.set(row.domainId, [row]); + } + + const managedKeyByGroupId = new Map(); + for (const r of Object.values(state.resources)) { + if (r.type === "group") managedKeyByGroupId.set(r.id, r.key); + } - info(`Grants are not state-tracked — this prints config only and does NOT write ${statePath}.`); - info(`Paste the block below into your config, then run \`ct plan\`:`); - process.stdout.write(`${block}\n`); - warn( - "Any grant left as a WARNING/NOTE comment in the block is still LIVE on the instance but absent " + - "from the declaration — applying the block will REVOKE it. Resolve every comment first; `ct plan` " + - "is only a no-op once none remain.", + let candidates: RoleInstance[]; + if (opts.group !== undefined) { + const groupId = resolveGroupSelector(opts.group, groups, state); + candidates = groups.filter((g) => g.id === groupId).flatMap((g) => g.roles); + if (candidates.length === 0) { + throw new Error(`--group "${opts.group}" resolved to group #${groupId}, which has no role instances.`); + } + } else { + candidates = groups.flatMap((g) => g.roles); + } + + const blocks: AdoptedGrantsBlock[] = []; + const skippedUndeclarable: string[] = []; + const skippedWouldRevoke: string[] = []; + let skippedEmpty = 0; + + for (const role of candidates) { + const rows = rowsByDomainId.get(role.domainId) ?? []; + const verdict = declarability(rows); + if (verdict.grantCount === 0) { + skippedEmpty += 1; + continue; // nothing authored on this domain — an empty block is not worth a paste + } + const label = `${role.groupName} / ${role.roleName} (domainId ${role.domainId})`; + // Skipped in EVERY bulk mode, not just --all-declarable: a role instance with a grant on a + // dimension ct has no resource for can only be written as a host-specific number, and a bulk paste + // is exactly where that quietly becomes a cross-environment misgrant. The single form + // (`ct adopt grants group_role `) still emits it, deliberately, one domain at a time. + if (!verdict.declarable) { + skippedUndeclarable.push( + `${label}: blocked by ${[...verdict.blockedBy, ...verdict.unknownAuthIds.map((a) => `authId ${a}`)].join(", ")}`, ); + continue; + } + const groupKey = managedKeyByGroupId.get(role.groupId); + const built = buildAdoptedGrants({ + domainType: "group_role", + domainId: role.domainId, + rows, + state, + domain: groupKey ? { group: groupKey, role: role.roleName } : undefined, + key: groupKey ? `${groupKey}_${slug(role.roleName)}` : undefined, }); + if (built.omitted > 0) { + skippedWouldRevoke.push(`${label}: ${built.omitted} live grant(s) cannot be expressed as config`); + continue; + } + blocks.push(built); + } + + if (blocks.length === 0 && skippedWouldRevoke.length === 0 && skippedUndeclarable.length === 0) { + throw new Error("No role instance with authored grants matched — nothing to emit."); + } + + info( + `${blocks.length} block(s) emitted · ${skippedWouldRevoke.length} skipped (would revoke live grants) · ` + + `${skippedUndeclarable.length} skipped (not declarable) · ${skippedEmpty} skipped (no authored grants)`, + ); + // Never a silent cap: what was NOT emitted is listed, so "44 blocks" can't quietly mean "44 of 59". + for (const line of skippedWouldRevoke) { + warn(`skipped ${line} — adopt the missing scope target(s), then re-run for this domain`); + } + for (const line of skippedUndeclarable) { + warn( + `skipped ${line} — own the rest with \`preserveUnknown: []\` (#102), or emit it ` + + `deliberately with the single form`, + ); + } + return blocks; +} + +/** Resolve `--group` to a live group id: numeric id, adopted-state logical key, or live name/slug. */ +function resolveGroupSelector( + raw: string, + groups: Array<{ id: number; name: string }>, + state: State, +): number { + const trimmed = raw.trim(); + if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10); + const managed = state.resources[trimmed]; + if (managed && managed.type === "group") return managed.id; + const bySlug = groups.filter((g) => slug(g.name) === slug(trimmed)); + const candidates = bySlug.length > 0 ? bySlug : groups.filter((g) => g.name === trimmed); + if (candidates.length === 0) { + throw new Error( + `--group "${raw}": not adopted (no state entry) and no live group matches (checked by slug and exact name).`, + ); + } + if (candidates.length > 1) { + const listed = candidates.map((c) => `${JSON.stringify(c.name)} (#${c.id})`).join(", "); + throw new Error(`--group "${raw}" is ambiguous: ${candidates.length} live groups match — ${listed}.`); + } + return candidates[0]!.id; } diff --git a/src/commands/adopt-group.ts b/src/commands/adopt-group.ts index 83ad116..4057ca2 100644 --- a/src/commands/adopt-group.ts +++ b/src/commands/adopt-group.ts @@ -20,7 +20,7 @@ import type { DynamicStatus } from "../engine/types.js"; import { RESOURCES, configSnippet, fromInformation, slug } from "../resources/registry.js"; import { ReverseResolver, type RoleCatalogEntry } from "../resolve/reverse.js"; import type { RefKind } from "../resolve/refs.js"; -import { portablizeRuleset } from "../config/query-refs.js"; +import { formatPortablizeWarnings, portablizeRuleset, scanUnportablized } from "../config/query-refs.js"; import { loadState, saveState, upsert, type State } from "../state/state.js"; import { success, info, warn, out } from "../ui.js"; @@ -32,7 +32,9 @@ interface AdoptGroupOptions { type?: string; childrenOf?: string; withDynamic?: boolean; + /** Commander's negatable `--no-portable-rulesets`: true unless the flag was passed (#101). */ portableRulesets?: boolean; + strictRulesets?: boolean; } const GROUP_SPEC = RESOURCES.group!; @@ -184,8 +186,17 @@ export function adoptGroupCommand(): Command { ) .option( "--portable-rulesets", - "when capturing rulesets (--with-dynamic), rewrite managed entity ids in the query into portable " + - "logical ref markers; unmanaged ids stay numeric with a warning (opt-in; #76)", + "(deprecated — this is the default since #101) rewrite managed entity ids into portable logical refs", + ) + .option( + "--no-portable-rulesets", + "capture rulesets verbatim: keep this host's numeric entity ids instead of rewriting the managed " + + "ones into portable logical ref markers (#76/#101 — portablization is the default)", + ) + .option( + "--strict-rulesets", + "refuse to write a ruleset that still contains an unportablized (host-specific) id, instead of " + + "writing it with a warning (#101)", ) .action(async (ids: string[], _localOpts: AdoptGroupOptions, command: Command) => { // `adopt` (the parent) also declares `-k/--key`, `-s/--state`, `-e/--env`, and `--dry-run` — @@ -254,10 +265,14 @@ export function adoptGroupCommand(): Command { // NOT a simple id→key map — a `role.id`/`groupTypeRoleId` is group-type-scoped and role names are // not globally unique, so we fetch the (groupTypeId, name) catalog plus the group-type id→key map // and let portablizeRuleset emit (group-type, role-name) markers (fixes #86's `role-def` mapping). + // + // Portablization is ON by default since #101 (`--no-portable-rulesets` opts out): leaving a + // capture host-specific fails SILENTLY on the next host — CT does not validate the ids inside a + // ruleset, so the auto-group just collects the wrong people while `ct plan` stays green. const portableCatalogMaps: Partial>> = {}; let roleCatalog: Map | undefined; let groupTypeIdToKey: Map | undefined; - if (opts.withDynamic && opts.portableRulesets) { + if (opts.withDynamic && opts.portableRulesets !== false) { portableCatalogMaps.campus = await reverse.idToKeyByKind("campus"); portableCatalogMaps["group-type"] = await reverse.idToKeyByKind("group-type"); groupTypeIdToKey = portableCatalogMaps["group-type"]; @@ -282,7 +297,7 @@ export function adoptGroupCommand(): Command { if (captured) { const relPath = `rulesets/${key}.json`; let rulesetToWrite = captured.normalizedRuleset; - if (opts.portableRulesets) { + if (opts.portableRulesets !== false) { // Managed group ids come from state (no catalog for `group`), including any group this // same run already adopted; the master-data kinds come from the catalog maps above. const groupMap = new Map(); @@ -295,10 +310,35 @@ export function adoptGroupCommand(): Command { groupTypeIdToKey, }); rulesetToWrite = ruleset; + // Report every dimension left numeric, with its reason (#101). The old output said only + // "left N unmanaged id(s) numeric", which named neither the dimension nor the fix — so a + // capture that silently froze prod's ids into a cross-host file looked like a clean run. if (warnings.length > 0) { + const lines = formatPortablizeWarnings(warnings); + if (opts.strictRulesets) { + throw new Error( + `--strict-rulesets: ${relPath} would contain ${warnings.length} unportablized ` + + `(host-specific) id(s), so nothing was written:\n` + + lines.map((l) => ` ${l}`).join("\n"), + ); + } + warn(`${relPath} keeps ${warnings.length} host-specific id(s) — NOT portable to another host:`); + for (const line of lines) info(` ${line}`); + } + } else { + // Verbatim capture (--no-portable-rulesets): every entity id in the file is this host's. + // Say so once per ruleset rather than let the opt-out quietly imply the ids are fine. + const left = scanUnportablized(captured.normalizedRuleset); + if (left.length > 0) { + if (opts.strictRulesets) { + throw new Error( + `--strict-rulesets with --no-portable-rulesets: ${relPath} would contain ` + + `${left.length} host-specific id(s) and nothing would rewrite them.`, + ); + } warn( - `left ${warnings.length} unmanaged id(s) numeric in ${key}.json — ` + - `operational/unmanaged refs, not portable (escape hatch)`, + `${relPath} captured verbatim (--no-portable-rulesets): ${left.length} host-specific ` + + `id(s) kept as-is — NOT portable to another host.`, ); } } diff --git a/src/commands/apply.ts b/src/commands/apply.ts index de79272..ec309e7 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -13,6 +13,7 @@ import { writeBackup } from "../engine/backup.js"; import { renderPlan } from "../engine/render.js"; import { summarize } from "../engine/types.js"; import { buildPermissionPlan } from "../permissions/plan.js"; +import { loadHostCatalog } from "../permissions/catalog-store.js"; import { renderPermissionPlan } from "../permissions/render.js"; import { applyPermissionPlan } from "../permissions/apply.js"; import { confirm, confirmEnv } from "../ui/prompt.js"; @@ -61,6 +62,10 @@ export function applyCommand(): Command { const statePath = cmdEnv.statePath; const { resources: desired, permissions, configDir } = await loadConfig(configPath); const state = await loadState(statePath, config.host); + // A per-instance permission catalog this repo committed for THIS host wins over the bundled + // one (#105) — same precedence as `ct plan`, so the two never disagree about what a right is. + const hostCatalog = await loadHostCatalog(config.host); + if (hostCatalog) info(`permission catalog: ${hostCatalog}`); const { client } = await authedSession(); // One shared resolver (#20) across both concurrent plans — see commands/plan.ts. @@ -163,6 +168,21 @@ export function applyCommand(): Command { if (opts.refresh) { await runPostApplyHooks(plan, state, client); + } else { + // The auto-group model is the single most surprising thing about a green apply (#105): the + // ruleset is written and activated, but ChurchTools computes membership on its own schedule, + // so a freshly created auto-group is legitimately EMPTY right now. Saying so costs one line + // and removes the "did it work?" that otherwise follows every first apply. + const dynamicKeys = plan.items + .filter((i) => i.action !== "no-op" && i.action !== "delete" && i.changes.some((c) => c.field === "dynamic")) + .map((i) => i.key); + if (dynamicKeys.length > 0) { + info( + `${dynamicKeys.length} dynamic group(s) written and activated — ChurchTools materializes their ` + + `membership on its own schedule, so they may be empty for now. Force it with ` + + `\`ct refresh --group ${dynamicKeys[0]}\` (or re-run apply with --refresh).`, + ); + } } }); } diff --git a/src/commands/coverage.ts b/src/commands/coverage.ts new file mode 100644 index 0000000..9011617 --- /dev/null +++ b/src/commands/coverage.ts @@ -0,0 +1,132 @@ +/** + * `ct coverage` (#103) — the audit "what exists on this host that I am not managing, and could I + * manage it?", built into the tool instead of hand-rolled per consumer repo. + * + * Reads only; writes nothing, touches no state. The joins it performs are the ones every repo was + * doing by hand (`?include[]=roles` + `/dynamicgroups` + `/permissions/group_role`, minus inherited + * rows); the verdict it computes — whether a role instance's grants are declarable — is the part only + * `ct` can produce, because it needs the scope-dimension knowledge that lives in this codebase. + */ +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import type { CtClient } from "../api/ctClient.js"; +import { resolveConfig } from "../config.js"; +import { prepareEnv } from "../env/context.js"; +import { + buildCoverageReport, + decodeGroupsWithRoles, + renderCoverage, + renderRoleInstances, + type CoverageReport, +} from "../coverage/report.js"; +import type { RawPermission } from "../permissions/grants.js"; +import { slug } from "../resources/registry.js"; +import { loadState, type State } from "../state/state.js"; +import { info, out } from "../ui.js"; + +interface CoverageOptions { + state?: string; + env?: string; + json?: boolean; + type?: string; + declarable?: boolean; + blocked?: boolean; +} + +/** + * `?include[]=roles` is the whole reason this command is cheap: without it, auditing role instances + * means one `/groups/{id}/roles` call per group (645 of them on eqrm prod). With it, the same data + * arrives in the paged group list. + */ +const GROUPS_WITH_ROLES = "/groups?include[]=roles"; + +export function coverageCommand(): Command { + return new Command("coverage") + .description("Report what ChurchTools has that the config does not manage, and what is declarable") + .option("-s, --state ", "state file (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("--json", "emit the full report as JSON (for CI gates)") + .option("--type ", "restrict the role-instance detail to one group type") + .option("--declarable", "list only role instances whose grants could be adopted today") + .option("--blocked", "list only role instances blocked by an undeclarable scope dimension") + .action(async (opts: CoverageOptions) => { + const cmdEnv = await prepareEnv(opts); + const config = await resolveConfig(); + const state = await loadState(cmdEnv.statePath, config.host); + const { client } = await authedSession(); + + const report = await collectCoverage(client, config.host, state); + + let instances = report.roleInstances; + if (opts.type) { + const wanted = opts.type.trim(); + const typeIds = new Set( + report.byType.filter((t) => t.name === wanted || slug(t.name) === slug(wanted)).map((t) => t.groupTypeId), + ); + if (typeIds.size === 0) { + throw new Error( + `--type "${opts.type}": no group type matches (checked by name and slug against this host).`, + ); + } + instances = instances.filter((r) => typeIds.has(r.groupTypeId)); + } + if (opts.declarable) instances = instances.filter((r) => r.verdict.declarable); + if (opts.blocked) instances = instances.filter((r) => !r.verdict.declarable); + + if (opts.json) { + out({ ...report, roleInstances: instances }); + return; + } + + if (cmdEnv.name) info(`env: ${cmdEnv.name} · host: ${config.host} · state: ${cmdEnv.statePath}`); + process.stdout.write(`${renderCoverage(report)}\n`); + if (opts.type || opts.declarable || opts.blocked) { + process.stdout.write( + instances.length > 0 ? `\n${renderRoleInstances(instances)}\n` : "\nNo role instances match.\n", + ); + } + }); +} + +/** Fetch everything the report needs. Split out from the action so it is reusable and mockable. */ +export async function collectCoverage( + client: Pick, + host: string, + state: State, +): Promise { + const [groupRows, groupTypeRows, dynamicRows, roleDefRows] = await Promise.all([ + client.getAll>(GROUPS_WITH_ROLES), + client.getAll>("/group/grouptypes"), + client.getAll>("/dynamicgroups"), + client.getAll>("/group/roles"), + ]); + // `/permissions/group_role` is a single instance-wide blob, not a paged list — same read the + // planner performs. + const groupRolePermissions = await client.get("/permissions/group_role"); + + const groupTypeNames = new Map(); + for (const row of groupTypeRows.data) { + const id = Number(row.id); + if (Number.isFinite(id) && typeof row.name === "string") groupTypeNames.set(id, row.name); + } + const roleNamesById = new Map(); + for (const row of roleDefRows.data) { + const id = Number(row.id); + if (Number.isFinite(id) && typeof row.name === "string") roleNamesById.set(id, row.name); + } + const dynamicGroupIds = new Set(); + for (const row of dynamicRows.data) { + // `/dynamicgroups` rows are keyed by the GROUP id they belong to; tolerate either spelling. + const id = Number(row.id ?? row.groupId); + if (Number.isFinite(id)) dynamicGroupIds.add(id); + } + + return buildCoverageReport({ + host, + state, + groups: decodeGroupsWithRoles(groupRows.data, roleNamesById), + groupTypeNames, + dynamicGroupIds, + groupRolePermissions: Array.isArray(groupRolePermissions) ? groupRolePermissions : [], + }); +} diff --git a/src/commands/get.ts b/src/commands/get.ts index ab9041c..811642e 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -1,8 +1,15 @@ import { Command } from "commander"; import { authedSession } from "../api/session.js"; +import { + hasMorePages, + hasOwnPageParams, + withPageParams, + type CtClient, + type CtMeta, +} from "../api/ctClient.js"; import { prepareEnvHost } from "../env/context.js"; import { CATALOG } from "../permissions/catalog.js"; -import { info, out } from "../ui.js"; +import { info, out, warn } from "../ui.js"; interface ResourceSpec { path: string; @@ -93,13 +100,97 @@ export function getCommand(): Command { cmd .command("raw ") - .description("GET an arbitrary API path, e.g. `ct get raw /groups/42`") + .description("GET an arbitrary API path, e.g. `ct get raw /groups/42` (list endpoints are paginated)") .option("-e, --env ", "environment profile from ct.envs.json (targets that host)") - .action(async (path: string, opts: { env?: string }) => { + .option( + "--no-paginate", + "issue exactly one request instead of following pagination (warns if rows were left behind)", + ) + .option("--page ", "fetch exactly this page (implies --no-paginate)") + .action(async (path: string, opts: RawOptions) => { await prepareEnvHost(opts); // #22: wire the env's host/token before authenticating const { client } = await authedSession(); - out(await client.get(path.startsWith("/") ? path : `/${path}`)); + const target = path.startsWith("/") ? path : `/${path}`; + await getRaw(client, target, opts); }); return cmd; } + +interface RawOptions { + env?: string; + /** Commander's negatable `--no-paginate`: true unless the flag was passed. */ + paginate?: boolean; + page?: string; +} + +/** + * `ct get raw ` (#100). Before this, raw issued ONE plain request and printed whatever came + * back — which for any CT list endpoint is its default first page (10 rows) with no hint that the + * rest exist. That made raw disagree with the typed commands by hundreds of rows on the same path, + * and the output (a valid-looking JSON array) gave the reader no reason to doubt it. + * + * So raw now follows pagination like the typed commands, while staying an honest escape hatch: + * + * - a probe request runs FIRST, with no paging params added. A non-array body (`/groups/42`, + * `/whoami`) is printed as-is — paging params are never appended to an endpoint that isn't a + * list, so no path can start 400ing because raw got clever. + * - an array body whose `meta.pagination` says more rows exist is re-read through `getAll`. + * - `--no-paginate` / `--page ` / a caller-supplied `page=`/`limit=` in the path keep the + * single-request behaviour for deliberate probing — and then a dropped-rows WARNING is loud, + * because silence is the one thing this must never do again. + */ +export async function getRaw( + client: Pick, + target: string, + opts: RawOptions, +): Promise { + let page: number | undefined; + if (opts.page !== undefined) { + if (!/^\d+$/.test(String(opts.page).trim()) || Number.parseInt(String(opts.page), 10) < 1) { + throw new Error(`Invalid --page "${opts.page}" — expected a positive integer.`); + } + page = Number.parseInt(String(opts.page), 10); + } + // A path that already carries page/limit is the caller hand-rolling their own paging: honour it + // verbatim rather than appending a second, conflicting pair. + const single = opts.paginate === false || page !== undefined || hasOwnPageParams(target); + + if (single) { + const path = page !== undefined ? withPageParams(target, page, DEFAULT_RAW_PAGE_LIMIT) : target; + const { data, meta } = await client.getRaw(path); + out(data); + reportRows(data, meta); + return; + } + + const probe = await client.getRaw(target); + if (!Array.isArray(probe.data) || !hasMorePages(probe.meta, probe.data.length)) { + out(probe.data); + reportRows(probe.data, probe.meta); + return; + } + const all = await client.getAll(target); + out(all.data); + reportRows(all.data, all.meta); +} + +/** Page size for `--page `, matching the client's own default so page N means the same thing. */ +const DEFAULT_RAW_PAGE_LIMIT = 100; + +/** Echo the row count on stderr, and WARN whenever rows were left behind (never silent — #100). */ +function reportRows(data: unknown, meta: CtMeta | undefined): void { + if (!Array.isArray(data)) { + return; // single object — pagination does not apply + } + const total = meta?.pagination?.total; + if (hasMorePages(meta, data.length)) { + warn( + `INCOMPLETE: returned ${data.length} of ${total ?? "more"} row(s) — this endpoint is paginated ` + + `and only part of it was fetched. Drop --page/--no-paginate (or the page=/limit= in the path) ` + + `to fetch every page.`, + ); + return; + } + info(total !== undefined && total !== data.length ? `${data.length} of ${total} total` : `${data.length} total`); +} diff --git a/src/commands/permissions.ts b/src/commands/permissions.ts new file mode 100644 index 0000000..43618f4 --- /dev/null +++ b/src/commands/permissions.ts @@ -0,0 +1,75 @@ +/** + * `ct permissions catalog` (#105) — inspect, and REFRESH, the name↔authId permission catalog. + * + * The refresh is the point: the catalog ships as a snapshot of one ChurchTools version, and until now + * the only way to update it was `npm run regenerate:permission-catalog` inside the ct-cli repo. A + * consumer repo therefore could not act on the staleness warning its own plans printed. This command + * captures the catalog from the repo's OWN instance into `.ct/permission-catalog..json`, which + * every subsequent plan/apply against that host loads in preference to the bundled one. + */ +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import { resolveConfig } from "../config.js"; +import { prepareEnvHost } from "../env/context.js"; +import { CATALOG, CATALOG_META } from "../permissions/catalog.js"; +import { + capturePermissionCatalog, + hostCatalogPath, + loadHostCatalog, + writeHostCatalog, +} from "../permissions/catalog-store.js"; +import { info, success, warn } from "../ui.js"; + +interface CatalogOptions { + env?: string; + refresh?: boolean; +} + +export function permissionsCommand(): Command { + const cmd = new Command("permissions").description("Inspect or refresh the permission catalog"); + + cmd + .command("catalog") + .description( + "Show the active permission catalog's provenance, or capture a fresh one for this instance " + + "with --refresh (writes .ct/permission-catalog..json)", + ) + .option("-e, --env ", "environment profile from ct.envs.json (targets that host)") + .option("--refresh", "capture the catalog from the live instance and write it for this host") + .action(async (opts: CatalogOptions) => { + await prepareEnvHost(opts); + const config = await resolveConfig(); + + if (!opts.refresh) { + // Offline: report which catalog WOULD be used for this host, and where it came from. + const loaded = await loadHostCatalog(config.host); + info( + loaded + ? `Active catalog: ${loaded} (per-instance capture)` + : `Active catalog: bundled with this ct release (no ${hostCatalogPath(config.host)})`, + ); + if (CATALOG_META) { + info( + ` captured from ${CATALOG_META.capturedFrom} · ChurchTools ${CATALOG_META.ctVersion} · ` + + `${CATALOG_META.capturedAt} · ${Object.keys(CATALOG).length} rights`, + ); + } + if (!loaded) { + info(` refresh it for this instance: \`ct permissions catalog --refresh${opts.env ? ` --env ${opts.env}` : ""}\``); + } + return; + } + + const { client } = await authedSession(); + const catalog = await capturePermissionCatalog(client); + const path = await writeHostCatalog(config.host, catalog); + const meta = catalog.$meta as { rightCount: number; ctVersion: string }; + success(`Wrote ${path} — ${meta.rightCount} rights · ChurchTools ${meta.ctVersion} · ${config.host}`); + info("Commit this file: every plan/apply against this host will use it instead of the bundled catalog."); + warn( + "Review the diff before committing — an authId that MOVED changes what a declared right grants.", + ); + }); + + return cmd; +} diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 063b101..6b9e51c 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -9,6 +9,7 @@ import { Resolver } from "../resolve/resolver.js"; import { renderPlan } from "../engine/render.js"; import { summarize } from "../engine/types.js"; import { buildPermissionPlan } from "../permissions/plan.js"; +import { loadHostCatalog } from "../permissions/catalog-store.js"; import { renderPermissionPlan } from "../permissions/render.js"; import { info, warn, out } from "../ui.js"; @@ -39,6 +40,10 @@ export function planCommand(): Command { const { resources: desired, permissions, configDir } = await loadConfig(configPath); // loadState already refuses a host mismatch (state.ts) — no second guard needed here. const state = await loadState(cmdEnv.statePath, config.host); + // A per-instance permission catalog this repo committed for THIS host wins over the one bundled + // with the release (#105). Loaded before the plan is built so every right resolves against it. + const hostCatalog = await loadHostCatalog(config.host); + if (hostCatalog) info(`permission catalog: ${hostCatalog}`); const { client } = await authedSession(); // One shared resolver (#20): buildPlan and buildPermissionPlan run concurrently, so a single diff --git a/src/commands/refresh.ts b/src/commands/refresh.ts new file mode 100644 index 0000000..7a81a49 --- /dev/null +++ b/src/commands/refresh.ts @@ -0,0 +1,131 @@ +/** + * `ct refresh` (#105) — ask ChurchTools to materialize a dynamic group's membership NOW. + * + * `ct apply` writes the ruleset and flips the status; ChurchTools computes the membership on its own + * schedule. So a freshly created auto-group is legitimately EMPTY after a green apply, which reads as + * a failure to anyone who does not know the model. `ct apply --refresh` only covers dynamic groups + * CHANGED in that run, so it cannot re-evaluate an existing group and does nothing at all on a no-op + * plan — leaving no lever for "it's empty and I want to know whether the ruleset is wrong". + * + * Scope is deliberately per-group: `POST /dynamicgroups/{id}/refresh`. ChurchTools also exposes a + * legacy scheduler ping (`GET /?q=cron&standby=true`) that the admin UI's cron page hits, but that + * runs EVERY due scheduled job on the instance — far beyond auto-groups — so `ct` documents it (see + * docs/runbook-manual-surface.md) and never fires it. + */ +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import type { CtClient } from "../api/ctClient.js"; +import { CtApiError } from "../api/ctClient.js"; +import { resolveConfig } from "../config.js"; +import { prepareEnv } from "../env/context.js"; +import { assertNotPeople } from "../engine/guard.js"; +import { loadState, type ManagedResource, type State } from "../state/state.js"; +import { error, info, success, warn } from "../ui.js"; + +interface RefreshOptions { + state?: string; + env?: string; + group?: string; + all?: boolean; +} + +/** The per-group counts CT returns from POST /dynamicgroups/{id}/refresh. */ +interface RefreshResult { + created: number; + updated: number; + deleted: number; +} + +export function refreshCommand(): Command { + return new Command("refresh") + .description( + "Ask ChurchTools to re-evaluate a managed dynamic group's membership now (does not change config)", + ) + .option("-s, --state ", "state file (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("--group ", "refresh this managed group only") + .option("--all", "refresh every managed dynamic group (required to fan out — this changes membership)") + .action(async (opts: RefreshOptions) => { + if (!opts.group && !opts.all) { + throw new Error( + "Specify --group for one group, or --all to refresh every managed dynamic group. " + + "Refreshing recomputes membership, so the fan-out is never the default.", + ); + } + if (opts.group && opts.all) { + throw new Error("Specify only one of: --group, --all."); + } + + const cmdEnv = await prepareEnv(opts); + const config = await resolveConfig(); + const state = await loadState(cmdEnv.statePath, config.host); + const { client } = await authedSession(); + + const targets = await selectTargets(client, state, opts.group); + if (targets.length === 0) { + info("No managed dynamic groups to refresh."); + return; + } + + let failed = 0; + for (const target of targets) { + const path = `/dynamicgroups/${target.id}/refresh`; + assertNotPeople(path); + try { + const res = await client.request("POST", path); + const r = res?.[0]; + success( + r + ? `refreshed ${target.key} (#${target.id}): +${r.created} ~${r.updated} -${r.deleted}` + : `refreshed ${target.key} (#${target.id})`, + ); + } catch (err) { + failed += 1; + error( + `Failed to refresh ${target.key} (#${target.id}): ${ + err instanceof CtApiError ? `HTTP ${err.status}` : (err as Error).message + }`, + ); + } + } + if (failed > 0) process.exitCode = 1; + }); +} + +/** + * Which managed groups to refresh. Refuses a group that is not an auto-group on this host rather than + * POSTing to an endpoint that will 404 — "this group has no ruleset" is the answer the caller needs. + */ +async function selectTargets( + client: Pick, + state: State, + groupKey: string | undefined, +): Promise { + const dynamicIds = new Set(); + const { data } = await client.getAll>("/dynamicgroups"); + for (const row of data) { + const id = Number(row.id ?? row.groupId); + if (Number.isFinite(id)) dynamicIds.add(id); + } + + if (groupKey !== undefined) { + const managed = state.resources[groupKey]; + if (!managed || managed.type !== "group") { + throw new Error( + `--group "${groupKey}" is not a managed group in this state file. Adopt or declare it first.`, + ); + } + if (!dynamicIds.has(managed.id)) { + throw new Error( + `--group "${groupKey}" (#${managed.id}) is not a dynamic group on this host — there is no ruleset to evaluate.`, + ); + } + return [managed]; + } + + const all = Object.values(state.resources).filter((r) => r.type === "group" && dynamicIds.has(r.id)); + // Only ever the MANAGED ones: `ct` never touches a group the config does not own, and the + // all-groups /dynamicgroups/refresh endpoint (huge blast radius) is deliberately never called. + if (all.length > 0) warn(`Refreshing ${all.length} managed dynamic group(s) — this recomputes membership.`); + return all; +} diff --git a/src/config/context.ts b/src/config/context.ts index b4f9733..b2c59dc 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -15,7 +15,8 @@ import { basename } from "node:path"; import { fileURLToPath } from "node:url"; import type { DesiredResource, DynamicSpec, DynamicStatus } from "../engine/types.js"; import type { DomainType } from "../permissions/grants.js"; -import type { DesiredPermission, Grant } from "../permissions/types.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 { normalizeScopeEntry } from "../permissions/scope.js"; import { conventionalRulesetRef, knownFields } from "../resources/registry.js"; @@ -130,6 +131,12 @@ export interface PermissionInput { /** `status`: the PERSON status by name/key (`/statuses`) — sugars into a Ref-valued domainId (#90). */ personStatus?: string; grants: Grant[]; + /** + * Opt in to keeping live grants this declaration does not mention, instead of revoking them (#102). + * `true` for all of them, or a list of scope dimensions (`["cc_html_template"]`) to keep the escape + * hatch's blast radius to the dimensions you consciously excluded. See {@link PreserveUnknown}. + */ + preserveUnknown?: PreserveUnknown; } /** Logical id-field sugar for declarations: a named string field → a Ref-valued numeric id field. @@ -206,6 +213,43 @@ function resolveDomainInput(domainType: DomainType, input: PermissionInput): num return input.id; } +/** + * Validate and normalise `preserveUnknown` (#102). Every failure is an eval-time error, because the + * whole feature is an escape hatch from a destructive default: a typo'd dimension that silently + * preserves nothing is indistinguishable from "nothing needed preserving" — right up until an apply + * revokes 41 live grants. `undefined` (the strict default) passes straight through. + */ +function normalizePreserveUnknown( + domainType: DomainType, + input: PermissionInput, +): PreserveUnknown | undefined { + const value = input.preserveUnknown; + if (value === undefined || value === false) return undefined; // false ≡ the default; don't carry it + if (value === true) return true; + if (!Array.isArray(value) || value.some((d) => typeof d !== "string" || d.length === 0)) { + throw new Error( + `${domainType} "${input.key}": "preserveUnknown" must be true or an array of scope dimension ` + + `names (e.g. ["cc_html_template"]).`, + ); + } + if (value.length === 0) { + throw new Error( + `${domainType} "${input.key}": "preserveUnknown: []" preserves nothing — omit it for the strict ` + + `default, or name the dimensions to leave alone.`, + ); + } + const unknown = value.filter((d) => !KNOWN_SCOPE_FIELDS.has(d)); + if (unknown.length > 0) { + throw new Error( + `${domainType} "${input.key}": "preserveUnknown" names ${unknown.length === 1 ? "a scope dimension" : "scope dimensions"} ` + + `no right in the permission catalog scopes by: ${unknown.join(", ")}. A dimension that matches ` + + `nothing would preserve nothing and read exactly like "there was nothing to preserve" — check ` + + `the spelling against \`ct get permissions-catalog\`.`, + ); + } + return [...new Set(value)]; +} + export interface ConfigContext { campus(input: ResourceInput): void; group(input: ResourceInput): void; @@ -466,7 +510,8 @@ export function createContext(): { ); } seenDomains.set(domainKey, input.key); - permissions.push({ key: input.key, domainType, domainId, grants }); + const preserveUnknown = normalizePreserveUnknown(domainType, input); + permissions.push({ key: input.key, domainType, domainId, grants, preserveUnknown }); }; const definePermission = (domainType: DomainType) => diff --git a/src/config/query-refs.ts b/src/config/query-refs.ts index 75b09c0..0b6a4b3 100644 --- a/src/config/query-refs.ts +++ b/src/config/query-refs.ts @@ -63,10 +63,49 @@ const ROLE_VAR = "role.id"; */ const ROLE_FIELD_NAME = "groupTypeRoleId"; -/** An id left numeric because no managed logical key mapped to it — collected, not thrown (escape hatch). */ +/** + * Why an id could not be portablized (#101). ChurchTools treats a ruleset as opaque JSON and does + * not validate the ids inside it, so a ruleset carrying prod's `ctgroup.id` applied to dev does not + * error — the auto-group simply collects the wrong people, and `ct plan` stays green because the + * ruleset round-trips byte-identically against the host it was written for. Naming the reason is what + * turns that silent wrongness into a known risk. + */ +export type PortablizeReason = + /** The dimension HAS a logical form, but no managed resource / catalog row carries this id. */ + | "unmanaged" + /** A `role.id`/`groupTypeRoleId` whose row is absent from the `/group/roles` catalog. */ + | "role-unknown" + /** A role resolved, but the group TYPE it belongs to is not managed, so no portable pair exists. */ + | "role-group-type-unmanaged" + /** The dimension has no logical reference form at all (e.g. group statuses — no REST catalog). */ + | "no-ref-kind"; + +/** An id left numeric — collected, not thrown (the escape hatch), and reported with its reason (#101). */ export interface PortablizeWarning { var: string; id: number; + reason: PortablizeReason; + /** Human-readable explanation, ready to print. Never a bare restatement of {@link reason}. */ + detail: string; +} + +/** + * Entity-bearing ChurchQuery vars with NO logical reference form, and why (#101). These are reported + * as left-numeric so a cross-host ruleset's real risk surface is complete — without them, adoption + * would claim "everything portablized" while a `groupStatusId` sat frozen at prod's value. + * + * Deliberately a closed list rather than "every var we don't recognise": a `person.age > 18` operand + * 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", +}; + +/** The reason text for an id no logical key mapped to, per kind. */ +function unmanagedDetail(kind: RefKind, id: number): string { + return kind === "group" + ? `group #${id} is not under management — \`ct adopt group ${id}\` (then re-adopt) makes it portable` + : `no managed ${kind} carries id ${id} on this host`; } export interface PortablizeOptions { @@ -124,7 +163,7 @@ export function portablizeRuleset( if (typeof value !== "number") return value; // booleans/strings/nulls are literals, never entity ids const key = idToKeyByKind[kind]?.get(value); if (key !== undefined) return marker(kind, key); - warnings.push({ var: varName, id: value }); + warnings.push({ var: varName, id: value, reason: "unmanaged", detail: unmanagedDetail(kind, value) }); return value; }; @@ -143,7 +182,26 @@ export function portablizeRuleset( role: entry.name, } as GroupTypeRoleRef; } - warnings.push({ var: varName, id: value }); + // Two genuinely different failures, kept apart: the role is unknown to `/group/roles` at all, or + // it resolved but its group type is unmanaged. They need different fixes, so they get different + // reasons rather than one "could not portablize". + warnings.push( + entry + ? { + var: varName, + id: value, + reason: "role-group-type-unmanaged", + detail: + `role "${entry.name}" belongs to group type #${entry.groupTypeId}, which is not managed — ` + + `adopt that group type to make the (group-type, role) pair portable`, + } + : { + var: varName, + id: value, + reason: "role-unknown", + detail: `no /group/roles row carries groupTypeRoleId ${value} on this host`, + }, + ); return value; }; @@ -166,6 +224,19 @@ export function portablizeRuleset( // Group-type-scoped role var (#76): rewrite siblings through the role catalog, not VAR_REF_KINDS. return node.map((el, i) => (i === varIdx ? el : mapOperand(el, (v) => mapRoleScalar(v, ROLE_VAR)))); } + const unportable = UNPORTABLE_ENTITY_VARS[varName]; + if (unportable !== undefined) { + // A known ENTITY var with no logical form: nothing to rewrite, but it is still a + // host-specific id frozen into a cross-host file, so it is reported rather than swallowed. + node.forEach((el, i) => { + if (i === varIdx) return; + for (const v of Array.isArray(el) ? el : [el]) { + if (typeof v === "number") { + warnings.push({ var: varName, id: v, reason: "no-ref-kind", detail: unportable }); + } + } + }); + } // Unknown var (escape hatch) — recurse structurally, leaving its numeric ids untouched. } return node.map(walk); @@ -187,3 +258,47 @@ export function portablizeRuleset( return { ruleset: walk(ruleset) as Record, warnings }; } + +/** + * Find every host-specific numeric id still sitting in an entity position of a ruleset (#101). + * + * This is {@link portablizeRuleset}'s reporting half without the rewrite: it takes a ruleset as + * AUTHORED (logical `{ __ctRef }` markers still un-resolved, plain numbers still plain) and returns + * what would not survive a move to another host. `ct plan` runs it over every declared dynamic group + * so an un-portablized ruleset is a visible, recurring risk rather than a green plan that quietly + * collects the wrong people on the wrong instance — the payload of an auto-group is group + * membership, which in this domain is exactly what carries permission grants. + * + * It reports by POSITION, not by lookup: any number left in a known entity var's operand is + * unportable by construction, because a portablized one would be a `{ __ctRef }` marker instead. So + * it needs no catalogs, no state and no network, and is safe to run on every plan. + */ +export function scanUnportablized(ruleset: unknown): PortablizeWarning[] { + const { warnings } = portablizeRuleset((ruleset ?? {}) as Record, { + // No id→key maps and no role catalog: every numeric entity id therefore fails to map and is + // reported, while an already-portable `{ __ctRef }` marker is not a number and is never flagged. + idToKeyByKind: {}, + }); + return warnings; +} + +/** + * One line per (var, reason), naming the ids — the shape both `ct adopt --with-dynamic` and + * `ct plan` print (#101). Grouped so a ruleset with 30 unmanaged group ids is one readable line, not + * thirty; ids are sorted so the output is stable across runs and diffable in CI logs. + */ +export function formatPortablizeWarnings(warnings: readonly PortablizeWarning[]): string[] { + const grouped = new Map(); + for (const w of warnings) { + const k = `${w.var} ${w.reason}`; + const hit = grouped.get(k); + if (hit) { + if (!hit.ids.includes(w.id)) hit.ids.push(w.id); + } else { + grouped.set(k, { var: w.var, detail: w.detail, ids: [w.id] }); + } + } + return [...grouped.values()].map( + (g) => `${g.var}: ${g.ids.sort((a, b) => a - b).join(", ")} left numeric — ${g.detail}`, + ); +} diff --git a/src/coverage/report.ts b/src/coverage/report.ts new file mode 100644 index 0000000..74e8478 --- /dev/null +++ b/src/coverage/report.ts @@ -0,0 +1,307 @@ +/** + * Coverage: what this host has that the config does not manage, and what could be managed (#103). + * + * Every consumer repo was reinventing this audit by hand — joining `/groups?include[]=roles`, + * `/dynamicgroups` and `/permissions/group_role`, then diffing the lot against the state file. Two + * things make it worth owning here rather than leaving to each repo: + * + * - `?include[]=roles` is a non-obvious trick that turns one role lookup per group into a handful of + * paged calls, and nobody finds it without digging; + * - getting it wrong is easy and QUIET. Forgetting the `isInherited` filter inflates the authored + * grant count (714 vs 590 on eqrm prod) and makes several role instances look unmanageable that + * are not. + * + * The DECLARABILITY verdict is the valuable half, and `ct` is the only thing that can compute it: it + * needs to know which scope dimensions have a logical reference form (#98), which are numeric but + * host-independent, and which are module data with no resource behind them at all. + * + * This module is pure — it takes already-fetched rows and returns a report. The command wrapper does + * the I/O, so the whole verdict is unit-testable without a network. + */ +import { KNOWN_AUTH_IDS, SCOPE_FIELD_BY_AUTH_ID } from "../permissions/catalog.js"; +import { normalizeActual, type RawPermission } from "../permissions/grants.js"; +import { ALL_SCOPE_SENTINEL, SCOPE_REF_KIND } from "../permissions/scope.js"; +import { fromInformation } from "../resources/registry.js"; +import type { State } from "../state/state.js"; + +/** + * Scope dimensions whose numeric dataIds mean the same thing on EVERY ChurchTools instance, so a + * numeric literal is portable and the grant is declarable without a logical reference form. + * `cc_securitylevel` is the enumeration 1/2/3 — not a resource, but not host-specific either. + */ +const NUMERIC_UNIVERSAL_SCOPE_FIELDS: ReadonlySet = new Set(["cc_securitylevel"]); + +/** A live (group, role) permission domain — the granularity declarability is decided at. */ +export interface RoleInstance { + /** `groups[].roles[].id` — the `group_role` domainId a `ct.groupRole` declaration targets. */ + domainId: number; + groupId: number; + groupName: string; + groupTypeId: number; + roleName: string; +} + +export interface GroupRow { + id: number; + name: string; + groupTypeId: number; + roles: RoleInstance[]; +} + +/** Why a role instance's grants cannot all be expressed as config today. */ +export interface DeclarabilityVerdict { + declarable: boolean; + /** Authored, non-inherited grants on this domain. */ + grantCount: number; + /** Scope dimensions with no declarable form — the reason it is blocked, named. */ + blockedBy: string[]; + /** Live authIds the permission catalog cannot name (a stale catalog, or a foreign right). */ + unknownAuthIds: number[]; +} + +export interface RoleInstanceCoverage extends RoleInstance { + managedGroupKey: string | null; + verdict: DeclarabilityVerdict; +} + +export interface TypeCoverage { + groupTypeId: number; + name: string; + total: number; + managed: number; + dynamic: number; + /** Unmanaged groups that nevertheless carry authored grants — the real "you are missing this" number. */ + unmanagedWithGrants: number; +} + +export interface CoverageReport { + host: string; + groups: { total: number; managed: number; dynamic: number; managedDynamic: number }; + grants: { authored: number; roleInstances: number; declarable: number; blocked: number; blockingDimensions: string[] }; + byType: TypeCoverage[]; + roleInstances: RoleInstanceCoverage[]; +} + +export interface CoverageInput { + host: string; + state: State; + groups: GroupRow[]; + /** Group type id → display name, from `/group/grouptypes`. */ + groupTypeNames: Map; + /** Ids from `/dynamicgroups` — which groups are auto-groups on this host. */ + dynamicGroupIds: Set; + /** Every row of `/permissions/group_role`, unfiltered (inherited rows are dropped here). */ + groupRolePermissions: RawPermission[]; +} + +/** + * Decide whether a role instance's live grants could be declared as config today, and if not, which + * scope dimensions block it. + * + * A grant is declarable when its right is nameable AND its scope can be written portably: + * - unscoped rights always are; + * - the `-1` ALL sentinel always is (CT reads it back verbatim on every dimension); + * - a dimension with a logical reference form (#98: group, campus, group type, department) is; + * - a numeric-but-universal dimension (`cc_securitylevel`) is. + * Everything else — calendar categories, HTML templates, wiki categories, OAuth clients — names + * module data this tool has no resource for, so it blocks the whole instance under the strict + * ownership default. (With `preserveUnknown` (#102) those same instances become declarable while + * leaving the module grants alone; the verdict below is the strict one, and the command surfaces the + * blocking dimensions precisely so they can be passed to `preserveUnknown`.) + */ +export function declarability(rows: RawPermission[]): DeclarabilityVerdict { + // `normalizeActual` is what makes the count right: it drops the self-re-adding system baseline and + // every inherited row, so the verdict is about ADMIN-AUTHORED grants — the only ones ct ever owns. + const tuples = normalizeActual(rows).filter((t) => t.type === "grant"); + const blockedBy = new Set(); + const unknownAuthIds = new Set(); + for (const t of tuples) { + if (!KNOWN_AUTH_IDS.has(t.authId)) { + unknownAuthIds.add(t.authId); + continue; + } + const scopeField = SCOPE_FIELD_BY_AUTH_ID.get(t.authId) ?? null; + if (scopeField === null) continue; + if (t.dataId.every((id) => id === ALL_SCOPE_SENTINEL)) continue; + if (SCOPE_REF_KIND[scopeField] !== undefined) continue; + if (NUMERIC_UNIVERSAL_SCOPE_FIELDS.has(scopeField)) continue; + blockedBy.add(scopeField); + } + return { + declarable: blockedBy.size === 0 && unknownAuthIds.size === 0, + grantCount: tuples.length, + blockedBy: [...blockedBy].sort(), + unknownAuthIds: [...unknownAuthIds].sort((a, b) => a - b), + }; +} + +/** Build the whole report from already-fetched rows. Pure: no client, no state mutation. */ +export function buildCoverageReport(input: CoverageInput): CoverageReport { + const { host, state, groups, groupTypeNames, dynamicGroupIds, groupRolePermissions } = input; + + // group id → managed logical key, and the same for role-instance domainIds. + const managedGroupKeyById = new Map(); + for (const r of Object.values(state.resources)) { + if (r.type === "group") managedGroupKeyById.set(r.id, r.key); + } + + const rowsByDomainId = new Map(); + for (const row of groupRolePermissions) { + const list = rowsByDomainId.get(row.domainId); + if (list) list.push(row); + else rowsByDomainId.set(row.domainId, [row]); + } + + const roleInstances: RoleInstanceCoverage[] = []; + // Declarability is per (group, role), NOT per group: on a real instance one group routinely has two + // declarable roles and one blocked one, and reporting at group granularity hides exactly that. + for (const group of groups) { + for (const role of group.roles) { + const rows = rowsByDomainId.get(role.domainId) ?? []; + const verdict = declarability(rows); + if (verdict.grantCount === 0) continue; // no authored grants → nothing to declare, nothing to report + roleInstances.push({ + ...role, + managedGroupKey: managedGroupKeyById.get(group.id) ?? null, + verdict, + }); + } + } + + const groupsWithGrants = new Set(roleInstances.map((r) => r.groupId)); + const byType = new Map(); + for (const group of groups) { + let t = byType.get(group.groupTypeId); + if (!t) { + t = { + groupTypeId: group.groupTypeId, + name: groupTypeNames.get(group.groupTypeId) ?? `#${group.groupTypeId}`, + total: 0, + managed: 0, + dynamic: 0, + unmanagedWithGrants: 0, + }; + byType.set(group.groupTypeId, t); + } + t.total += 1; + const managed = managedGroupKeyById.has(group.id); + if (managed) t.managed += 1; + if (dynamicGroupIds.has(group.id)) t.dynamic += 1; + if (!managed && groupsWithGrants.has(group.id)) t.unmanagedWithGrants += 1; + } + + const blockingDimensions = new Set(); + for (const r of roleInstances) for (const d of r.verdict.blockedBy) blockingDimensions.add(d); + + return { + host, + groups: { + total: groups.length, + managed: groups.filter((g) => managedGroupKeyById.has(g.id)).length, + dynamic: groups.filter((g) => dynamicGroupIds.has(g.id)).length, + managedDynamic: groups.filter((g) => dynamicGroupIds.has(g.id) && managedGroupKeyById.has(g.id)).length, + }, + grants: { + authored: roleInstances.reduce((n, r) => n + r.verdict.grantCount, 0), + roleInstances: roleInstances.length, + declarable: roleInstances.filter((r) => r.verdict.declarable).length, + blocked: roleInstances.filter((r) => !r.verdict.declarable).length, + blockingDimensions: [...blockingDimensions].sort(), + }, + byType: [...byType.values()].sort((a, b) => a.name.localeCompare(b.name)), + roleInstances: roleInstances.sort( + (a, b) => a.groupName.localeCompare(b.groupName) || a.roleName.localeCompare(b.roleName), + ), + }; +} + +/** + * Decode `/groups?include[]=roles` rows into the shape the report wants. Defensive about the role + * row's own field names: the domainId is `roles[].id`, but the role NAME is only sometimes carried + * inline — when it is not, `groupTypeRoleId` is joined against the `/group/roles` catalog. A role + * whose name cannot be recovered either way still counts (it has a domainId and grants); it is just + * labelled by its id, rather than being dropped from the audit. + */ +export function decodeGroupsWithRoles( + rows: Array>, + roleNamesById: Map, +): GroupRow[] { + const out: GroupRow[] = []; + for (const row of rows) { + const id = Number(row.id); + if (!Number.isFinite(id)) continue; + const groupTypeId = Number(fromInformation(row, "groupTypeId")); + const rawRoles = Array.isArray(row.roles) ? (row.roles as Array>) : []; + const roles: RoleInstance[] = []; + for (const r of rawRoles) { + const domainId = Number(r?.id); + if (!Number.isFinite(domainId)) continue; + const groupTypeRoleId = Number(r?.groupTypeRoleId); + const inlineName = typeof r?.name === "string" && r.name.length > 0 ? r.name : undefined; + const roleName = + inlineName ?? + (Number.isFinite(groupTypeRoleId) ? roleNamesById.get(groupTypeRoleId) : undefined) ?? + `role #${domainId}`; + roles.push({ + domainId, + groupId: id, + groupName: typeof row.name === "string" ? row.name : `#${id}`, + groupTypeId: Number.isFinite(groupTypeId) ? groupTypeId : -1, + roleName, + }); + } + out.push({ + id, + name: typeof row.name === "string" ? row.name : `#${id}`, + groupTypeId: Number.isFinite(groupTypeId) ? groupTypeId : -1, + roles, + }); + } + return out; +} + +/** Render the report the way the issue sketched it: totals, a per-type table, then the verdict. */ +export function renderCoverage(report: CoverageReport): string { + const lines: string[] = []; + const g = report.groups; + lines.push( + `${g.total} groups · ${g.managed} managed · ${report.grants.authored} authored grants over ` + + `${report.grants.roleInstances} role instances`, + ); + lines.push(""); + const nameWidth = Math.max(12, ...report.byType.map((t) => t.name.length)); + lines.push( + `${"by type".padEnd(nameWidth)} ${"total".padStart(5)} ${"managed".padStart(7)} ` + + `${"dynamic".padStart(7)} unmanaged+grants`, + ); + for (const t of report.byType) { + lines.push( + ` ${t.name.padEnd(nameWidth - 2)} ${String(t.total).padStart(5)} ${String(t.managed).padStart(7)} ` + + `${String(t.dynamic).padStart(7)} ${String(t.unmanagedWithGrants).padStart(16)}`, + ); + } + lines.push(""); + lines.push( + `grants: ${report.grants.declarable} role instance(s) declarable · ${report.grants.blocked} blocked`, + ); + if (report.grants.blockingDimensions.length > 0) { + lines.push(` blocked by: ${report.grants.blockingDimensions.join(", ")}`); + lines.push( + ` (a blocked instance becomes declarable with \`preserveUnknown: [, …]\` — see #102)`, + ); + } + return lines.join("\n"); +} + +/** The per-role-instance detail lines, printed under `--verbose`/`--declarable`. */ +export function renderRoleInstances(instances: readonly RoleInstanceCoverage[]): string { + return instances + .map((r) => { + const managed = r.managedGroupKey ? `managed as "${r.managedGroupKey}"` : "unmanaged"; + const verdict = r.verdict.declarable + ? "declarable" + : `blocked by ${[...r.verdict.blockedBy, ...r.verdict.unknownAuthIds.map((a) => `authId ${a}`)].join(", ")}`; + return ` ${r.groupName} / ${r.roleName} (domainId ${r.domainId}, ${r.verdict.grantCount} grant(s), ${managed}): ${verdict}`; + }) + .join("\n"); +} diff --git a/src/engine/synthetic.ts b/src/engine/synthetic.ts index b6e0fe6..63dc9a2 100644 --- a/src/engine/synthetic.ts +++ b/src/engine/synthetic.ts @@ -15,6 +15,7 @@ import { deepEqual } from "./plan.js"; import { mapConcurrent } from "../util/concurrency.js"; import { info, warn, formatError } from "../ui.js"; import { normalizeDynamic, normalizeRuleset, putRulesetBody, resolveRulesetRef } from "./dynamic.js"; +import { formatPortablizeWarnings, scanUnportablized } from "../config/query-refs.js"; import type { DynamicStatus } from "./types.js"; /** How many dynamic groups to fetch (ruleset + status) from ChurchTools at once. Mirrors build.ts. */ @@ -162,10 +163,23 @@ const dynamicField: SyntheticField = { // ({ status: "none", ruleset: {} }). The docs tell users to KEEP the dynamic block when // demoting, so their authored ruleset is still present here — but folding it would diff // forever against the sentinel actual. Collapsing both sides makes a demoted group converge. - const dynamic = - d.dynamic.status === "none" - ? { status: "none" as DynamicStatus, ruleset: {} } - : normalizeDynamic({ status: d.dynamic.status, ruleset: resolveRulesetRef(d.dynamic.ruleset, configDir, d.key) }); + if (d.dynamic.status === "none") { + return { ...d, fields: { ...d.fields, dynamic: { status: "none" as DynamicStatus, ruleset: {} } } }; + } + const resolvedRuleset = resolveRulesetRef(d.dynamic.ruleset, configDir, d.key); + // Un-portablized ids report at PLAN time too (#101), not only at adoption. A ruleset carrying + // another host's ids round-trips byte-identically against the host it was written for, so the + // plan is green and the damage — an auto-group collecting the wrong people — is invisible until + // someone notices the membership. Warn, never fail: the numeric form stays a valid escape hatch. + const unportable = scanUnportablized(resolvedRuleset); + if (unportable.length > 0) { + warn( + `dynamic group "${d.key}": ruleset carries ${unportable.length} host-specific id(s) — ` + + `not portable to another instance:`, + ); + for (const line of formatPortablizeWarnings(unportable)) info(` ${line}`); + } + const dynamic = normalizeDynamic({ status: d.dynamic.status, ruleset: resolvedRuleset }); return { ...d, fields: { ...d.fields, dynamic } }; }); return { desired: augmented, errors }; diff --git a/src/index.ts b/src/index.ts index e816e5a..eab581c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,9 @@ 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 { refreshCommand } from "./commands/refresh.js"; import { planCommand } from "./commands/plan.js"; import { applyCommand } from "./commands/apply.js"; import { destroyCommand } from "./commands/destroy.js"; @@ -25,6 +28,9 @@ export function buildProgram(): Command { program.addCommand(getCommand()); program.addCommand(adoptCommand()); program.addCommand(stateCommand()); + program.addCommand(coverageCommand()); + program.addCommand(permissionsCommand()); + program.addCommand(refreshCommand()); program.addCommand(planCommand()); program.addCommand(applyCommand()); program.addCommand(destroyCommand()); diff --git a/src/permissions/adopt.ts b/src/permissions/adopt.ts index 84dd025..678635d 100644 --- a/src/permissions/adopt.ts +++ b/src/permissions/adopt.ts @@ -90,13 +90,41 @@ function collapse(tuples: GrantTuple[]): CollapsedGrant[] { * the state, returns the block text (comments and all). The command wrapper handles the fetch and * prints the result — this stays fully unit-testable without a network. */ -export function emitAdoptedGrants(args: { +export interface AdoptGrantsArgs { domainType: DomainType; domainId: number; rows: RawPermission[]; state: State; -}): string { - const { domainType, domainId, rows, state } = args; + /** + * The PORTABLE domain form (#104): the managed group's logical key + the role name, which the + * resolver maps back to this host's pairing domainId at plan time. Emitted instead of the numeric + * `id:` whenever the group is managed — the numeric id is host-specific, so it is exactly the edit a + * human forgets on the 30th paste. Absent ⇒ fall back to `id:`. + */ + domain?: { group: string; role: string }; + /** Logical key for the emitted block. Defaults to `_` (the pre-#104 shape). */ + key?: string; +} + +/** What {@link buildAdoptedGrants} produced, so a BULK caller can decide whether it is safe to emit. */ +export interface AdoptedGrantsBlock { + block: string; + /** + * How many LIVE grants were left as comments. Non-zero means applying the block as-is would REVOKE + * them — the one outcome the WARNING footer exists to prevent, and the reason bulk mode skips such + * blocks instead of printing 44 of them and hoping every comment gets read. + */ + omitted: number; + /** Pre-existing deny rows on this domain; preserved by the reconciler, never emitted. */ + revokes: number; +} + +export function emitAdoptedGrants(args: AdoptGrantsArgs): string { + return buildAdoptedGrants(args).block; +} + +export function buildAdoptedGrants(args: AdoptGrantsArgs): AdoptedGrantsBlock { + const { domainType, domainId, rows, state, domain, key } = args; const normalized = normalizeActual(rows); const grants = normalized.filter((t) => t.type === "grant"); const revokes = normalized.filter((t) => t.type !== "grant"); @@ -105,8 +133,18 @@ export function emitAdoptedGrants(args: { const body: string[] = []; let omitted = 0; body.push(`${DSL_FN[domainType]}({`); - body.push(` key: "${domainType}_${domainId}", // a logical key, unique across the config — rename to taste`); - body.push(` id: ${domainId},`); + if (key !== undefined) { + body.push(` key: ${JSON.stringify(key)},`); + } else { + body.push(` key: "${domainType}_${domainId}", // a logical key, unique across the config — rename to taste`); + } + if (domain) { + // Portable form: resolved per host at plan time, so the same block applies to dev and prod. + body.push(` group: ${JSON.stringify(domain.group)},`); + body.push(` role: ${JSON.stringify(domain.role)},`); + } else { + body.push(` id: ${domainId}, // host-specific — adopt the group to emit the portable group + role form`); + } if (grants.length === 0) { body.push(" grants: [], // no user-authored grants on this domain (baseline/inherited rows excluded)"); @@ -144,7 +182,7 @@ export function emitAdoptedGrants(args: { lines.push("// config is not supported yet (see issue #25 stretch goal)."); } - return lines.join("\n"); + return { block: lines.join("\n"), omitted, revokes: revokes.length }; } /** The lines emitted for one collapsed grant, plus whether a LIVE grant was left as a comment diff --git a/src/permissions/catalog-store.ts b/src/permissions/catalog-store.ts new file mode 100644 index 0000000..6ad33ce --- /dev/null +++ b/src/permissions/catalog-store.ts @@ -0,0 +1,138 @@ +/** + * The per-instance permission catalog (#105). + * + * The name↔authId catalog is not exposed by the REST API, so it ships as a JSON snapshot captured + * from ONE ChurchTools version. Every plan against an instance on a different version printed: + * + * ! Permission catalog was captured from ChurchTools 3.134.0 but this instance runs 3.135.2. + * … regenerate it with `npm run regenerate:permission-catalog` + * + * …naming a script that lives in the ct-cli repo. A consumer repo could not act on it short of + * opening a PR here and waiting for a release, so the warning was unactionable exactly where it was + * printed, and it printed on every single plan — which trains people to ignore it, including on the + * plans where a stale authId would actually matter. + * + * The fix is to let a consumer repo capture the catalog for ITS OWN host and commit the result: + * `ct permissions catalog --refresh` writes `.ct/permission-catalog..json`, and every + * plan/apply against that host loads it in preference to the bundled snapshot. The bundled catalog + * stays the fallback, so nothing changes for a repo that never runs the refresh. + */ +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { CtClient } from "../api/ctClient.js"; +import { useCatalog, type CatalogEntry } from "./catalog.js"; + +/** Directory a consumer repo commits its per-instance captures into, beside the config/state files. */ +export const CATALOG_DIR = ".ct"; + +/** Host → filename component. Keeps one file per instance, so dev and prod captures coexist. */ +export function hostSlug(host: string): string { + return host + .replace(/^https?:\/\//, "") + .replace(/\/+$/, "") + .replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +export function hostCatalogPath(host: string, dir: string = CATALOG_DIR): string { + return join(dir, `permission-catalog.${hostSlug(host)}.json`); +} + +/** + * Load the per-instance catalog for `host`, if one has been committed, and make it the active one. + * Returns the path it loaded, or `null` when there is none (the bundled catalog stays active). + * + * A malformed file THROWS rather than silently falling back: a repo that committed a capture is + * relying on it, and quietly planning against a different catalog than the author thinks is in use is + * the failure mode this whole feature exists to remove. + */ +export async function loadHostCatalog(host: string, dir: string = CATALOG_DIR): Promise { + const path = hostCatalogPath(host, dir); + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (err) { + if ((err as { code?: string }).code === "ENOENT") return null; + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Malformed permission catalog ${path}: not valid JSON (${(err as Error).message}).`); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Malformed permission catalog ${path}: expected a JSON object at the top level.`); + } + useCatalog(parsed, { perInstance: true }); + return path; +} + +/** The legacy master-data shape (`auth_table[module][right]`) — see `capturePermissionCatalog`. */ +interface RawRight { + id: number; + datenfeld?: string | null; + bezeichnung?: string | null; + isRevocable?: boolean | number; +} +interface MasterData { + data?: { auth_table?: Record> }; + auth_table?: Record>; +} + +/** + * Capture the catalog from a live instance and return it in `catalog.json`'s exact schema. + * + * The data comes from the legacy AJAX endpoint the permission editor itself uses — the catalog is + * genuinely absent from the REST API, so there is no cleaner source. Any authenticated session that + * can open Settings → Permissions can call it, which is why a consumer repo can now do this for + * itself. One read; it never writes to the instance. + */ +export async function capturePermissionCatalog( + client: Pick, +): Promise> { + const master = (await client.legacyPostForm("churchauth/ajax", { func: "getMasterData" })) as MasterData; + const authTable = master?.data?.auth_table ?? master?.auth_table; + if (!authTable || typeof authTable !== "object") { + throw new Error( + "Unexpected response from churchauth/ajax getMasterData: no data.auth_table. The legacy endpoint " + + "or its shape may have changed — the bundled catalog is still in use.", + ); + } + const rights: Record = {}; + for (const [moduleName, moduleRights] of Object.entries(authTable)) { + for (const [rightName, raw] of Object.entries(moduleRights)) { + const field = raw.datenfeld; + rights[`${moduleName}:${rightName}`] = { + authId: raw.id, + scopeField: field && String(field).length > 0 ? String(field) : null, + revocable: Boolean(raw.isRevocable), + desc: raw.bezeichnung ? String(raw.bezeichnung) : "", + }; + } + } + const host = client.host.replace(/^https?:\/\//, ""); + return { + // Reserved provenance key, split off by catalog.ts and never seen as a right. + $meta: { + capturedFrom: host, + ctVersion: client.version ?? "unknown", + capturedAt: new Date().toISOString().slice(0, 10), + rightCount: Object.keys(rights).length, + source: "POST /index.php?q=churchauth/ajax func=getMasterData", + regenerate: `ct permissions catalog --refresh (writes ${hostCatalogPath(client.host)})`, + }, + ...rights, + }; +} + +/** Write a capture to this host's per-instance path, creating `.ct/` if needed. Returns the path. */ +export async function writeHostCatalog( + host: string, + catalog: Record, + dir: string = CATALOG_DIR, +): Promise { + await mkdir(dir, { recursive: true }); + const path = hostCatalogPath(host, dir); + await writeFile(path, `${JSON.stringify(catalog, null, 1)}\n`, "utf8"); + return path; +} diff --git a/src/permissions/catalog.ts b/src/permissions/catalog.ts index 91ef92b..21d54c8 100644 --- a/src/permissions/catalog.ts +++ b/src/permissions/catalog.ts @@ -30,29 +30,93 @@ export interface CatalogMeta { [k: string]: unknown; } -// Split the reserved `$meta` provenance key from the rights. Done once at module load so every -// consumer of CATALOG sees rights only, and metadata is available without a second parse. -const { $meta, ...rights } = catalogData as unknown as { $meta?: CatalogMeta } & Record; +/** Split the reserved `$meta` provenance key from the rights, so no consumer sees it as a right. */ +function splitCatalog(data: unknown): { rights: Record; meta: CatalogMeta | null } { + const { $meta, ...rights } = data as { $meta?: CatalogMeta } & Record; + return { rights, meta: $meta ?? null }; +} + +const bundled = splitCatalog(catalogData); /** - * The permission catalog: name → authId bridge, inlined at build time (see the module header). - * It is a constant, not something "loaded" — callers that need a snapshot already spread it, so it - * is exported directly rather than behind a `loadCatalog()` wrapper. Excludes the `$meta` key. + * The permission catalog: name → authId bridge. Bundled at build time (see the module header), and + * REPLACEABLE at runtime by a per-instance capture (#105 — see {@link useCatalog}). + * + * `let`, not `const`, because ESM exports are live bindings: every module that did + * `import { CATALOG }` sees the swap without any of them having to learn that a catalog can be + * loaded. That keeps the "one static catalog" reading of this module intact everywhere it is used, + * while the command layer gets to point it at a fresher one. */ -export const CATALOG = rights as Record; +export let CATALOG: Record = bundled.rights; /** The catalog's recorded provenance (#25), or `null` on a legacy catalog with no `$meta` key. */ -export const CATALOG_META: CatalogMeta | null = $meta ?? null; +export let CATALOG_META: CatalogMeta | null = bundled.meta; + +/** + * True once a capture taken from the TARGET instance itself has been loaded (#105). Such a catalog is + * authoritative for that host, so the version-skew warning — which exists to flag "this snapshot came + * from a different ChurchTools than you are planning against" — has nothing left to say and stays + * quiet. False while the bundled snapshot is active. + */ +export let CATALOG_IS_PER_INSTANCE = false; /** * Every authId the catalog knows a name for. `ct plan` uses this to detect a live grant carrying an * authId the catalog cannot name (a stale/foreign right) — such a grant is warned about and left * untouched, never revoked, because we cannot even describe what we would be deleting (#25). */ -export const KNOWN_AUTH_IDS: ReadonlySet = new Set( - Object.values(CATALOG).map((e) => e.authId), +export let KNOWN_AUTH_IDS: ReadonlySet = new Set(Object.values(CATALOG).map((e) => e.authId)); + +/** + * authId → the scope dimension (`scopeField`) that right is scoped by, or `null` for an unscoped + * right. The inverse of the name-keyed catalog. Used wherever a LIVE grant — which carries an authId + * and no name — must be classified by dimension: `preserveUnknown` (#102) and the declarability + * verdict in `ct coverage` (#103). An authId absent from the map is a right the catalog cannot name. + */ +export let SCOPE_FIELD_BY_AUTH_ID: ReadonlyMap = new Map( + Object.values(CATALOG).map((e) => [e.authId, e.scopeField] as const), +); + +/** + * Every distinct scope dimension the catalog knows, for validating an author-supplied dimension list + * (`preserveUnknown: ["cc_html_template"]`, #102). A typo there would otherwise preserve nothing and + * look identical to "there was nothing to preserve". + */ +export let KNOWN_SCOPE_FIELDS: ReadonlySet = new Set( + Object.values(CATALOG) + .map((e) => e.scopeField) + .filter((f): f is string => f != null), ); +/** + * Point every catalog consumer at a different capture (#105). Used by the per-instance catalog + * (`.ct/permission-catalog..json`) so a consumer repo can act on the staleness warning without + * waiting for a release of this package — the bundled catalog is a snapshot of ONE instance's + * ChurchTools version, and the warning that told people to regenerate it named a script that only + * exists in this repo. + * + * Every derived index is rebuilt here, so the whole module stays internally consistent — there is no + * path that leaves `KNOWN_AUTH_IDS` describing the old catalog. + */ +export function useCatalog(data: unknown, opts: { perInstance?: boolean } = {}): void { + const next = splitCatalog(data); + CATALOG_IS_PER_INSTANCE = opts.perInstance ?? false; + CATALOG = next.rights; + CATALOG_META = next.meta; + KNOWN_AUTH_IDS = new Set(Object.values(CATALOG).map((e) => e.authId)); + SCOPE_FIELD_BY_AUTH_ID = new Map(Object.values(CATALOG).map((e) => [e.authId, e.scopeField] as const)); + KNOWN_SCOPE_FIELDS = new Set( + Object.values(CATALOG) + .map((e) => e.scopeField) + .filter((f): f is string => f != null), + ); +} + +/** Restore the catalog bundled with this release. Exists so tests can undo {@link useCatalog}. */ +export function useBundledCatalog(): void { + useCatalog(catalogData); +} + export function resolveAuthId(name: string): CatalogEntry { const entry = CATALOG[name]; if (!entry) { diff --git a/src/permissions/grants.ts b/src/permissions/grants.ts index 23833e0..893fbe4 100644 --- a/src/permissions/grants.ts +++ b/src/permissions/grants.ts @@ -76,9 +76,31 @@ export function normalizeActual(rows: RawPermission[]): GrantTuple[] { return out; } -export interface GrantDiff { toPut: GrantTuple[]; toDelete: GrantTuple[]; preserved: GrantTuple[] } +export interface GrantDiff { + toPut: GrantTuple[]; + toDelete: GrantTuple[]; + preserved: GrantTuple[]; + /** + * Live grants that WOULD have been revoked but were kept, because the declaration opted into + * `preserveUnknown` for their scope dimension (#102). Separate from {@link preserved} (pre-existing + * deny rows, which reconciliation never owned in the first place): these are grants this + * declaration COULD have owned and deliberately does not. Rendered explicitly — "I forgot one" and + * "I deliberately left the module grants alone" must never look alike in a plan. + */ + preservedUnknown: GrantTuple[]; +} + +/** + * Decide whether a live grant absent from the declaration should be KEPT rather than revoked (#102). + * Built from a declaration's `preserveUnknown`; `undefined` means the strict default (revoke). + */ +export type PreservePredicate = (t: GrantTuple) => boolean; -export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDiff { +export function diffGrants( + desired: GrantTuple[], + actual: GrantTuple[], + preserveUnknown?: PreservePredicate, +): GrantDiff { // Reconciliation owns only user-authored GRANT rows. `desiredTuples` only ever emits // `type: "grant"`, so an explicit deny row (`type: "revoke"`) has no desired counterpart and // would land in `toDelete` — silently removing an admin's deny. Treat non-grant rows as @@ -88,6 +110,12 @@ export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDi const desiredKeys = new Map(desired.map((t) => [tupleKey(t), t])); const actualKeys = new Map(managedActual.map((t) => [tupleKey(t), t])); const toPut = [...desiredKeys].filter(([k]) => !actualKeys.has(k)).map(([, t]) => t); - const toDelete = [...actualKeys].filter(([k]) => !desiredKeys.has(k)).map(([, t]) => t); - return { toPut, toDelete, preserved }; + const undeclared = [...actualKeys].filter(([k]) => !desiredKeys.has(k)).map(([, t]) => t); + // Opt-in partial ownership (#102). Without it, ONE live grant on a dimension this tool has no + // business managing (a wiki category, an HTML template) makes the whole role instance undeclarable, + // because a partial declaration turns a clean no-op into a destructive plan. The split happens here + // rather than upstream so `toDelete` keeps its exact meaning — "apply will revoke this". + const toDelete = preserveUnknown ? undeclared.filter((t) => !preserveUnknown(t)) : undeclared; + const preservedUnknown = preserveUnknown ? undeclared.filter((t) => preserveUnknown(t)) : []; + return { toPut, toDelete, preserved, preservedUnknown }; } diff --git a/src/permissions/plan.ts b/src/permissions/plan.ts index c49c015..c1c4d87 100644 --- a/src/permissions/plan.ts +++ b/src/permissions/plan.ts @@ -8,11 +8,25 @@ import type { CtClient } from "../api/ctClient.js"; import { CtApiError } from "../api/ctClient.js"; import type { State } from "../state/state.js"; import type { DesiredResource } from "../engine/types.js"; -import { resolveAuthId, CATALOG_META, KNOWN_AUTH_IDS } from "./catalog.js"; +import { + resolveAuthId, + CATALOG_META, + CATALOG_IS_PER_INSTANCE, + KNOWN_AUTH_IDS, + SCOPE_FIELD_BY_AUTH_ID, +} from "./catalog.js"; import { compareVersions } from "../api/version.js"; import { resolveScope, resolveScopeRefs, type ScopeRefMap } from "./scope.js"; -import { normalizeActual, diffGrants, type GrantTuple, type GrantDiff, type DomainType, type RawPermission } from "./grants.js"; -import type { DesiredPermission } from "./types.js"; +import { + normalizeActual, + diffGrants, + type GrantTuple, + type GrantDiff, + type DomainType, + type PreservePredicate, + type RawPermission, +} from "./grants.js"; +import type { DesiredPermission, PreserveUnknown } from "./types.js"; import { Resolver } from "../resolve/resolver.js"; import { isPendingRef, refKey, refLabel, type Ref } from "../resolve/refs.js"; @@ -78,6 +92,27 @@ export function desiredTuples( }); } +/** + * Turn a declaration's `preserveUnknown` (#102) into the predicate `diffGrants` applies to every live + * grant the declaration does not mention. `undefined` ⇒ no predicate ⇒ the strict default (revoke). + * + * The dimension of a live grant comes from its authId via the catalog. A grant whose authId the + * catalog cannot name never reaches here — `buildPermissionPlan` already excludes those from the diff + * entirely (they are warned about and left untouched, never revoked). + */ +export function preservePredicateFor(preserveUnknown: PreserveUnknown | undefined): PreservePredicate | undefined { + if (preserveUnknown === undefined || preserveUnknown === false) return undefined; + if (preserveUnknown === true) return () => true; + const dimensions = new Set(preserveUnknown); + return (t) => { + const scopeField = SCOPE_FIELD_BY_AUTH_ID.get(t.authId); + // An UNSCOPED right (scopeField null) is never covered by a dimension list: the author named + // dimensions to leave alone, and "no dimension" is not one of them. Preserving it would silently + // widen the escape hatch past what was asked for. + return scopeField != null && dimensions.has(scopeField); + }; +} + /** * A permission whose domainId has been resolved. Either a concrete numeric domain, or — when the * domain is a group type created in this same run (#69) — a `pendingDomain` Ref with `domainId: null`, @@ -113,7 +148,14 @@ async function resolveDomainIds( const site = `${p.domainType} "${p.key}".domainId`; const res = await resolver.resolve(p.domainId, site); if (isPendingRef(res)) { - resolved.push({ key: p.key, domainType: p.domainType, grants: p.grants, domainId: null, pendingDomain: res.__pendingRef }); + resolved.push({ + key: p.key, + domainType: p.domainType, + grants: p.grants, + preserveUnknown: p.preserveUnknown, + domainId: null, + pendingDomain: res.__pendingRef, + }); continue; } resolved.push({ ...p, domainId: res }); @@ -143,14 +185,26 @@ export async function buildPermissionPlan( const items: PermissionPlanItem[] = []; const fetchErrors: string[] = []; const warnings: string[] = []; - // Catalog staleness (#25): the catalog is a snapshot captured against one CT version. If the live - // instance reports a different version, right names/authIds/scopeFields may have drifted — warn - // (never fail) so the diff is trusted-but-verified and the fix (regenerate) is one command away. - if (permissions.length > 0 && instanceVersion && CATALOG_META && compareVersions(instanceVersion, CATALOG_META.ctVersion) !== 0) { + // Catalog staleness (#25/#105): the catalog is a snapshot captured against one CT version. If the + // live instance reports a different version, right names/authIds/scopeFields may have drifted — + // warn (never fail) so the diff is trusted-but-verified. + // + // Two narrowings since #105, both aimed at the same failure: a warning that fires on every plan is + // a warning nobody reads, including on the plan where a moved authId would actually matter. + // 1. A catalog captured FROM THIS HOST is authoritative for it, so a version delta against the + // BUNDLED catalog is the only thing worth mentioning. + // 2. The remediation now names a command a consumer repo can actually run. + if ( + permissions.length > 0 && + instanceVersion && + CATALOG_META && + !CATALOG_IS_PER_INSTANCE && + compareVersions(instanceVersion, CATALOG_META.ctVersion) !== 0 + ) { warnings.push( `Permission catalog was captured from ChurchTools ${CATALOG_META.ctVersion} but this instance ` + - `runs ${instanceVersion}. Right names/authIds may be stale — regenerate it with ` + - `\`npm run regenerate:permission-catalog\` (see docs/handbuch/permissions.md).`, + `runs ${instanceVersion}. Right names/authIds may be stale — capture one for this instance ` + + `with \`ct permissions catalog --refresh\` (see docs/handbuch/permissions.md).`, ); } // Resolve logical domainIds (#20) up front. Shares the command layer's resolver so master-data @@ -224,7 +278,16 @@ export async function buildPermissionPlan( `(\`npm run regenerate:permission-catalog\`) if this right should be manageable.`, ); } - items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state, declaredGroupKeys, scopeRefs), knownActual) }); + items.push({ + key: p.key, + domainType: p.domainType, + domainId: p.domainId, + diff: diffGrants( + desiredTuples(p, state, declaredGroupKeys, scopeRefs), + knownActual, + preservePredicateFor(p.preserveUnknown), + ), + }); } return { items, fetchErrors, warnings }; } diff --git a/src/permissions/render.ts b/src/permissions/render.ts index d69146d..3086b0b 100644 --- a/src/permissions/render.ts +++ b/src/permissions/render.ts @@ -29,7 +29,12 @@ function fmtTuple(t: GrantTuple): string { } export function renderPermissionPlan(items: PermissionPlanItem[]): string { - const changed = items.filter((i) => i.diff.toPut.length > 0 || i.diff.toDelete.length > 0); + // A `preserveUnknown` item can have an EMPTY diff and still deserve a line (#102): the whole point + // of the opt-in is that "I deliberately left the module grants alone" is visible, and it would not + // be if a role whose only interesting property is 41 preserved grants rendered as nothing at all. + const changed = items.filter( + (i) => i.diff.toPut.length > 0 || i.diff.toDelete.length > 0 || i.diff.preservedUnknown.length > 0, + ); if (changed.length === 0) { const preserved = items.reduce((n, i) => n + i.diff.preserved.length, 0); @@ -42,14 +47,18 @@ export function renderPermissionPlan(items: PermissionPlanItem[]): string { const lines: string[] = []; let totalGrant = 0; let totalRevoke = 0; + let totalPreserved = 0; for (const item of changed) { const grantCount = item.diff.toPut.length; const revokeCount = item.diff.toDelete.length; + const preservedCount = item.diff.preservedUnknown.length; totalGrant += grantCount; totalRevoke += revokeCount; + totalPreserved += preservedCount; + const preservedNote = preservedCount > 0 ? `, ${pc.dim(`~${preservedCount} preserved`)}` : ""; lines.push( - ` ${item.domainType} ${fmtDomain(item)} (${item.key}): ${pc.green(`+${grantCount} grant(s)`)}, ${pc.red(`-${revokeCount} remove(s)`)}`, + ` ${item.domainType} ${fmtDomain(item)} (${item.key}): ${pc.green(`+${grantCount} grant(s)`)}, ${pc.red(`-${revokeCount} remove(s)`)}${preservedNote}`, ); for (const t of item.diff.toPut) { lines.push(` ${pc.green("+")} ${fmtTuple(t)}`); @@ -57,12 +66,18 @@ export function renderPermissionPlan(items: PermissionPlanItem[]): string { for (const t of item.diff.toDelete) { lines.push(` ${pc.red("-")} ${fmtTuple(t)}`); } + for (const t of item.diff.preservedUnknown) { + lines.push(` ${pc.dim(`~ ${fmtTuple(t)} (preserved, not managed — preserveUnknown)`)}`); + } for (const t of item.diff.preserved) { lines.push(` ${pc.dim(`~ ${fmtTuple(t)} (pre-existing deny — left untouched)`)}`); } } lines.push(""); - lines.push(pc.bold(`Permission plan: ${totalGrant} to grant, ${totalRevoke} to remove.`)); + // Preserved grants are counted separately and never folded into the change totals: they are + // explicitly what apply will NOT do. + const preservedSummary = totalPreserved > 0 ? `, ${totalPreserved} preserved (not managed)` : ""; + lines.push(pc.bold(`Permission plan: ${totalGrant} to grant, ${totalRevoke} to remove${preservedSummary}.`)); return lines.join("\n"); } diff --git a/src/permissions/types.ts b/src/permissions/types.ts index f2a1115..050ab53 100644 --- a/src/permissions/types.ts +++ b/src/permissions/types.ts @@ -33,6 +33,22 @@ export type ScopeEntry = string | number | Ref | ScopeSugar; export type Grant = string | { right: string; scope: ScopeEntry[] }; +/** + * Opt-in partial grant ownership (#102). A declaration normally OWNS its domain outright: every live + * grant absent from `grants` is revoked. That is the right default and stays the default — but it also + * means one unmanageable grant makes a whole role instance undeclarable, and on a real instance the + * blocker is usually module data (calendar categories, HTML templates, wiki categories) sitting next + * to perfectly expressible structural grants. + * + * - `true` — keep every live grant this declaration does not mention. + * - `string[]` — keep them only on these scope dimensions (`scopeField`s). The narrow form, and the + * one worth reaching for: an unexpected new grant on a dimension you DO manage still + * shows up as drift instead of being swallowed. + * + * Preserved grants are never invisible — the plan renders each one (see `renderPermissionPlan`). + */ +export type PreserveUnknown = boolean | string[]; + export interface DesiredPermission { key: string; domainType: DomainType; @@ -43,4 +59,6 @@ export interface DesiredPermission { */ domainId: number | Ref; grants: Grant[]; + /** Opt-in escape from whole-domain ownership (#102). Omitted ⇒ strict: undeclared grant → revoke. */ + preserveUnknown?: PreserveUnknown; } diff --git a/src/resolve/reverse.ts b/src/resolve/reverse.ts index 6ea3893..e0a8f7d 100644 --- a/src/resolve/reverse.ts +++ b/src/resolve/reverse.ts @@ -56,22 +56,27 @@ 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; } private index(path: string): Promise> { let p = this.catalogs.get(path); if (!p) { + // `getAll`, never a plain `get` (#101): these catalogs are LIST endpoints, so a plain GET + // returns only CT's default first page (10 rows). That silently truncated every reverse map — + // 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 - .get(path) - .then((rows) => { + .getAll(path) + .then(({ data: rows }) => { const map = new Map(); if (Array.isArray(rows)) { for (const row of rows) { @@ -116,8 +121,8 @@ export class ReverseResolver { async roleGroupTypeCatalog(): Promise> { if (!this.roleCatalog) { this.roleCatalog = this.client - .get("/group/roles") - .then((rows) => { + .getAll("/group/roles") + .then(({ data: rows }) => { const map = new Map(); if (Array.isArray(rows)) { for (const row of rows) { diff --git a/tests/adopt-grants-bulk.test.ts b/tests/adopt-grants-bulk.test.ts new file mode 100644 index 0000000..1a9ae2c --- /dev/null +++ b/tests/adopt-grants-bulk.test.ts @@ -0,0 +1,201 @@ +/** + * Bulk `ct adopt grants` (#104). + * + * Adopting the declarable estate of a real instance used to mean one invocation and one manual paste + * per role instance, each needing its `key` renamed and its emitted numeric `id:` swapped for the + * portable `group` + `role` pair — the two edits a human forgets on the 30th paste. These tests pin + * the portable emission, the derived key, and the refusal to emit a revoking block in bulk. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RawPermission } from "../src/permissions/grants.js"; +import type { State } from "../src/state/state.js"; + +/** `churchcore:administer settings` (unscoped) and `churchgroup:view group` (`cdb_gruppe`). */ +const UNSCOPED = 1; +const VIEW_GROUP = 1104; +/** `churchcore:use church html templates` — `cc_html_template`, module data with no resource. */ +const HTML_TEMPLATE = 17; + +const perm = (authId: number, domainId: number, dataId: number | null = null): RawPermission => ({ + authId, + dataId, + type: "grant", + domainId, + meta: { modifiedPid: 5 }, +}); + +/** + * Two groups. `kids` (#10, managed as "kids") has a declarable Leiter role and a Mitglied role blocked + * by a module dimension; `ops` (#11, unmanaged) has a declarable Leiter role. + */ +const groupRows = [ + { + id: 10, + name: "Kids", + information: { groupTypeId: 2 }, + roles: [ + { id: 100, groupTypeRoleId: 16 }, + { id: 101, groupTypeRoleId: 17 }, + ], + }, + { id: 11, name: "Ops", information: { groupTypeId: 2 }, roles: [{ id: 200, groupTypeRoleId: 16 }] }, +]; +const roleDefRows = [ + { id: 16, name: "Leiter", groupTypeId: 2 }, + { id: 17, name: "Mitglied", groupTypeId: 2 }, +]; +const permissions: RawPermission[] = [ + perm(UNSCOPED, 100), + perm(VIEW_GROUP, 100, 10), + perm(UNSCOPED, 101), + perm(HTML_TEMPLATE, 101, 3), // blocks role instance 101 + perm(UNSCOPED, 200), +]; + +const getMock = vi.fn(async (path: string): Promise => { + if (path === "/permissions/group_role") return permissions; + const m = /^\/permissions\/group_role\/(\d+)$/.exec(path); + if (m) return permissions.filter((p) => p.domainId === Number(m[1])); + throw new Error(`unmocked GET ${path}`); +}); +const getAllMock = vi.fn(async (path: string): Promise<{ data: unknown[] }> => { + if (path.startsWith("/groups")) return { data: groupRows }; + if (path === "/group/roles") return { data: roleDefRows }; + throw new Error(`unmocked getAll ${path}`); +}); + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client: { get: getMock, getAll: getAllMock }, me: { id: 1 } })), +})); + +const { adoptCommand } = await import("../src/commands/adopt.js"); + +const HOST = "https://mychurch.church.tools"; +const originalHost = process.env.CT_HOST; +let workDir: string; +let statePath: string; + +const state: State = { + version: 1, + host: HOST, + resources: { + kids: { type: "group", id: 10, key: "kids", fields: { name: "Kids" }, adoptedAt: "t", updatedAt: "t" }, + }, +}; + +async function run(args: string[]): Promise<{ stdout: string; stderr: string }> { + const outs: string[] = []; + const errs: string[] = []; + const o = vi.spyOn(process.stdout, "write").mockImplementation((s) => { + outs.push(String(s)); + return true; + }); + const e = vi.spyOn(process.stderr, "write").mockImplementation((s) => { + errs.push(String(s)); + return true; + }); + try { + await adoptCommand().parseAsync([...args, "--state", statePath], { from: "user" }); + } finally { + o.mockRestore(); + e.mockRestore(); + } + return { stdout: outs.join(""), stderr: errs.join("") }; +} + +beforeEach(() => { + getMock.mockClear(); + getAllMock.mockClear(); + process.env.CT_HOST = HOST; + workDir = mkdtempSync(join(tmpdir(), "ct-adopt-grants-bulk-")); + statePath = join(workDir, "ct-state.json"); + writeFileSync(statePath, JSON.stringify(state, null, 2), "utf8"); +}); + +afterEach(() => { + if (originalHost === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = originalHost; + rmSync(workDir, { recursive: true, force: true }); +}); + +describe("ct adopt grants --group (#104)", () => { + it("emits the PORTABLE group + role form and a derived key when the group is managed", async () => { + const { stdout } = await run(["grants", "--group", "kids"]); + expect(stdout).toContain('key: "kids_leiter"'); + expect(stdout).toContain('group: "kids"'); + expect(stdout).toContain('role: "Leiter"'); + // The host-specific pairing id is exactly what the portable form replaces. + expect(stdout).not.toContain("id: 100"); + }); + + it("skips the role instance blocked by a module dimension, and says which and why", async () => { + const { stdout, stderr } = await run(["grants", "--group", "kids"]); + expect(stdout).not.toContain("kids_mitglied"); + expect(stderr).toContain("Kids / Mitglied (domainId 101)"); + expect(stderr).toContain("cc_html_template"); + expect(stderr).toContain("preserveUnknown"); + }); + + it("falls back to the numeric id form when the group is NOT managed", async () => { + const { stdout } = await run(["grants", "--group", "11"]); + expect(stdout).toContain("id: 200"); + expect(stdout).toContain("host-specific — adopt the group"); + expect(stdout).not.toContain('group: "'); + }); + + it("resolves --group by live name as well as by managed key and numeric id", async () => { + const { stdout } = await run(["grants", "--group", "Kids"]); + expect(stdout).toContain('group: "kids"'); + }); + + it("summarises what it emitted and what it did not — never a silent cap", async () => { + const { stderr } = await run(["grants", "--group", "kids"]); + expect(stderr).toMatch(/1 block\(s\) emitted .* 1 skipped \(not declarable\)/); + }); +}); + +describe("ct adopt grants --all-declarable (#104)", () => { + it("emits every declarable role instance on the host in one invocation", async () => { + const { stdout } = await run(["grants", "--all-declarable"]); + expect(stdout).toContain('key: "kids_leiter"'); + expect(stdout).toContain("id: 200"); // the unmanaged group's declarable role, numeric form + expect(stdout).not.toContain("kids_mitglied"); + }); + + it("--write appends the blocks to a file instead of printing them", async () => { + const target = join(workDir, "grants.ts"); + writeFileSync(target, "// existing\n", "utf8"); + const { stdout } = await run(["grants", "--all-declarable", "--write", target]); + const written = readFileSync(target, "utf8"); + expect(written).toContain("// existing"); + expect(written).toContain('key: "kids_leiter"'); + expect(stdout).not.toContain("ct.groupRole"); // went to the file, not stdout + }); +}); + +describe("ct adopt grants — argument guards (#104)", () => { + it("rejects mixing a positional domain with a bulk selector", async () => { + await expect(run(["grants", "group_role", "100", "--group", "kids"])).rejects.toThrow(/not both/); + }); + + it("rejects both bulk selectors at once", async () => { + await expect(run(["grants", "--group", "kids", "--all-declarable"])).rejects.toThrow( + /only one of: --group, --all-declarable/, + ); + }); + + it("still supports the original single-domain form", async () => { + const { stdout } = await run(["grants", "group_role", "100"]); + expect(getMock).toHaveBeenCalledWith("/permissions/group_role/100"); + // The single form keeps its pre-#104 shape: numeric id, rename-to-taste key. + expect(stdout).toContain("id: 100"); + expect(stdout).toContain("rename to taste"); + }); + + it("explains what to pass when given nothing at all", async () => { + await expect(run(["grants"])).rejects.toThrow(/Specify /); + }); +}); diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index c46eaba..ae40721 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -381,28 +381,59 @@ describe("ct adopt group --with-dynamic --portable-rulesets (#76 Stage 3)", () = expect(and[3]!.oneof![1]).toEqual([1, 2]); }); - it("warns once naming the file for the unmanaged id it left numeric (escape hatch)", async () => { + it("names every dimension it left numeric, with the reason (#101)", async () => { const errs: string[] = []; const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { errs.push(String(s)); return true; }); try { - await adoptWithManagedGroup(["--portable-rulesets"]); + await adoptWithManagedGroup([]); } finally { spy.mockRestore(); } const warned = errs.join(""); - expect(warned).toMatch(/left 1 unmanaged id\(s\) numeric in portable_dynamic\.json/); - expect(warned).toContain("escape hatch"); + // The whole point of #101: not "left N ids numeric", but WHICH dimension, WHICH ids, and WHY. + expect(warned).toContain("rulesets/portable_dynamic.json keeps"); + expect(warned).toContain("NOT portable to another host"); + expect(warned).toMatch(/ctgroup\.id: 999 left numeric — group #999 is not under management/); + expect(warned).toMatch(/ctgroup\.groupStatusId: 1, 2 left numeric — group statuses have no REST catalog/); }); - it("is OFF by default: a plain --with-dynamic capture keeps raw numeric ids (no markers)", async () => { - await adoptWithManagedGroup([]); // no --portable-rulesets + it("is ON by default since #101: a plain --with-dynamic capture emits ref markers", async () => { + await adoptWithManagedGroup([]); // no flag at all + const written = JSON.parse(await readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8")); + const and = (written.query as { and: Array> }).and; + expect(and[0]!.oneof![1]).toEqual([{ __ctRef: true, kind: "group", key: "area_a" }, 999]); + }); + + it("--no-portable-rulesets opts out, keeping raw numeric ids — and says the capture is host-specific", async () => { + const errs: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { + errs.push(String(s)); + return true; + }); + try { + await adoptWithManagedGroup(["--no-portable-rulesets"]); + } finally { + spy.mockRestore(); + } const written = JSON.parse(await readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8")); const and = (written.query as { and: Array> }).and; expect(and[0]!.oneof![1]).toEqual([10, 999]); // raw ids, no rewrite expect(JSON.stringify(written)).not.toContain("__ctRef"); + expect(errs.join("")).toContain("captured verbatim (--no-portable-rulesets)"); + }); + + it("--strict-rulesets refuses to write a ruleset that still carries a host-specific id", async () => { + await run(["group", "10", "--state", statePath]); + await expect( + run(["group", "33", "--with-dynamic", "--strict-rulesets", "--state", statePath]), + ).rejects.toThrow(/--strict-rulesets/); + // Nothing was written — the refusal must not leave a half-portable file behind. + await expect( + readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); }); it("--dry-run --portable-rulesets writes no file", async () => { diff --git a/tests/adopt.test.ts b/tests/adopt.test.ts index 4c47173..0236d5b 100644 --- a/tests/adopt.test.ts +++ b/tests/adopt.test.ts @@ -4,10 +4,19 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; const fakeCampus: Record = { id: 0, name: "Mainz", shorty: "MZ" }; -const getMock = vi.fn(async (): Promise> => fakeCampus); +const getMock = vi.fn(async (...args: [string?]): Promise> => { + void args; + return fakeCampus; +}); +// The ReverseResolver reads its master-data catalogs through `getAll` (#101): a plain `get` returns +// only CT's default first page, which silently truncated every id→key map on a real instance. +const getAllMock = vi.fn(async (path: string): Promise<{ data: unknown[] }> => { + const single = await getMock(path); + return { data: Array.isArray(single) ? single : [single] }; +}); vi.mock("../src/api/session.js", () => ({ - authedSession: vi.fn(async () => ({ client: { get: getMock }, me: { id: 1 } })), + authedSession: vi.fn(async () => ({ client: { get: getMock, getAll: getAllMock }, me: { id: 1 } })), })); const { adoptCommand } = await import("../src/commands/adopt.js"); diff --git a/tests/coverage.test.ts b/tests/coverage.test.ts new file mode 100644 index 0000000..6dd295f --- /dev/null +++ b/tests/coverage.test.ts @@ -0,0 +1,177 @@ +/** + * `ct coverage` (#103). The verdict half is what matters: whether a live (group, role) instance's + * grants could be declared as config today, and — when they cannot — which scope dimension blocks it. + * Everything here is offline: the report builder takes already-fetched rows. + */ +import { describe, it, expect } from "vitest"; +import { + buildCoverageReport, + declarability, + decodeGroupsWithRoles, + renderCoverage, + type GroupRow, +} from "../src/coverage/report.js"; +import type { RawPermission } from "../src/permissions/grants.js"; +import type { State } from "../src/state/state.js"; + +/** `churchgroup:view group` — `cdb_gruppe`, a dimension with a logical reference form (#98). */ +const VIEW_GROUP = 1104; +/** `churchcore:use church html templates` — `cc_html_template`, module data with no resource behind it. */ +const HTML_TEMPLATE = 17; +/** `churchcore:administer settings` — unscoped. */ +const UNSCOPED = 1; + +const row = (o: Partial & { authId: number; domainId: number }): RawPermission => ({ + dataId: null, + type: "grant", + meta: { modifiedPid: 7 }, + ...o, +}); + +describe("declarability (#103)", () => { + it("calls an unscoped + group-scoped role instance declarable", () => { + const v = declarability([ + row({ authId: UNSCOPED, domainId: 1 }), + row({ authId: VIEW_GROUP, dataId: 42, domainId: 1 }), + ]); + expect(v).toMatchObject({ declarable: true, grantCount: 2, blockedBy: [] }); + }); + + it("names the blocking dimension rather than just saying no", () => { + const v = declarability([ + row({ authId: VIEW_GROUP, dataId: 42, domainId: 1 }), + row({ authId: HTML_TEMPLATE, dataId: 3, domainId: 1 }), + ]); + expect(v.declarable).toBe(false); + expect(v.blockedBy).toEqual(["cc_html_template"]); + // The count is of ALL authored grants, so "blocked by 1 of 2" is visible, not just "blocked". + expect(v.grantCount).toBe(2); + }); + + it("excludes inherited rows and the system baseline from the authored count", () => { + // Forgetting this filter is the exact mistake that inflated a hand-rolled audit from 590 to 714 + // grants and made several role instances look unmanageable that are not. + const v = declarability([ + row({ authId: VIEW_GROUP, dataId: 42, domainId: 1 }), + row({ authId: HTML_TEMPLATE, dataId: 3, domainId: 1, isInherited: true }), + row({ authId: HTML_TEMPLATE, dataId: 4, domainId: 1, meta: { modifiedPid: -1 } }), + ]); + expect(v.grantCount).toBe(1); + expect(v.declarable).toBe(true); // the only blocker was an inherited row ct never authors + }); + + it("treats the -1 ALL sentinel as declarable on any dimension", () => { + const v = declarability([row({ authId: HTML_TEMPLATE, dataId: -1, domainId: 1 })]); + expect(v.declarable).toBe(true); + }); + + it("blocks on a right the catalog cannot even name", () => { + const v = declarability([row({ authId: 999_999, dataId: 1, domainId: 1 })]); + expect(v.declarable).toBe(false); + expect(v.unknownAuthIds).toEqual([999_999]); + }); +}); + +describe("buildCoverageReport (#103)", () => { + const state: State = { + version: 1, + host: "h", + resources: { + struktur_a: { type: "group", id: 1, key: "struktur_a", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + }; + const groups: GroupRow[] = [ + { + id: 1, + name: "Struktur A", + groupTypeId: 9, + roles: [ + { domainId: 100, groupId: 1, groupName: "Struktur A", groupTypeId: 9, roleName: "Leiter" }, + { domainId: 101, groupId: 1, groupName: "Struktur A", groupTypeId: 9, roleName: "Mitglied" }, + ], + }, + { + id: 2, + name: "Local Lead B", + groupTypeId: 12, + roles: [{ domainId: 200, groupId: 2, groupName: "Local Lead B", groupTypeId: 12, roleName: "Leiter" }], + }, + { id: 3, name: "Plain C", groupTypeId: 12, roles: [] }, + ]; + const report = buildCoverageReport({ + host: "h", + state, + groups, + groupTypeNames: new Map([[9, "Struktur"], [12, "Local Lead"]]), + dynamicGroupIds: new Set([1, 3]), + groupRolePermissions: [ + row({ authId: VIEW_GROUP, dataId: 42, domainId: 100 }), + row({ authId: HTML_TEMPLATE, dataId: 3, domainId: 101 }), + row({ authId: UNSCOPED, domainId: 200 }), + ], + }); + + it("reports declarability PER (group, role), not per group", () => { + // Struktur A has one declarable role and one blocked one — reporting at group granularity would + // hide exactly that, which is why the unit is the role instance. + const strukturA = report.roleInstances.filter((r) => r.groupId === 1); + expect(strukturA.map((r) => [r.roleName, r.verdict.declarable])).toEqual([ + ["Leiter", true], + ["Mitglied", false], + ]); + expect(report.grants).toMatchObject({ declarable: 2, blocked: 1, roleInstances: 3, authored: 3 }); + }); + + it("collects every blocking dimension instance-wide", () => { + expect(report.grants.blockingDimensions).toEqual(["cc_html_template"]); + }); + + it("counts managed / dynamic / unmanaged-with-grants per group type", () => { + const byName = new Map(report.byType.map((t) => [t.name, t])); + expect(byName.get("Struktur")).toMatchObject({ total: 1, managed: 1, dynamic: 1, unmanagedWithGrants: 0 }); + // Group 2 is unmanaged AND carries grants — the "you are missing this" number. Group 3 is + // unmanaged too but has no grants, so it must not inflate it. + expect(byName.get("Local Lead")).toMatchObject({ total: 2, managed: 0, dynamic: 1, unmanagedWithGrants: 1 }); + }); + + it("skips role instances with no authored grants entirely", () => { + expect(report.roleInstances.some((r) => r.groupId === 3)).toBe(false); + }); + + it("marks which role instances sit on a managed group", () => { + expect(report.roleInstances.find((r) => r.domainId === 100)?.managedGroupKey).toBe("struktur_a"); + expect(report.roleInstances.find((r) => r.domainId === 200)?.managedGroupKey).toBeNull(); + }); + + it("renders the totals, the per-type table and the blocking dimensions", () => { + const text = renderCoverage(report); + expect(text).toContain("3 groups · 1 managed · 3 authored grants over 3 role instances"); + expect(text).toContain("Struktur"); + expect(text).toContain("2 role instance(s) declarable · 1 blocked"); + expect(text).toContain("blocked by: cc_html_template"); + }); +}); + +describe("decodeGroupsWithRoles (#103)", () => { + it("reads groupTypeId from `information` and role names inline", () => { + const [g] = decodeGroupsWithRoles( + [{ id: 5, name: "Kids", information: { groupTypeId: 2 }, roles: [{ id: 77, name: "Leiter" }] }], + new Map(), + ); + expect(g).toMatchObject({ id: 5, groupTypeId: 2 }); + expect(g?.roles[0]).toMatchObject({ domainId: 77, roleName: "Leiter", groupId: 5 }); + }); + + it("falls back to the /group/roles catalog when the role row carries no inline name", () => { + const [g] = decodeGroupsWithRoles( + [{ id: 5, name: "Kids", groupTypeId: 2, roles: [{ id: 77, groupTypeRoleId: 16 }] }], + new Map([[16, "Organisator"]]), + ); + expect(g?.roles[0]?.roleName).toBe("Organisator"); + }); + + it("still counts a role whose name cannot be recovered — it has grants either way", () => { + const [g] = decodeGroupsWithRoles([{ id: 5, name: "Kids", roles: [{ id: 77 }] }], new Map()); + expect(g?.roles[0]).toMatchObject({ domainId: 77, roleName: "role #77" }); + }); +}); diff --git a/tests/get-command.test.ts b/tests/get-command.test.ts index d2f517b..7ad074c 100644 --- a/tests/get-command.test.ts +++ b/tests/get-command.test.ts @@ -2,9 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const getAllMock = vi.fn(); const getMock = vi.fn(); +const getRawMock = vi.fn(); vi.mock("../src/api/session.js", () => ({ - authedSession: vi.fn(async () => ({ client: { get: getMock, getAll: getAllMock }, me: { id: 1 } })), + authedSession: vi.fn(async () => ({ + client: { get: getMock, getAll: getAllMock, getRaw: getRawMock }, + me: { id: 1 }, + })), })); const { getCommand } = await import("../src/commands/get.js"); @@ -18,6 +22,7 @@ describe("ct get (#50)", () => { beforeEach(() => { getAllMock.mockReset(); getMock.mockReset(); + getRawMock.mockReset(); }); it("auto-paginates a list resource and prints every item, not just the first page", async () => { @@ -97,7 +102,7 @@ describe("ct get (#50)", () => { }); it("propagates a raw call's CtApiError (status + body) instead of swallowing it", async () => { - getMock.mockRejectedValue( + getRawMock.mockRejectedValue( new CtApiError("GET /groups?limit=500 failed", 400, { errors: ["limit exceeds max of 100"] }), ); @@ -107,3 +112,109 @@ describe("ct get (#50)", () => { }); }); }); + +/** + * `ct get raw` used to issue exactly one request and print whatever came back — which for any CT list + * endpoint is its default first page. `ct get raw "/groups"` therefore reported 10 rows on an instance + * with 645, in a valid-looking JSON array, while `ct get groups` on the same path reported all 645. + */ +describe("ct get raw pagination (#100)", () => { + beforeEach(() => { + getAllMock.mockReset(); + getMock.mockReset(); + getRawMock.mockReset(); + }); + + it("follows pagination instead of returning CT's default first page", async () => { + getRawMock.mockResolvedValue({ + data: Array.from({ length: 10 }, (_, i) => ({ id: i })), + meta: { pagination: { total: 645, current: 1, lastPage: 65, limit: 10 } }, + }); + getAllMock.mockResolvedValue({ + data: Array.from({ length: 645 }, (_, i) => ({ id: i })), + meta: { pagination: { total: 645, current: 65, lastPage: 65, limit: 10 } }, + }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runGet(["raw", "/groups"]); + + expect(getAllMock).toHaveBeenCalledWith("/groups"); + const printed = JSON.parse(writeSpy.mock.calls[0]?.[0] as string) as unknown[]; + expect(printed).toHaveLength(645); + writeSpy.mockRestore(); + }); + + it("prints a single-object response as-is and never appends paging params to it", async () => { + getRawMock.mockResolvedValue({ data: { id: 42, name: "Kids" } }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runGet(["raw", "/groups/42"]); + + expect(getRawMock).toHaveBeenCalledWith("/groups/42"); + expect(getAllMock).not.toHaveBeenCalled(); + expect(JSON.parse(writeSpy.mock.calls[0]?.[0] as string)).toEqual({ id: 42, name: "Kids" }); + writeSpy.mockRestore(); + }); + + it("makes exactly one request for an unpaginated list", async () => { + getRawMock.mockResolvedValue({ + data: [{ id: 1 }, { id: 2 }], + meta: { pagination: { total: 2, current: 1, lastPage: 1 } }, + }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runGet(["raw", "/departments"]); + + expect(getAllMock).not.toHaveBeenCalled(); + expect(JSON.parse(writeSpy.mock.calls[0]?.[0] as string)).toHaveLength(2); + writeSpy.mockRestore(); + }); + + it("--no-paginate keeps the single-request probe but WARNS that rows were dropped", async () => { + getRawMock.mockResolvedValue({ + data: Array.from({ length: 10 }, (_, i) => ({ id: i })), + meta: { pagination: { total: 645, current: 1, lastPage: 65 } }, + }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + await runGet(["raw", "/groups", "--no-paginate"]); + + expect(getAllMock).not.toHaveBeenCalled(); + expect(JSON.parse(writeSpy.mock.calls[0]?.[0] as string)).toHaveLength(10); + expect(errSpy.mock.calls.map(String).join("")).toContain("INCOMPLETE: returned 10 of 645 row(s)"); + writeSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it("--page probes exactly that page", async () => { + getRawMock.mockResolvedValue({ + data: [{ id: 300 }], + meta: { pagination: { total: 645, current: 3, lastPage: 65 } }, + }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + await runGet(["raw", "/groups", "--page", "3"]); + + expect(getRawMock).toHaveBeenCalledWith("/groups?page=3&limit=100"); + expect(getAllMock).not.toHaveBeenCalled(); + writeSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it("honours a caller's own page=/limit= verbatim rather than appending a conflicting pair", async () => { + getRawMock.mockResolvedValue({ data: [{ id: 1 }], meta: { pagination: { total: 1, current: 1, lastPage: 1 } } }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runGet(["raw", "/groups?limit=100&page=2"]); + + expect(getRawMock).toHaveBeenCalledWith("/groups?limit=100&page=2"); + expect(getAllMock).not.toHaveBeenCalled(); + writeSpy.mockRestore(); + }); + + it("rejects a nonsense --page instead of silently probing page 1", async () => { + await expect(runGet(["raw", "/groups", "--page", "0"])).rejects.toThrow(/Invalid --page/); + }); +}); diff --git a/tests/permission-apply.test.ts b/tests/permission-apply.test.ts index c6b6b5c..5021ead 100644 --- a/tests/permission-apply.test.ts +++ b/tests/permission-apply.test.ts @@ -10,7 +10,7 @@ describe("applyPermissionPlan", () => { diff: { toPut: [{ authId: 1104, dataId: [42], type: "grant" }, { authId: 1101, dataId: [], type: "grant" }], toDelete: [{ authId: 2000, dataId: [], type: "grant" }], - preserved: [], + preserved: [], preservedUnknown: [], }, }], { request } as never); expect(res).toEqual({ granted: 2, deleted: 1, failed: [] }); @@ -34,7 +34,7 @@ describe("applyPermissionPlan", () => { { authId: 1101, dataId: [], type: "grant" }, ], toDelete: [{ authId: 2000, dataId: [], type: "grant" }], - preserved: [], + preserved: [], preservedUnknown: [], }, }], { request } as never); expect(res.granted).toBe(1); // only the 1101 PUT succeeded @@ -55,7 +55,7 @@ describe("applyPermissionPlan", () => { diff: { toPut: [{ authId: 1104, dataId: [42], type: "grant" }], toDelete: [], - preserved: [], + preserved: [], preservedUnknown: [], }, }], { request } as never); expect(res.failed).toHaveLength(1); diff --git a/tests/permission-catalog-store.test.ts b/tests/permission-catalog-store.test.ts new file mode 100644 index 0000000..5bd035c --- /dev/null +++ b/tests/permission-catalog-store.test.ts @@ -0,0 +1,145 @@ +/** + * Per-instance permission catalog (#105). + * + * The bundled catalog is a snapshot of ONE ChurchTools version, and the staleness warning it produced + * told consumer repos to run a script that only exists in the ct-cli repo — unactionable where it was + * printed, and printed on every single plan, which is how a warning stops being read. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + capturePermissionCatalog, + hostCatalogPath, + loadHostCatalog, + writeHostCatalog, +} from "../src/permissions/catalog-store.js"; +import { + CATALOG, + CATALOG_IS_PER_INSTANCE, + CATALOG_META, + KNOWN_AUTH_IDS, + KNOWN_SCOPE_FIELDS, + SCOPE_FIELD_BY_AUTH_ID, + resolveAuthId, + useBundledCatalog, +} from "../src/permissions/catalog.js"; + +const HOST = "https://mychurch.church.tools"; +let workDir: string | undefined; + +afterEach(() => { + useBundledCatalog(); // the catalog is process-global — never leak a capture into another test + if (workDir) rmSync(workDir, { recursive: true, force: true }); + workDir = undefined; +}); + +function tempCatalogDir(contents?: unknown): string { + workDir = mkdtempSync(join(tmpdir(), "ct-catalog-")); + if (contents !== undefined) { + writeFileSync(hostCatalogPath(HOST, workDir), JSON.stringify(contents), "utf8"); + } + return workDir; +} + +describe("hostCatalogPath", () => { + it("keeps one file per instance so dev and prod captures coexist", () => { + expect(hostCatalogPath("https://dev.church.tools", ".ct")).toBe(".ct/permission-catalog.dev.church.tools.json"); + expect(hostCatalogPath("https://prod.church.tools/", ".ct")).toBe(".ct/permission-catalog.prod.church.tools.json"); + }); +}); + +describe("loadHostCatalog", () => { + it("returns null and leaves the bundled catalog active when no capture exists", async () => { + const dir = tempCatalogDir(); + expect(await loadHostCatalog(HOST, dir)).toBeNull(); + expect(CATALOG_IS_PER_INSTANCE).toBe(false); + expect(CATALOG["churchcore:administer settings"]).toBeDefined(); + }); + + it("replaces every derived index, not just the name map", async () => { + const dir = tempCatalogDir({ + $meta: { capturedFrom: "mychurch.church.tools", ctVersion: "9.9.9", capturedAt: "2026-08-13", rightCount: 1 }, + "newmodule:new right": { authId: 4242, scopeField: "cdb_zone", revocable: false, desc: "New" }, + }); + + const path = await loadHostCatalog(HOST, dir); + + expect(path).toBe(hostCatalogPath(HOST, dir)); + expect(resolveAuthId("newmodule:new right").authId).toBe(4242); + // A stale index here would be worse than no swap at all: `KNOWN_AUTH_IDS` decides which live + // grants are safe to reconcile, and `SCOPE_FIELD_BY_AUTH_ID` decides what `preserveUnknown` keeps. + expect(KNOWN_AUTH_IDS.has(4242)).toBe(true); + expect(KNOWN_AUTH_IDS.has(1)).toBe(false); // the bundled rights are gone, not merged + expect(SCOPE_FIELD_BY_AUTH_ID.get(4242)).toBe("cdb_zone"); + expect(KNOWN_SCOPE_FIELDS.has("cdb_zone")).toBe(true); + expect(CATALOG_META?.ctVersion).toBe("9.9.9"); + expect(CATALOG_IS_PER_INSTANCE).toBe(true); + }); + + it("throws on a malformed capture instead of silently planning against a different catalog", async () => { + const dir = mkdtempSync(join(tmpdir(), "ct-catalog-")); + workDir = dir; + writeFileSync(hostCatalogPath(HOST, dir), "{ not json", "utf8"); + await expect(loadHostCatalog(HOST, dir)).rejects.toThrow(/not valid JSON/); + expect(CATALOG_IS_PER_INSTANCE).toBe(false); + }); + + it("rejects a top-level array", async () => { + const dir = tempCatalogDir([]); + await expect(loadHostCatalog(HOST, dir)).rejects.toThrow(/expected a JSON object/); + }); +}); + +describe("capturePermissionCatalog", () => { + // Typed through the capture function's own parameter so the fake stays honest about its shape. + const client: Parameters[0] = { + host: HOST, + version: "3.135.2", + legacyPostForm: (async () => ({ + data: { + auth_table: { + churchdb: { + "view station": { id: 124, datenfeld: "cdb_station", bezeichnung: "Standort sehen", isRevocable: 1 }, + "view alldata": { id: 102, datenfeld: "", bezeichnung: "Alle sehen", isRevocable: false }, + }, + }, + }, + })) as (typeof client)["legacyPostForm"], + }; + + it("flattens auth_table into the catalog schema and stamps its provenance", async () => { + const catalog = await capturePermissionCatalog(client); + expect(catalog["churchdb:view station"]).toEqual({ + authId: 124, + scopeField: "cdb_station", + revocable: true, + desc: "Standort sehen", + }); + // An empty `datenfeld` means UNSCOPED — not a dimension named "". + expect(catalog["churchdb:view alldata"]).toMatchObject({ scopeField: null, revocable: false }); + expect(catalog.$meta).toMatchObject({ + capturedFrom: "mychurch.church.tools", + ctVersion: "3.135.2", + rightCount: 2, + }); + }); + + it("fails loudly if the legacy endpoint's shape changed, rather than writing an empty catalog", async () => { + await expect( + capturePermissionCatalog({ + ...client, + legacyPostForm: (async () => ({ data: {} })) as (typeof client)["legacyPostForm"], + }), + ).rejects.toThrow(/no data\.auth_table/); + }); + + it("round-trips through writeHostCatalog into a loadable per-instance catalog", async () => { + const dir = tempCatalogDir(); + const path = await writeHostCatalog(HOST, await capturePermissionCatalog(client), dir); + expect(JSON.parse(readFileSync(path, "utf8"))["churchdb:view station"].authId).toBe(124); + await loadHostCatalog(HOST, dir); + expect(resolveAuthId("churchdb:view station").authId).toBe(124); + }); +}); diff --git a/tests/permission-preserve-unknown.test.ts b/tests/permission-preserve-unknown.test.ts new file mode 100644 index 0000000..6c7b5f2 --- /dev/null +++ b/tests/permission-preserve-unknown.test.ts @@ -0,0 +1,143 @@ +/** + * Opt-in partial grant ownership (#102). + * + * A `ct.groupRole` normally owns its whole role instance: every live grant absent from the + * declaration lands in `toDelete`. On a real instance that costs most of the estate — one + * `cc_html_template` grant makes a 41-grant role undeclarable, because a partial declaration turns a + * clean no-op into a destructive plan. `preserveUnknown` is the deliberate way out; these tests pin + * that it is opt-in, that it is loud, and that the strict default did not move. + */ +import { describe, it, expect } from "vitest"; +import { diffGrants, type GrantTuple } from "../src/permissions/grants.js"; +import { preservePredicateFor } from "../src/permissions/plan.js"; +import { renderPermissionPlan } from "../src/permissions/render.js"; +import { createContext } from "../src/config/context.js"; + +/** `churchcore:use church html templates` — scoped by `cc_html_template`, the real-world blocker. */ +const HTML_TEMPLATE = 17; +/** `churchgroup:view group` — scoped by `cdb_gruppe`, a dimension this tool DOES manage. */ +const VIEW_GROUP = 1104; +/** `churchcore:administer settings` — unscoped. */ +const UNSCOPED = 1; + +const grant = (authId: number, dataId: number[] = []): GrantTuple => ({ authId, dataId, type: "grant" }); + +describe("preserveUnknown — the diff (#102)", () => { + const desired = [grant(VIEW_GROUP, [42])]; + const actual = [grant(VIEW_GROUP, [42]), grant(HTML_TEMPLATE, [3]), grant(VIEW_GROUP, [99])]; + + it("default is unchanged: every undeclared live grant is still a revoke", () => { + const d = diffGrants(desired, actual); + expect(d.toDelete.map((t) => t.authId)).toEqual([HTML_TEMPLATE, VIEW_GROUP]); + expect(d.preservedUnknown).toEqual([]); + }); + + it("preserveUnknown: true keeps every undeclared grant instead of revoking it", () => { + const d = diffGrants(desired, actual, preservePredicateFor(true)); + expect(d.toDelete).toEqual([]); + expect(d.preservedUnknown.map((t) => t.authId)).toEqual([HTML_TEMPLATE, VIEW_GROUP]); + }); + + it("a dimension list keeps ONLY that dimension — a stray grant on a managed one is still drift", () => { + const d = diffGrants(desired, actual, preservePredicateFor(["cc_html_template"])); + // The module grant is left alone… + expect(d.preservedUnknown.map((t) => t.authId)).toEqual([HTML_TEMPLATE]); + // …while the unexpected extra group-scoped grant still shows up as something apply will revoke. + // That is the whole reason to prefer the list form over `true`. + expect(d.toDelete.map((t) => [t.authId, t.dataId])).toEqual([[VIEW_GROUP, [99]]]); + }); + + it("a dimension list never silently widens to unscoped rights", () => { + const d = diffGrants([], [grant(UNSCOPED)], preservePredicateFor(["cc_html_template"])); + expect(d.preservedUnknown).toEqual([]); + expect(d.toDelete.map((t) => t.authId)).toEqual([UNSCOPED]); + }); + + it("preserved grants are never mistaken for a pre-existing deny row", () => { + const deny: GrantTuple = { authId: VIEW_GROUP, dataId: [7], type: "revoke" }; + const d = diffGrants(desired, [...actual, deny], preservePredicateFor(true)); + expect(d.preserved).toEqual([deny]); // denies reconciliation never owned + expect(d.preservedUnknown.every((t) => t.type === "grant")).toBe(true); + }); +}); + +describe("preserveUnknown — the plan is loud about it (#102)", () => { + it("renders a role whose ONLY notable property is preserved grants, and excludes them from the totals", () => { + const rendered = renderPermissionPlan([ + { + key: "team_office_leiter", + domainType: "group_role", + domainId: 44675, + diff: { + toPut: [], + toDelete: [], + preserved: [], + preservedUnknown: [grant(HTML_TEMPLATE, [3]), grant(HTML_TEMPLATE, [4])], + }, + }, + ]); + // Invisible is exactly what this must not be: without the item line, "I forgot one" and "I + // deliberately left the module grants alone" render identically (as nothing). + expect(rendered).toContain("team_office_leiter"); + expect(rendered).toContain("preserved, not managed — preserveUnknown"); + expect(rendered).toContain("0 to grant, 0 to remove, 2 preserved (not managed)"); + }); + + it("still reports a clean no-op when nothing at all was preserved", () => { + const rendered = renderPermissionPlan([ + { + key: "k", + domainType: "group_role", + domainId: 1, + diff: { toPut: [], toDelete: [], preserved: [], preservedUnknown: [] }, + }, + ]); + expect(rendered).toContain("No permission changes"); + }); +}); + +describe("preserveUnknown — config validation (#102)", () => { + const declare = (preserveUnknown: unknown): void => { + const { ct } = createContext(); + ct.groupRole({ + key: "k", + id: 1, + grants: ["churchcore:administer settings"], + preserveUnknown: preserveUnknown as never, + }); + }; + + it("accepts true and a list of real scope dimensions", () => { + expect(() => declare(true)).not.toThrow(); + expect(() => declare(["cc_html_template", "cdb_gruppe"])).not.toThrow(); + }); + + it("rejects a dimension no right is scoped by — a typo must not read as 'nothing to preserve'", () => { + expect(() => declare(["cc_html_templates"])).toThrow(/no right in the permission catalog scopes by/); + }); + + it("rejects an empty list rather than accept a no-op escape hatch", () => { + expect(() => declare([])).toThrow(/preserves nothing/); + }); + + it("rejects a non-string entry", () => { + expect(() => declare([42])).toThrow(/must be true or an array of scope dimension names/); + }); + + it("treats false as the strict default and carries nothing through", () => { + const { ct, permissions } = createContext(); + ct.groupRole({ key: "k", id: 1, grants: [], preserveUnknown: false }); + expect(permissions[0]?.preserveUnknown).toBeUndefined(); + }); + + it("carries an accepted value onto the desired permission, deduped", () => { + const { ct, permissions } = createContext(); + ct.groupRole({ + key: "k", + id: 1, + grants: [], + preserveUnknown: ["cc_html_template", "cc_html_template"], + }); + expect(permissions[0]?.preserveUnknown).toEqual(["cc_html_template"]); + }); +}); diff --git a/tests/plan-detailed-exitcode-command.test.ts b/tests/plan-detailed-exitcode-command.test.ts index a551d4a..cda9753 100644 --- a/tests/plan-detailed-exitcode-command.test.ts +++ b/tests/plan-detailed-exitcode-command.test.ts @@ -110,7 +110,7 @@ describe("ct plan --detailed-exitcode", () => { key: "team", domainType: "group_role", domainId: 1, - diff: { toPut: [{ authId: 5, dataId: [], type: "grant" }], toDelete: [], preserved: [] }, + diff: { toPut: [{ authId: 5, dataId: [], type: "grant" }], toDelete: [], preserved: [], preservedUnknown: [] }, }, ]; await runPlan(["--detailed-exitcode"]); diff --git a/tests/query-refs.test.ts b/tests/query-refs.test.ts index 3c7d4a8..f994fdd 100644 --- a/tests/query-refs.test.ts +++ b/tests/query-refs.test.ts @@ -8,7 +8,13 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; -import { VAR_REF_KINDS, portablizeRuleset, type RoleCatalogEntry } from "../src/config/query-refs.js"; +import { + VAR_REF_KINDS, + formatPortablizeWarnings, + portablizeRuleset, + scanUnportablized, + type RoleCatalogEntry, +} 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"; @@ -65,24 +71,33 @@ describe("portablizeRuleset (#76 Stage 2)", () => { ]); }); - it("leaves an unmanaged id numeric and collects a { var, id } warning", () => { + it("leaves an unmanaged id numeric and collects a warning naming the reason (#101)", () => { const ruleset = { query: churchQuery(q.oneof("ctgroup.id", [148, 999])) }; const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: { group: new Map([[148, "jugend-mainz"]]) }, }); const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; expect(filter.oneof[1]).toEqual([{ __ctRef: true, kind: "group", key: "jugend-mainz" }, 999]); - expect(warnings).toEqual([{ var: "ctgroup.id", id: 999 }]); + expect(warnings).toEqual([ + { + var: "ctgroup.id", + id: 999, + reason: "unmanaged", + detail: "group #999 is not under management — `ct adopt group 999` (then re-adopt) makes it portable", + }, + ]); }); - it("never touches a catalog-less var (groupStatusId) — no rewrite, no warning", () => { + it("never REWRITES a catalog-less var (groupStatusId), but does report it left numeric (#101)", () => { const ruleset = { query: churchQuery(q.oneof("ctgroup.groupStatusId", [1, 2, 4])) }; const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: { group: new Map([[1, "nope"]]) }, }); const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; - expect(filter.oneof[1]).toEqual([1, 2, 4]); - expect(warnings).toEqual([]); + 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"])); }); it("does not mutate its input ruleset", () => { @@ -128,7 +143,33 @@ describe("portablizeRuleset (#76 Stage 2)", () => { { __ctRef: true, kind: "group-type-role", groupType: "local_lead", role: "Leiter" }, 999, // not in roleCatalog → numeric ]); - expect(warnings).toEqual([{ var: "role.id", id: 999 }]); + expect(warnings).toEqual([ + { + var: "role.id", + id: 999, + reason: "role-unknown", + detail: "no /group/roles row carries groupTypeRoleId 999 on this host", + }, + ]); + }); + + it("distinguishes 'role unknown' from 'role's group type unmanaged' (#101)", () => { + const ruleset = { query: churchQuery(q.oneof("role.id", [84])) }; + const { warnings } = portablizeRuleset(ruleset, { + idToKeyByKind: {}, + roleCatalog, // 84 → group type 12 … + groupTypeIdToKey: new Map(), // … which is NOT managed here + }); + expect(warnings).toEqual([ + { + var: "role.id", + id: 84, + reason: "role-group-type-unmanaged", + detail: + 'role "Leiter" belongs to group type #12, which is not managed — ' + + "adopt that group type to make the (group-type, role) pair portable", + }, + ]); }); it("leaves role.id numeric (with a warning) when no roleCatalog is supplied at all", () => { @@ -136,7 +177,14 @@ describe("portablizeRuleset (#76 Stage 2)", () => { const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: {} }); const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; expect(filter.oneof[1]).toEqual([84]); - expect(warnings).toEqual([{ var: "role.id", id: 84 }]); + expect(warnings).toEqual([ + { + var: "role.id", + id: 84, + reason: "role-unknown", + detail: "no /group/roles row carries groupTypeRoleId 84 on this host", + }, + ]); }); }); @@ -176,7 +224,14 @@ describe("portablizeRuleset (#76 Stage 2)", () => { const hm = (out.process as { queryResultOnly: { none: { handleMembership: Record } } }) .queryResultOnly.none.handleMembership; expect(hm.groupTypeRoleId).toBe(4242); - expect(warnings).toEqual([{ var: "groupTypeRoleId", id: 4242 }]); + expect(warnings).toEqual([ + { + var: "groupTypeRoleId", + id: 4242, + reason: "role-unknown", + detail: "no /group/roles row carries groupTypeRoleId 4242 on this host", + }, + ]); }); }); @@ -238,13 +293,29 @@ describe("portablizeRuleset (#76 Stage 2)", () => { expect(back).toEqual(normalized); }); - it("leaves the unmanaged group id (1246) and the groupStatusId lists numeric, warning only for 1246", () => { + it("leaves the unmanaged group id (1246) and the groupStatusId lists numeric, and reports BOTH (#101)", () => { const { ruleset: portable, warnings } = portablizeRuleset(normalized, opts); const json = JSON.stringify(portable); expect(json).toContain("1246"); // unmanaged group id survives numeric - expect(warnings).toEqual([{ var: "ctgroup.id", id: 1246 }]); // exactly the one escape-hatch id - // groupStatusId oneof ([1]) is never a candidate → never rewritten, never warned. - expect(warnings.some((w) => w.id === 1)).toBe(false); + expect(warnings).toContainEqual({ + var: "ctgroup.id", + id: 1246, + reason: "unmanaged", + detail: "group #1246 is not under management — `ct adopt group 1246` (then re-adopt) makes it 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); + }); + + it("scanUnportablized reports the same ids from the ALREADY-PORTABLIZED file (#101 plan-time check)", () => { + const { ruleset: portable } = portablizeRuleset(normalized, opts); + const left = scanUnportablized(portable); + // Every marker-rewritten id is gone from the report; the numeric leftovers remain. + expect(left.some((w) => w.var === "ctgroup.id" && w.id === 1246)).toBe(true); + expect(left.some((w) => w.id === 112 || w.id === 8)).toBe(false); // now `{ __ctRef }` markers + expect(formatPortablizeWarnings(left).some((l) => l.startsWith("ctgroup.id: 1246 left numeric"))).toBe(true); }); }); }); diff --git a/tests/refresh-command.test.ts b/tests/refresh-command.test.ts new file mode 100644 index 0000000..4cf4849 --- /dev/null +++ b/tests/refresh-command.test.ts @@ -0,0 +1,112 @@ +/** + * `ct refresh` (#105). + * + * `ct apply` writes the ruleset and flips the status; ChurchTools materializes membership on its own + * schedule — so a freshly created auto-group is legitimately empty after a green apply. `--refresh` + * only covers groups CHANGED in that run, so it cannot re-evaluate an existing group and does nothing + * on a no-op plan. These tests pin the lever that closes that gap, and the guardrails on it. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { State } from "../src/state/state.js"; + +const requestMock = vi.fn(async (...args: [string, string]) => { + void args; + return [{ created: 3, updated: 1, deleted: 0 }]; +}); +const getAllMock = vi.fn(async () => ({ data: [{ id: 10 }, { id: 12 }] })); + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client: { request: requestMock, getAll: getAllMock }, me: { id: 1 } })), +})); + +const { refreshCommand } = await import("../src/commands/refresh.js"); + +const HOST = "https://mychurch.church.tools"; +const originalHost = process.env.CT_HOST; +let workDir: string; +let statePath: string; + +const state: State = { + version: 1, + host: HOST, + resources: { + auto_a: { type: "group", id: 10, key: "auto_a", fields: {}, adoptedAt: "t", updatedAt: "t" }, + auto_b: { type: "group", id: 12, key: "auto_b", fields: {}, adoptedAt: "t", updatedAt: "t" }, + plain_c: { type: "group", id: 13, key: "plain_c", fields: {}, adoptedAt: "t", updatedAt: "t" }, + a_campus: { type: "campus", id: 0, key: "a_campus", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, +}; + +async function run(args: string[]): Promise { + await refreshCommand().parseAsync([...args, "--state", statePath], { from: "user" }); +} + +beforeEach(() => { + requestMock.mockClear(); + getAllMock.mockClear(); + process.env.CT_HOST = HOST; + workDir = mkdtempSync(join(tmpdir(), "ct-refresh-")); + statePath = join(workDir, "ct-state.json"); + writeFileSync(statePath, JSON.stringify(state), "utf8"); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (originalHost === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = originalHost; + rmSync(workDir, { recursive: true, force: true }); + process.exitCode = 0; +}); + +describe("ct refresh (#105)", () => { + it("refreshes one named group via the PER-GROUP endpoint", async () => { + await run(["--group", "auto_a"]); + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith("POST", "/dynamicgroups/10/refresh"); + }); + + it("never fans out without --all — refreshing recomputes membership", async () => { + await expect(run([])).rejects.toThrow(/Specify --group for one group, or --all/); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it("--all refreshes every MANAGED dynamic group and nothing else", async () => { + await run(["--all"]); + expect(requestMock.mock.calls.map((c) => c[1])).toEqual([ + "/dynamicgroups/10/refresh", + "/dynamicgroups/12/refresh", + ]); + // plain_c is managed but not an auto-group; a_campus is not a group at all. + }); + + it("never touches the all-groups endpoint (blast radius) or the legacy scheduler ping", async () => { + await run(["--all"]); + const paths = requestMock.mock.calls.map((c) => String(c[1])); + expect(paths).not.toContain("/dynamicgroups/refresh"); + expect(paths.some((p) => p.includes("cron"))).toBe(false); + }); + + it("refuses a managed group that has no ruleset, instead of POSTing into a 404", async () => { + await expect(run(["--group", "plain_c"])).rejects.toThrow(/is not a dynamic group on this host/); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it("refuses an unmanaged key — ct only ever refreshes what the config owns", async () => { + await expect(run(["--group", "nope"])).rejects.toThrow(/not a managed group in this state file/); + }); + + it("rejects --group together with --all", async () => { + await expect(run(["--group", "auto_a", "--all"])).rejects.toThrow(/only one of: --group, --all/); + }); + + it("keeps going after one failure and exits non-zero", async () => { + requestMock.mockRejectedValueOnce(new Error("boom")); + await run(["--all"]); + expect(requestMock).toHaveBeenCalledTimes(2); // the second group still ran + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/synthetic-dynamic.test.ts b/tests/synthetic-dynamic.test.ts index 4507e4b..2f1119b 100644 --- a/tests/synthetic-dynamic.test.ts +++ b/tests/synthetic-dynamic.test.ts @@ -266,3 +266,60 @@ describe("dynamic { ref } ruleset — resolved relative to the config file", () .rejects.toThrow(/group "g".*cannot read.*missing\.json/is); }); }); + +describe("dynamic synthetic field — un-portablized ruleset reporting (#101)", () => { + /** + * A ruleset carrying another host's ids round-trips byte-identically against the host it was + * written for, so the plan is green and the auto-group quietly collects the wrong people on the + * other host. Plan time is the only place a config author sees this before it matters. + */ + it("warns at plan time, naming the dimension, the ids and why they stayed numeric", async () => { + const state: State = { version: 1, host: "h", + resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const actual = new Map>([["g", { name: "G" }]]); + const ruleset = { + description: "x", + query: { and: [{ oneof: [{ var: "ctgroup.id" }, [1246]] }] }, + process: {}, + }; + const desired: DesiredResource[] = [ + { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], dynamic: { status: "active", ruleset } }, + ]; + const client = { get: vi.fn(async (p: string) => + p.endsWith("/ruleset") ? ruleset : { dynamicGroupStatus: "active" }) }; + const errs: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { errs.push(String(s)); return true; }); + try { + await dynamicField().fold({ client: getClient(client), state, desired, actual }); + } finally { + spy.mockRestore(); + } + const out = errs.join(""); + expect(out).toContain('dynamic group "g": ruleset carries 1 host-specific id(s)'); + expect(out).toMatch(/ctgroup\.id: 1246 left numeric/); + }); + + it("stays silent for a fully portable ruleset — the warning must mean something", async () => { + const state: State = { version: 1, host: "h", + resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const actual = new Map>([["g", { name: "G" }]]); + const ruleset = { + description: "x", + query: { and: [{ oneof: [{ var: "ctgroup.id" }, [{ __ctRef: true, kind: "group", key: "other" }]] }] }, + process: {}, + }; + const desired: DesiredResource[] = [ + { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], dynamic: { status: "active", ruleset } }, + ]; + const client = { get: vi.fn(async (p: string) => + p.endsWith("/ruleset") ? ruleset : { dynamicGroupStatus: "active" }) }; + const errs: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { errs.push(String(s)); return true; }); + try { + await dynamicField().fold({ client: getClient(client), state, desired, actual }); + } finally { + spy.mockRestore(); + } + expect(errs.join("")).not.toContain("host-specific"); + }); +}); From ab88fb58358ca5e7b6ce7b81fcf08945e75427f0 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 13 Aug 2026 18:02:02 +0200 Subject: [PATCH 2/5] ci: gate on format:check, and format the repo to satisfy it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run format:check` existed but ran nowhere, so it had drifted to failing on 109 files on main — a check that fails by default is not a check. Added it to both CI and release (before lint, so the cheapest gate fails first) and ran `npm run format` across the repo to make it pass. The bulk of the diff is mechanical Prettier reflow with no behaviour change. Docs staleness signatures re-signed, since reformatting touched declared sources. Verified: format:check, lint, typecheck, 681 tests, build, docs staleness — all green. Claude-Session: https://claude.ai/code/session_016TacHkv3oVczwXGHBwckq2 --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 1 + README.md | 42 +- docs/README.md | 22 +- docs/api-coverage.md | 36 +- docs/ci.md | 39 +- docs/configuration.md | 15 +- docs/group-field-decisions.md | 36 +- docs/handbuch/blueprints.md | 16 +- docs/handbuch/dynamic-groups.md | 18 +- docs/handbuch/field-definitions.md | 30 +- docs/handbuch/index.md | 20 +- docs/handbuch/permissions.md | 72 +- docs/runbook-manual-surface.md | 40 +- .../plans/2026-07-07-phase-4-apply-destroy.md | 175 +- .../plans/2026-07-08-auto-groups.md | 359 ++- .../plans/2026-07-08-blueprints.md | 24 +- .../plans/2026-07-08-permissions.md | 325 ++- ...2026-07-07-phase-4-apply-destroy-design.md | 32 +- ...ermissions-autogroups-blueprints-design.md | 62 +- examples/campus-blueprint.config.ts | 6 +- examples/portable.config.ts | 16 +- scripts/regenerate-permission-catalog.ts | 18 +- src/api/ctClient.ts | 7 +- src/auth/tokenStore.ts | 11 +- src/commands/adopt-group.ts | 4 +- src/commands/apply.ts | 37 +- src/commands/coverage.ts | 4 +- src/commands/destroy.ts | 18 +- src/commands/get.ts | 6 +- src/commands/permissions.ts | 10 +- src/commands/plan.ts | 12 +- src/config/context.ts | 15 +- src/config/query-refs.ts | 7 +- src/coverage/report.ts | 8 +- src/engine/dynamic.ts | 27 +- src/engine/execute.ts | 12 +- src/engine/guard.ts | 4 +- src/engine/render.ts | 4 +- src/engine/synthetic.ts | 4 +- src/env/context.ts | 5 +- src/env/envs.ts | 4 +- src/permissions/README.md | 21 +- src/permissions/adopt.ts | 50 +- src/permissions/apply.ts | 4 +- src/permissions/catalog.json | 2262 ++++++++--------- src/permissions/catalog.ts | 11 +- src/permissions/grants.ts | 21 +- src/permissions/plan.ts | 41 +- src/permissions/render.ts | 12 +- src/permissions/scope.ts | 24 +- src/permissions/types.ts | 5 +- src/resolve/refs.ts | 34 +- src/resolve/resolver.ts | 3 +- src/resolve/reverse.ts | 6 +- src/ui/prompt.ts | 4 +- tests/adopt-group-command.test.ts | 12 +- tests/apply-env-command.test.ts | 8 +- tests/apply-refresh.test.ts | 36 +- tests/blueprint.test.ts | 2 +- tests/config.test.ts | 5 +- tests/coverage.test.ts | 19 +- tests/ctClient.test.ts | 10 +- tests/destroy.test.ts | 43 +- tests/dynamic.integration.test.ts | 20 +- tests/dynamic.test.ts | 69 +- tests/envs.test.ts | 15 +- tests/execute.test.ts | 95 +- tests/fixtures/dynamic/README.md | 2 +- .../portablize-sintegrationmeeting.json | 61 +- .../dynamic/portablize-skidscheckinopsmz.json | 49 +- tests/fixtures/dynamic/ruleset-1092.get.json | 47 +- tests/fixtures/dynamic/ruleset-2022.get.json | 29 +- tests/fixtures/dynamic/ruleset-683.get.json | 46 +- tests/fixtures/dynamic/ruleset.get.json | 46 +- tests/get-command.test.ts | 12 +- tests/integration.test.ts | 4 +- tests/permission-adopt.test.ts | 145 +- tests/permission-apply.test.ts | 97 +- tests/permission-bootstrap.test.ts | 39 +- tests/permission-catalog-store.test.ts | 22 +- tests/permission-catalog.test.ts | 4 +- tests/permission-grants.test.ts | 10 +- tests/permission-pending-domain.test.ts | 65 +- tests/permission-plan.test.ts | 437 +++- tests/permission-scope-refs.test.ts | 184 +- tests/permission-scope.test.ts | 20 +- tests/permission.integration.test.ts | 7 +- tests/person-status-resource.test.ts | 57 +- tests/plan-detailed-exitcode-command.test.ts | 7 +- tests/plan.test.ts | 28 +- tests/query-refs.test.ts | 65 +- tests/query.test.ts | 20 +- tests/refs.test.ts | 7 +- tests/registry.test.ts | 6 +- tests/resolver.test.ts | 79 +- tests/session.test.ts | 4 +- tests/synthetic-dynamic.test.ts | 400 ++- tests/tokenStore-multihost.test.ts | 38 +- 99 files changed, 4032 insertions(+), 2441 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43f28bf..5658b97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm run format:check - run: npm run lint - run: npm run typecheck - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff248db..71b4456 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,7 @@ jobs: cache: npm - run: npm ci + - run: npm run format:check - run: npm run lint - run: npm run typecheck - run: npm test diff --git a/README.md b/README.md index 801fe3f..e8c2387 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ code, and reconcile it against the ChurchTools API with Terraform-style A ChurchTools instance's structure is normally maintained by clicking. That works — until you need to answer questions clicking cannot: -| Clicking | With `ct` | -| --- | --- | -| "Who changed this group's rights, and why?" | `git log`, and the PR that changed it | -| "Set up the next campus like the last one" | Call the same blueprint function again | -| "Try it somewhere safe first" | `ct plan --env dev` → `ct apply --env dev` → promote | -| "Has anyone edited this by hand?" | `ct plan` reports drift against the last known state | -| "What will this actually change?" | `ct plan` prints it before anything is written | +| Clicking | With `ct` | +| ------------------------------------------- | ---------------------------------------------------- | +| "Who changed this group's rights, and why?" | `git log`, and the PR that changed it | +| "Set up the next campus like the last one" | Call the same blueprint function again | +| "Try it somewhere safe first" | `ct plan --env dev` → `ct apply --env dev` → promote | +| "Has anyone edited this by hand?" | `ct plan` reports drift against the last known state | +| "What will this actually change?" | `ct plan` prints it before anything is written | ## Show me @@ -118,17 +118,17 @@ confirmation — and a declaration marked `preventDestroy: true` blocks even tha ## What it manages -| Resource | DSL | Guide | -| --- | --- | --- | -| Campuses | `ct.campus` | [config guide](docs/configuration.md) | -| Groups (fields, campus, multi-parent hierarchy) | `ct.group` | [config guide](docs/configuration.md) | -| Group types, roles, age/target groups, relationship types, person statuses | `ct.groupType`, `ct.roleDefinition`, `ct.ageGroup`, `ct.targetGroup`, `ct.relationshipType`, `ct.personStatus` | reusable building blocks | -| Permissions (group-role, group-type-role, person-status) | `ct.groupRole`, `ct.groupTypeRole`, `ct.status` | [permissions](docs/handbuch/permissions.md) | -| Auto-groups (dynamic groups) | the `dynamic` block on a group | [dynamic groups](docs/handbuch/dynamic-groups.md) | -| Repeated structure, parametrized | a plain function over the DSL | [blueprints](docs/handbuch/blueprints.md) | +| Resource | DSL | Guide | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Campuses | `ct.campus` | [config guide](docs/configuration.md) | +| Groups (fields, campus, multi-parent hierarchy) | `ct.group` | [config guide](docs/configuration.md) | +| Group types, roles, age/target groups, relationship types, person statuses | `ct.groupType`, `ct.roleDefinition`, `ct.ageGroup`, `ct.targetGroup`, `ct.relationshipType`, `ct.personStatus` | reusable building blocks | +| Permissions (group-role, group-type-role, person-status) | `ct.groupRole`, `ct.groupTypeRole`, `ct.status` | [permissions](docs/handbuch/permissions.md) | +| Auto-groups (dynamic groups) | the `dynamic` block on a group | [dynamic groups](docs/handbuch/dynamic-groups.md) | +| Repeated structure, parametrized | a plain function over the DSL | [blueprints](docs/handbuch/blueprints.md) | Read-only by design: the person master-data model, security levels and -custom-field *definitions* (`ct get person-masterdata`, `ct get data-fields`) — +custom-field _definitions_ (`ct get person-masterdata`, `ct get data-fields`) — schema in scope, per-record values never. See [field definitions](docs/handbuch/field-definitions.md). @@ -139,7 +139,7 @@ in place, `ct` says so rather than letting a host-specific id travel silently to another instance. `ct coverage` answers the other direction: what exists on the instance that the -config does not manage, and which of it could be declared today (per group *and* +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. ## Environments and CI @@ -174,10 +174,10 @@ ct apply --env prod # protected env: type the env name to confirm ## Two-repo model -| Repo | Contents | -| --- | --- | -| **`eqrm/ct-cli`** (this repo) | The tool: CLI, API client, plan/apply engine. Generic, reusable. | -| *your config repo* (private) | Your instance's desired-state config + state files. Depends on this tool. | +| Repo | Contents | +| ----------------------------- | ------------------------------------------------------------------------- | +| **`eqrm/ct-cli`** (this repo) | The tool: CLI, API client, plan/apply engine. Generic, reusable. | +| _your config repo_ (private) | Your instance's desired-state config + state files. Depends on this tool. | Like Terraform, the tool never lives in the same repo as the infra config — the config repo holds an organisation's actual structure and stays private. This repo is the tool diff --git a/docs/README.md b/docs/README.md index 92d1566..0609890 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,13 +9,13 @@ dynamic groups and field definitions behave, independent of any one instance. These pages are published into a wider ChurchTools Handbuch, so they carry `sources:` frontmatter and are gated by the staleness checker (#89). -| Page | About | -| --- | --- | -| [`handbuch/index.md`](handbuch/index.md) | Section overview | -| [`handbuch/permissions.md`](handbuch/permissions.md) | Grant domains, `domainId` semantics, scope resolution | -| [`handbuch/dynamic-groups.md`](handbuch/dynamic-groups.md) | Auto-groups, the ChurchQuery DSL, portable rulesets | +| Page | About | +| ---------------------------------------------------------------- | ------------------------------------------------------------- | +| [`handbuch/index.md`](handbuch/index.md) | Section overview | +| [`handbuch/permissions.md`](handbuch/permissions.md) | Grant domains, `domainId` semantics, scope resolution | +| [`handbuch/dynamic-groups.md`](handbuch/dynamic-groups.md) | Auto-groups, the ChurchQuery DSL, portable rulesets | | [`handbuch/field-definitions.md`](handbuch/field-definitions.md) | Person master data, security levels, custom-field definitions | -| [`handbuch/blueprints.md`](handbuch/blueprints.md) | Parametrized, reusable structure | +| [`handbuch/blueprints.md`](handbuch/blueprints.md) | Parametrized, reusable structure | **A page publishes only if it lives under `handbuch/` and is reachable from `handbuch/mkdocs.yml`'s nav.** Everything else in `docs/` stays invisible. @@ -48,9 +48,9 @@ A page with no code behaviour to track declares `sources: []` plus a ## Everything else — developer- and operator-facing, unpublished -| Page | About | -| --- | --- | -| [`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 | +| Page | About | +| -------------------------------------------------------- | --------------------------------------------------------------------- | +| [`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 — API gaps and the manual steps around them | -| `superpowers/` | Historical implementation plans; kept as a record, never published | +| `superpowers/` | Historical implementation plans; kept as a record, never published | diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 1da5b59..8d98495 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -15,22 +15,22 @@ Analysis of the ChurchTools OpenAPI spec (`openapi.json`, OpenAPI 3.1.0, 487 pat Methods marked only if they actually exist on the matched path. "Update" = PUT or PATCH (noted). Collection paths (list/create) vs item paths (`/{id}`) are separated. -| # | Resource | Matched path(s) | GET (list / by-id) | POST (create) | PUT/PATCH (update) | DELETE | Verdict | -| --- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------- | ----------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `ct_campus` | `/campuses`, `/campuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | -| 2 | `ct_group_type` | `/group/grouptypes`, `/group/grouptypes/{groupTypeId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | -| 3 | `ct_group` | `/groups`, `/groups/{groupId}` | list ✅ / by-id ✅ | ✅ | ✅ PATCH | ✅ | **Full CRUD** | -| 4 | `ct_group_hierarchy` | `/groups/hierarchies` (GET), `/groups/{groupId}/children` (GET), `/groups/{groupId}/parents` (GET), `/groups/{groupId}/parents/{parentGroupId}` (PUT/DELETE) | list ✅ (hierarchies/children/parents) | — (no collection POST) | ✅ PUT links a parent | ✅ unlinks a parent | **Writable** — parent/child edges created & removed via PUT/DELETE on the item path (no POST needed) | -| 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. | -| 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** | +| # | Resource | Matched path(s) | GET (list / by-id) | POST (create) | PUT/PATCH (update) | DELETE | Verdict | +| --- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------- | ----------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `ct_campus` | `/campuses`, `/campuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | +| 2 | `ct_group_type` | `/group/grouptypes`, `/group/grouptypes/{groupTypeId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | +| 3 | `ct_group` | `/groups`, `/groups/{groupId}` | list ✅ / by-id ✅ | ✅ | ✅ PATCH | ✅ | **Full CRUD** | +| 4 | `ct_group_hierarchy` | `/groups/hierarchies` (GET), `/groups/{groupId}/children` (GET), `/groups/{groupId}/parents` (GET), `/groups/{groupId}/parents/{parentGroupId}` (PUT/DELETE) | list ✅ (hierarchies/children/parents) | — (no collection POST) | ✅ PUT links a parent | ✅ unlinks a parent | **Writable** — parent/child edges created & removed via PUT/DELETE on the item path (no POST needed) | +| 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. | +| 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. | -| 14 | `ct_department` | `/departments` | list ✅ / by-id ❌ | ❌ | ❌ | ❌ | **Read-only — a ref catalog, 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 they can never be declared, adopted or created. | +| 14 | `ct_department` | `/departments` | list ✅ / by-id ❌ | ❌ | ❌ | ❌ | **Read-only — a ref catalog, 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 they can never be declared, adopted or created. | > **Create-time required fields (#73).** The "POST ✅" marks above were spec-derived, not live-exercised for > create. CT's POST validators require fields the tool does not manage for diffing: `group-type` needs @@ -85,9 +85,9 @@ API-client libraries (5pm-HDH `churchtools-api` @ CT 3.104, bensteUEM `src/api/schema.d.ts` is git-ignored and was not available offline. Re-verify per the runbook's re-audit procedure once the schema is regenerated. -| Resource | Matched path(s) | GET | POST/PUT/PATCH/DELETE | Verdict | -| --------------------------------- | ------------------------------ | ----------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Person master-data model | `/person/masterdata` | ✅ (single object) | ❌ | **Read-only.** Versionable master-data model incl. the `securityLevels` enumeration. No write endpoint (edited in the CT master-data admin UI). | +| Resource | Matched path(s) | GET | POST/PUT/PATCH/DELETE | Verdict | +| ------------------------------------ | ----------------------------- | ------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Person master-data model | `/person/masterdata` | ✅ (single object) | ❌ | **Read-only.** Versionable master-data model incl. the `securityLevels` enumeration. No write endpoint (edited in the CT master-data admin UI). | | Data-field definitions (Datenfelder) | `/dbfields`, `/dbfields/{id}` | list ✅ / by-id ✅ | ❌ | **Read-only.** Unified person + group field definitions, discriminated by `fieldCategory`. Mutation only via legacy churchdb AJAX (`db_insert/update/deletefields`), not REST. | See [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md) for the full writability diff --git a/docs/ci.md b/docs/ci.md index d6af451..7f0583e 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -36,11 +36,11 @@ alone do not bypass that guardrail. Terraform-style. With the flag, `ct plan` exits: -| Exit code | Meaning | -|---|---| -| `0` | no changes — desired state already matches ChurchTools (resources AND permissions) | -| `1` | error — the plan is INCOMPLETE (a resource or permission fetch failed), or the command failed outright | -| `2` | changes are pending — at least one resource item is not a no-op, OR at least one permission item has a grant/revoke to apply | +| Exit code | Meaning | +| --------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `0` | no changes — desired state already matches ChurchTools (resources AND permissions) | +| `1` | error — the plan is INCOMPLETE (a resource or permission fetch failed), or the command failed outright | +| `2` | changes are pending — at least one resource item is not a no-op, OR at least one permission item has a grant/revoke to apply | Without the flag, behaviour is byte-identical to before: `ct plan` exits `1` only on an INCOMPLETE plan/error, `0` otherwise — so existing scripts that just @@ -56,7 +56,7 @@ esac ``` **Drift alone never sets exit `2`.** `--detailed-exitcode` mirrors what `ct -apply` would actually *do* — an item can carry drift (ChurchTools changed +apply` would actually _do_ — an item can carry drift (ChurchTools changed since the last apply) while its `action` stays `no-op` (the drifted field isn't managed by the current config, or happens to already match it), and `apply` would write nothing for it. Drift is always visible in the human @@ -81,23 +81,24 @@ Shape: "plan": { "items": [ { - "type": "group", "key": "kids", "id": 7, "action": "update", - "changes": [ - { "field": "name", "from": "Kid's", "to": "Kids", "source": "config" } - ], - "drift": [ - { "field": "campusId", "from": 4, "to": 9 } - ] - } - ] + "type": "group", + "key": "kids", + "id": 7, + "action": "update", + "changes": [{ "field": "name", "from": "Kid's", "to": "Kids", "source": "config" }], + "drift": [{ "field": "campusId", "from": 4, "to": 9 }], + }, + ], }, - "permissions": [ /* PermissionPlanItem[]: { key, domainType, domainId, pendingDomain?, diff: { toPut, toDelete, preserved } } */ ], + "permissions": [ + /* PermissionPlanItem[]: { key, domainType, domainId, pendingDomain?, diff: { toPut, toDelete, preserved } } */ + ], "summary": { "resources": { "create": 0, "update": 1, "delete": 0, "no-op": 3 }, "drifted": 1, "permissions": { "toPut": 0, "toDelete": 0, "preserved": 0 }, - "hasChanges": true - } + "hasChanges": true, + }, } ``` @@ -124,7 +125,7 @@ Per resource item: manages that field — a **superset** of what `changes[].source` narrows down to only the fields `apply` will actually touch. - **Permission items carry no `source`.** The state file snapshots managed - *resource* fields only, not granted permissions, so there is no + _resource_ fields only, not granted permissions, so there is no last-known baseline to attribute a permission diff to config-vs-drift. `diff.toPut`/`diff.toDelete` is honestly just desired-vs-actual — this is the one place the tool cannot make the distinction, so it doesn't diff --git a/docs/configuration.md b/docs/configuration.md index 970ba13..0358b0e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,11 +12,22 @@ export default (ct) => { // Hierarchy is opt-in and multi-parent: `parents` are managed group keys, each declared // in this config. Omit it to leave a group's hierarchy unmanaged; edges to unmanaged // groups stay invisible. (`parent:` is unrelated — an ordering hint only, not hierarchy.) - ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupType: "ministry_team", parents: ["mainz_area"] }); + ct.group({ + key: "mainz_kids_lead", + name: "Mainz · Kids Leitung", + groupType: "ministry_team", + parents: ["mainz_area"], + }); // Assign a group to a campus BY KEY: `campus: "mainz"` links to the campus above even though // it is created in the same apply (its id is filled in at apply time). The numeric escape // hatch still works — `campusId: 3` (or `campusId: null` to clear) targets an existing id. - ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupType: "ministry_team", campus: "mainz", parents: ["mainz_kids_lead"] }); + ct.group({ + key: "mainz_kids", + name: "Mainz · Kids", + groupType: "ministry_team", + campus: "mainz", + parents: ["mainz_kids_lead"], + }); }; ``` diff --git a/docs/group-field-decisions.md b/docs/group-field-decisions.md index 9a1ffa8..0a3b63f 100644 --- a/docs/group-field-decisions.md +++ b/docs/group-field-decisions.md @@ -1,12 +1,12 @@ # Group field decisions (#21) -`ct-cli` manages the rights-bearing *structure* of a group, not everything the +`ct-cli` manages the rights-bearing _structure_ of a group, not everything the ChurchTools group object carries. This table records the deliberate decision for each field seen on the live group / group `PATCH` body: **managed** (diffed and applied like `name`), **opt-in synthetic** (its own DSL block + endpoint, not the plain field bag), or **out of scope** (left to the CT admin UI, never touched). -Adding a *managed* field is a real commitment — it needs a registry entry, a diff +Adding a _managed_ field is a real commitment — it needs a registry entry, a diff test, an adopt round-trip, and a note on state-snapshot migration. So this issue promotes exactly one new field (`campusId`) and triages the rest rather than silently widening `managedFields`. @@ -23,20 +23,20 @@ field — a plain top-level key. `campusId` is wired the same deliberate way as ## Decision table -| Field | Decision | Rationale | -|---|---|---| -| `name` | **managed** | Core identity; already managed. | -| `groupTypeId` | **managed** | Determines the group's kind and role template; already managed. | -| `groupStatusId` | **managed** | Lifecycle status; already managed. | -| **`campusId`** | **managed (new, #21)** | The campus link is the tool's core "instantiate this area per campus" requirement. Numeric escape hatch only — an existing CT campus id (or `null` to clear). A *logical* `campus: "key"` reference (resolving a same-run campus by key) is **deferred to [#20](https://github.com/eqrm/ct-cli/issues/20)**; the DSL rejects a `campus` field with a pointer to #20 so it can't slip through as an un-diffable phantom. | -| `parents` (hierarchy) | **opt-in synthetic** | Group→group hierarchy is reconciled through its own endpoint, not the group body — see `src/engine/synthetic.ts`. Opt-in via `parents: [...]`. | -| `dynamic` (auto-group ruleset) | **opt-in synthetic** | Ruleset + status live behind a dedicated endpoint (#14); opt-in via the `dynamic` block. See `docs/handbuch/dynamic-groups.md`. | +| Field | Decision | Rationale | +| ---------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | **managed** | Core identity; already managed. | +| `groupTypeId` | **managed** | Determines the group's kind and role template; already managed. | +| `groupStatusId` | **managed** | Lifecycle status; already managed. | +| **`campusId`** | **managed (new, #21)** | The campus link is the tool's core "instantiate this area per campus" requirement. Numeric escape hatch only — an existing CT campus id (or `null` to clear). A _logical_ `campus: "key"` reference (resolving a same-run campus by key) is **deferred to [#20](https://github.com/eqrm/ct-cli/issues/20)**; the DSL rejects a `campus` field with a pointer to #20 so it can't slip through as an un-diffable phantom. | +| `parents` (hierarchy) | **opt-in synthetic** | Group→group hierarchy is reconciled through its own endpoint, not the group body — see `src/engine/synthetic.ts`. Opt-in via `parents: [...]`. | +| `dynamic` (auto-group ruleset) | **opt-in synthetic** | Ruleset + status live behind a dedicated endpoint (#14); opt-in via the `dynamic` block. See `docs/handbuch/dynamic-groups.md`. | | `allowDuplicateName` (CT's `force` create flag, #75) | **create-only, unmanaged** | Not a field on the live group at all — it's a request-only escape hatch for `POST /groups`' same-name guard (`forbidden.duplicate.group`). Opt-in per declaration; sent as `force: true` on CREATE only. Deliberately NOT a registry `managedFields` entry: it has no live value to diff against, so it is destructured out of the DSL input before the field bag (never triggers the unknown-field warning, never lands in state, never adopted, never touched on update). | -| `visibility` | **out of scope** | Not rights-bearing structure; instance-/policy-specific and easily changed in the UI. No demand in #21 to manage it. Promote later only with its own registry entry + tests. | -| `note` | **out of scope** | Free-text annotation, not structure. Managing it would fight human edits in the UI for no structural benefit. | -| `autoAccept` / open-for-members settings | **out of scope** | Membership-request policy — adjacent to *who is in a group*, which the tool never manages (`assertNotPeople`, `src/engine/guard.ts`). Left to the UI. | -| chat status | **out of scope** | Chat/messaging toggle, outside the structural mandate (README: "campuses, structural groups, hierarchies, group types/roles, permission & auto-groups"). | -| sort key | **out of scope** | Presentation ordering, not structure. (Note: `sortKey` *is* managed on the master-data types `age-group`/`target-group`, where ordering is the resource's point; on a group it is cosmetic.) | +| `visibility` | **out of scope** | Not rights-bearing structure; instance-/policy-specific and easily changed in the UI. No demand in #21 to manage it. Promote later only with its own registry entry + tests. | +| `note` | **out of scope** | Free-text annotation, not structure. Managing it would fight human edits in the UI for no structural benefit. | +| `autoAccept` / open-for-members settings | **out of scope** | Membership-request policy — adjacent to _who is in a group_, which the tool never manages (`assertNotPeople`, `src/engine/guard.ts`). Left to the UI. | +| chat status | **out of scope** | Chat/messaging toggle, outside the structural mandate (README: "campuses, structural groups, hierarchies, group types/roles, permission & auto-groups"). | +| sort key | **out of scope** | Presentation ordering, not structure. (Note: `sortKey` _is_ managed on the master-data types `age-group`/`target-group`, where ordering is the resource's point; on a group it is cosmetic.) | ## State-snapshot migration @@ -46,15 +46,15 @@ and needs no migration**, because: - the diff is **desired-driven** — `diffFields` only walks the config's fields, so a config that doesn't declare `campusId` never invents a change for it; -- drift is **snapshot-driven** — `driftFields` only walks the *old* snapshot's +- drift is **snapshot-driven** — `driftFields` only walks the _old_ snapshot's keys, so a key absent there is never surfaced; - the write body comes from the **fetched actual** ([#27](https://github.com/eqrm/ct-cli/issues/27)), so an unrelated field update never omits or reverts `campusId`, and the post-write snapshot self-heals to include it. -Contrast the `shortName → shorty` case (#17): a *renamed* key lingers in the old +Contrast the `shortName → shorty` case (#17): a _renamed_ key lingers in the old snapshot and drifts forever, so it needs an explicit `migrateState` rename. An -*added* key cannot. See the comment on `migrateState` in `src/state/state.ts`. +_added_ key cannot. See the comment on `migrateState` in `src/state/state.ts`. **Re-adopt guidance:** to capture the campus of an already-managed group into state immediately (rather than waiting for the next apply to self-heal the diff --git a/docs/handbuch/blueprints.md b/docs/handbuch/blueprints.md index 3e9050b..8e6227e 100644 --- a/docs/handbuch/blueprints.md +++ b/docs/handbuch/blueprints.md @@ -4,7 +4,7 @@ sources: - src/config/context.ts - src/engine/graph.ts - src/engine/hierarchy.ts -sources_hash: f6c5a275456c1de4 +sources_hash: 0124644b70452450 reviewed: 2026-08-13 --- @@ -48,7 +48,7 @@ language, no generated files, just a function called twice. Link a group to a campus **by key** — `campus: "mainz"` (or, when the campus key is a loop variable, `campus`) — and the per-host resolver fills in the id (#20). -When the blueprint *creates* the campus in the same apply, its id is unknown at +When the blueprint _creates_ the campus in the same apply, its id is unknown at eval time, so the resolver marks the link **pending** and writes the freshly-created id at apply time (tier ordering creates the campus first). `ct plan` renders it as `campusId = `. @@ -92,7 +92,7 @@ export default (ct: ConfigContext): void => { `${campus}_kids_lead`, `${campus}_kids_0_3`, `${campus}_kids_checkin`, and so on give every campus's copy of the structure its own non-colliding key -namespace, while the *shape* (lead group + N ministry teams) stays defined +namespace, while the _shape_ (lead group + N ministry teams) stays defined once in `kidsArea`. Add a third campus to `CAMPUSES` and the same function produces a third, fully independent structure — no changes to `kidsArea` itself. @@ -112,7 +112,11 @@ that call), never at Mainz's tree: function kidsArea(ct: ConfigContext, campus: string): void { const lead = `${campus}_kids_lead`; ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupTypeId: 2, parents: [] }); - for (const [suffix, label] of [["0_3", "0–3"], ["4_6", "4–6"], ["checkin", "Check-in"]] as const) { + for (const [suffix, label] of [ + ["0_3", "0–3"], + ["4_6", "4–6"], + ["checkin", "Check-in"], + ] as const) { ct.group({ key: `${campus}_kids_${suffix}`, name: `${campus} · Kids ${label}`, @@ -198,7 +202,7 @@ A blueprint doesn't need to worry about sequencing: `${campus}_kids_0_3`/`_4_6`/`_checkin`/`_all` — the topological sort guarantees it structurally, not by declaration order. - **Hierarchy and auto-group state ride along with their group.** `parents` - and `dynamic` are *synthetic fields* on the `group` resource itself (see + and `dynamic` are _synthetic fields_ on the `group` resource itself (see `SYNTHETIC_FIELDS` in [`src/engine/synthetic.ts`](https://github.com/eqrm/ct-cli/blob/main/src/engine/synthetic.ts)), not separate resources with their own tier — they're diffed and applied as part of that same group's create/update, once the group (and, for @@ -224,7 +228,7 @@ beyond the `parents: [lead]` you'd write anyway. 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 +`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: diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index 7347089..dd2835e 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,7 +6,7 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/commands/adopt-group.ts -sources_hash: e78e72e535b895ae +sources_hash: a1fd81d6d76bba76 reviewed: 2026-08-10 --- @@ -120,7 +120,7 @@ no-op — it does not re-`PUT` on every apply). Two equivalent ways to author it Simple marker `kind`s carry a single `key` (the logical key / slug): `campus`, `group`, `group-type`. A **role** (`role.id`) uses the compound `group-type-role` marker instead — `{ "__ctRef": true, "kind": - "group-type-role", "groupType": "", "role": "" }` — +"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`. @@ -147,13 +147,13 @@ 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) | +| 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) | The same `group-type-role` rewrite also covers the **out-of-query** integer field `process.*.handleMembership.groupTypeRoleId` (the target role a diff --git a/docs/handbuch/field-definitions.md b/docs/handbuch/field-definitions.md index 057f431..0c4ade2 100644 --- a/docs/handbuch/field-definitions.md +++ b/docs/handbuch/field-definitions.md @@ -3,30 +3,30 @@ title: Field definitions & security levels sources: - src/commands/get.ts - src/api/ctClient.ts -sources_hash: 50d1537fe9c47a10 +sources_hash: e22ccc069dc6b95a reviewed: 2026-08-13 --- # Field definitions & security levels (#47, #48) -`ct` can **read** the structural *schema* that defines what data a person or a +`ct` can **read** the structural _schema_ that defines what data a person or a group carries: the person master-data model, the security-level enumeration, and the data-field definitions ("Datenfelder") for both persons and groups. ## Hard boundary — schema in scope, people never -| In scope (schema / DEFINITIONS) | **Never** (people / records) | -| --------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| What fields a person/group *has* (field definitions, types, field groups) | The **value** of any field on an actual person or group record | -| The security-level model (levels + what visibility they gate) | Which persons exist, their memberships, their master-data values | -| The person master-data model (sexes, titles, statuses, campuses, …) | Assigning/reading a person's status, sex, campus, etc. | +| In scope (schema / DEFINITIONS) | **Never** (people / records) | +| ------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| What fields a person/group _has_ (field definitions, types, field groups) | The **value** of any field on an actual person or group record | +| The security-level model (levels + what visibility they gate) | Which persons exist, their memberships, their master-data values | +| The person master-data model (sexes, titles, statuses, campuses, …) | Assigning/reading a person's status, sex, campus, etc. | > **Exception — person statuses are managed, not just read (#96).** The status -> *enumeration* ("0 - First", "3 - Group Active", …) is a declarable resource: +> _enumeration_ ("0 - First", "3 - Group Active", …) is a declarable resource: > `ct.personStatus({ key, name, shorty })`, adoptable with > `ct adopt person-status ` and listable with `ct get statuses` > (`/statuses`, full CRUD). That is what lets a config declare permission grants -> on a status (`ct.status`) and still stand up on a fresh host. Which *person* +> on a status (`ct.status`) and still stand up on a fresh host. Which _person_ > carries which status remains permanently out of scope, like every other > per-record value. Campuses are likewise managed (`ct.campus`); the rest of the > master-data model — sexes, titles, the security-level enumeration — stays @@ -34,7 +34,7 @@ the data-field definitions ("Datenfelder") for both persons and groups. This mirrors the tool's permanent people boundary (README "People are never managed"; `assertNotPeople` in `src/engine/guard.ts`). The commands below read -*definitions only*; none of them read or write a per-record field value. +_definitions only_; none of them read or write a per-record field value. ## Read commands @@ -102,11 +102,11 @@ the row out of the read-only section here and in the runbook. ## Endpoint reference -| Purpose | Path | Methods (this CT) | `ct` surface | -| ---------------------------------------- | ----------------------- | ------------------------ | --------------------------- | -| Person master-data model + security levels | `/person/masterdata` | GET (read-only) | `ct get person-masterdata` | -| Data-field definitions (person + group) | `/dbfields`, `/dbfields/{id}` | GET (read-only) | `ct get data-fields` | -| Field-definition **mutation** | legacy `churchdb` AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) | non-REST | **not managed — manual** | +| Purpose | Path | Methods (this CT) | `ct` surface | +| ------------------------------------------ | ------------------------------------------------------------------------------ | ----------------- | -------------------------- | +| Person master-data model + security levels | `/person/masterdata` | GET (read-only) | `ct get person-masterdata` | +| Data-field definitions (person + group) | `/dbfields`, `/dbfields/{id}` | GET (read-only) | `ct get data-fields` | +| Field-definition **mutation** | legacy `churchdb` AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) | non-REST | **not managed — manual** | All paths verified against public CT client libraries + CT Academy docs, **not** against this repo's (git-ignored, ungenerated) `src/api/schema.d.ts`. diff --git a/docs/handbuch/index.md b/docs/handbuch/index.md index 5a1c46c..4b894d3 100644 --- a/docs/handbuch/index.md +++ b/docs/handbuch/index.md @@ -20,11 +20,11 @@ each page marks explicitly what was **verified** and what is still an ## Where each kind of documentation lives -| Layer | Repo | Example | -| --- | --- | --- | -| **Generic ChurchTools behaviour** | `ct-cli` `docs/handbuch/` (these pages) | "A `group_type_role` grant reaches every group of that type" | -| **One instance's structure** | that organisation's private config repo, `docs/` | "Which Bereiche exist here and why" | -| **Tool-specific how-tos** | the relevant tool's own Handbuch | "How to run check-in" | +| Layer | Repo | Example | +| --------------------------------- | ------------------------------------------------ | ------------------------------------------------------------ | +| **Generic ChurchTools behaviour** | `ct-cli` `docs/handbuch/` (these pages) | "A `group_type_role` grant reaches every group of that type" | +| **One instance's structure** | that organisation's private config repo, `docs/` | "Which Bereiche exist here and why" | +| **Tool-specific how-tos** | the relevant tool's own Handbuch | "How to run check-in" | ## The pages @@ -36,13 +36,13 @@ each page marks explicitly what was **verified** and what is still an ruleset/status model, the typed ChurchQuery DSL, how membership recompute is triggered, and how a captured ruleset is made portable across instances. - **[Field definitions](field-definitions.md)** — the person master-data model, - security levels, and person/group custom-field *definitions*: what is readable + security levels, and person/group custom-field _definitions_: what is readable over REST, what is writable, and where the boundary to per-record values runs. - **[Blueprints](blueprints.md)** — describing a repeated structure (e.g. one campus's area scaffold) once and instantiating it per campus. !!! note "Language" - These pages are English while the rest of the Handbuch is German. That is a - known, deliberate mismatch — they are placed here first and translated (or - relocated) by the follow-on content work; see - [ct-cli#89](https://github.com/eqrm/ct-cli/issues/89). +These pages are English while the rest of the Handbuch is German. That is a +known, deliberate mismatch — they are placed here first and translated (or +relocated) by the follow-on content work; see +[ct-cli#89](https://github.com/eqrm/ct-cli/issues/89). diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 866048c..c0366d8 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -5,7 +5,7 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: 755e1575996e49dc +sources_hash: 2fe2c8ac3f4e7c00 reviewed: 2026-08-13 --- @@ -20,25 +20,25 @@ person-status rights — as code, and reconcile them idempotently with the same ```ts 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) + key: "leiter_tpl", // logical key (unique across the whole config) + groupType: "ministry_team", // domain BY NAME — resolved to the domainId per host (#20) grants: [ - "churchgroup:view group", // unscoped - { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped + "churchgroup:view group", // unscoped + { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped ], }); ct.groupRole({ key: "kids_lead_grant", - group: "kids_area", // domain BY (group, role) — resolved to the pairing domainId per host (#25) - role: "Leiter", // (or keep the numeric escape hatch: `id: 2882`) + group: "kids_area", // domain BY (group, role) — resolved to the pairing domainId per host (#25) + role: "Leiter", // (or keep the numeric escape hatch: `id: 2882`) // "edit group memberships of group" is a scoped right, so it takes a `scope: [...]`. grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }], }); ct.status({ key: "core_external_login", - personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90) + personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90) // -1 is ChurchTools' "all values of this dimension" sentinel (here: every external system). grants: [{ right: "churchcore:login to external system", scope: [-1] }], }); @@ -91,7 +91,7 @@ Each line shows the name, its numeric `authId`, and whether it's `scoped` **plan/apply time** — inside `desiredTuples` (`src/permissions/plan.ts`), when grants are resolved to tuples against the catalog, after the authed fetch — with a "did you mean" hint drawn from same-module names. (Config -evaluation only checks a grant's *shape*: `module:right` string or +evaluation only checks a grant's _shape_: `module:right` string or `{ right, scope }`; it does not resolve the name against the catalog.) ## Catalog lifecycle & staleness (#25) @@ -144,7 +144,7 @@ records the version the catalog was captured from. On every `plan`/`apply`: would actually matter. - If a **live grant carries an `authId` the catalog cannot name** (a stale or foreign right), `ct plan` names the `authId` + domain and **leaves the grant - untouched** — it is deliberately kept *out* of the diff so `ct apply` never + untouched** — it is deliberately kept _out_ of the diff so `ct apply` never revokes a right it cannot even describe. This is idempotent: the unknown row is excluded every run, so it neither churns nor silently disappears. @@ -162,7 +162,7 @@ The two DSL functions manage two different ChurchTools "domain types," and `groupType: ""` (resolved per host, #20) 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 + specific group's specific role, _not_ the group's id and _not_ the role's id. Declare it portably as `group: "", role: ""` (resolved per host, #25) or directly as `id: `. - **`status`** (`ct.status`) — the domain is a **person status's own id** @@ -197,7 +197,7 @@ The two DSL functions manage two different ChurchTools "domain types," and match it. > **Teardown caveat.** A person status is the one managed type whose deletion - > reaches person *records*: dropping the declaration and running `ct destroy` + > reaches person _records_: dropping the declaration and running `ct destroy` > deletes the status, and ChurchTools re-stamps every person carrying it. > `assertNotPeople` cannot catch this (`/statuses/{id}` is not a people path), > so `ct destroy` warns explicitly for this type and `--force` does **not** @@ -207,8 +207,8 @@ The two DSL functions manage two different ChurchTools "domain types," and 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 + 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. @@ -256,7 +256,7 @@ handled as a **pending domain** rather than aborting the plan: **`group_role` is deliberately NOT symmetric here.** A `group_role` domain id is the (group, role) **pairing** id, which only exists on -`GET /groups/{groupId}/roles` — re-resolving it needs a *live fetch* after the +`GET /groups/{groupId}/roles` — re-resolving it needs a _live fetch_ after the group exists, not just a post-execute state lookup. So a `group_role` domain referencing a same-run-created **group** still fails fast with its own actionable message ("apply the group first, or pass a numeric id"). Its harder @@ -268,16 +268,16 @@ scenario). A scoped grant's `scope: [...]` is a list where each entry is one of three forms (`src/permissions/scope.ts`): -| Form | Example | Dimension | -| --- | --- | --- | -| **Logical group key** (string) | `scope: ["kids_area"]` | groups (`cdb_gruppe`) only | -| **Typed logical reference** (#98) | `scope: [{ campus: "koblenz" }]` | campuses, group types — see below | -| **Raw numeric `dataId`** (escape hatch, #49) | `scope: [1, 2, 3]` | any dimension | +| Form | Example | Dimension | +| -------------------------------------------- | -------------------------------- | --------------------------------- | +| **Logical group key** (string) | `scope: ["kids_area"]` | groups (`cdb_gruppe`) only | +| **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**: - 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* +- A key **declared in this config but not yet created** resolves to a _pending_ target: the plan renders it as `scope=[ (created this apply)]`, and its real `dataId` is filled in at apply time — so a config can declare a group AND a grant scoped to it and still plan/apply in one run (no bootstrap deadlock). @@ -296,14 +296,14 @@ silently orphan a grant's scope. **Re-resolution at apply time.** Every scoped tuple resolved from a logical group key retains its symbolic scope key. Immediately before grants are written (after the resource tier has run), each key is re-resolved against the -post-execute state. This means a group *created* or *recreated* in the same +post-execute state. This means a group _created_ or _recreated_ in the same apply always gets its grant written with its fresh `dataId`, never a pending placeholder or a stale, dangling id. ### Typed logical scope references (#98) Not every scoped right scopes **by group**, and some of the other dimensions are -resources this tool *can* name portably. Those take a typed reference: +resources this tool _can_ name portably. Those take a typed reference: ```ts ct.groupRole({ @@ -320,22 +320,22 @@ 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: "" }` | `GET /departments` **only** — read-only, see below | +| `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: "" }` | `GET /departments` **only** — read-only, see below | **Why this matters:** campus ids are host-specific — Mainz is `0` on eqrm prod and `6` on eqrm dev. A campus-scoped grant written as a numeric literal is 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 +`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 +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, @@ -388,7 +388,7 @@ e.g.: - `churchdb:view comments` → `scopeField: "cdb_comment_viewer"` - `churchdb:security level view own data` / `edit own data` → `scopeField: - "cc_securitylevel"` +"cc_securitylevel"` For these, a `dataId` like `1`, `2`, `3` names a security level or a comment-viewer bucket — **not** a group — so `GET /groups/{1,2,3}` 404s and @@ -531,7 +531,7 @@ and `ct plan`. too broad and is removed. - **Revocation is a later extension, not exposed yet.** `GrantTuple.type` is typed as `"grant" | "revoke"`, but the DSL and `desiredTuples` currently - only ever *emit* `"grant"` tuples — there is no config-level way to declare + only ever _emit_ `"grant"` tuples — there is no config-level way to declare an explicit `type: "revoke"` grant today (`type: "revoke"` is reserved for a future `group_role`-only extension). Removing a grant's entry from the config still works as expected: the reconciler's set-diff (see below) @@ -578,7 +578,7 @@ unmanageable grant makes an entire role instance undeclarable — and on a real instance the blocker is almost always module data (an HTML template, a calendar category, a wiki category) sitting next to perfectly expressible structural grants. On eqrm prod that cost 403 of 590 authored grants, several of them -blocked by a *single* `cc_html_template` row on a 41-grant role. +blocked by a _single_ `cc_html_template` row on a 41-grant role. `preserveUnknown` is the deliberate way out: @@ -590,7 +590,7 @@ ct.groupRole({ // Own the structural grants; leave anything on these dimensions alone. This role also carries // cc_html_template grants that this scaffold has no business owning. preserveUnknown: ["cc_html_template"], - grants: [ /* the 40 structural ones */ ], + grants: [/* the 40 structural ones */], }); ``` @@ -598,7 +598,7 @@ ct.groupRole({ - **`true` preserves everything undeclared**; a **list of scope dimensions** is the form to prefer — it keeps the escape hatch's blast radius to the dimensions you consciously excluded, so a genuinely unexpected new grant on a - dimension you *do* manage still shows up as drift rather than being swallowed. + dimension you _do_ manage still shows up as drift rather than being swallowed. - A dimension list never widens to **unscoped** rights: you named dimensions to leave alone, and "no dimension" is not one of them. - A dimension no right in the catalog scopes by is an **eval-time error**, not a diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index cc589ee..b7ca3c0 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -17,12 +17,12 @@ Every item below falls into exactly one of three buckets: ## API gap — CT does not expose a write endpoint -| Item | What it is | Why manual | Where in the CT admin UI | How to verify | -| ------------------------------ | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Group member-statuses | The set of "member status" values a group can assign to its members (e.g. active/candidate) — NOT the same dimension as a group's own `groupStatusId`, which IS fully managed (`ct.group({ groupStatusId })`, [`docs/group-field-decisions.md`](group-field-decisions.md)); do not conflate the two (#67) | Only `GET /group/memberstatus` exists; no create/update/delete endpoint ([`docs/api-coverage.md`](api-coverage.md) #8) | Group settings → member status admin (org-wide master data, not per-group) | `ct get raw /group/memberstatus` (no dedicated `ct get member-statuses` subcommand yet — the generic `raw` path covers it) and diff by eye against the expected list below | -| Meeting points (Treffpunkte) | A group's meeting-location master data | No endpoint at all — zero matches for `treffpunkt`/`meetingpoint` anywhere in the OpenAPI spec ([`docs/api-coverage.md`](api-coverage.md) #11) | Group admin → meeting point field on a group | No API verification possible; visually confirm in the UI. (Do not confuse with _meeting templates_ `/group/meetingtemplates` or _group meetings_ `/groups/{id}/meetings`, both full CRUD but different concepts — confirm with product if "meeting point" was meant to be one of those instead) | -| Bereiche (departments) — creating/renaming one | The Bereich master data (`/departments`) — the `cdb_bereich` permission scope dimension | `GET /departments` exists but no write verb does — no `POST`/`PUT`/`DELETE`, and no `/departments/{id}` path at all (live-probed against the instance OpenAPI spec, eqrm prod CT 3.135.2, 2026-08-13; [`docs/api-coverage.md`](api-coverage.md) #14) | Master data admin → Bereiche | `ct get departments` lists them. **Grants scoped to one ARE declarable** — `scope: [{ department: "" }]` resolves by name per host (#98) — so only CREATING or RENAMING a Bereich is manual. Renaming one in CT breaks every config reference to its old name, by design: the reference then hard-errors instead of silently regranting elsewhere | -| Permission name↔authId catalog | The mapping from human-readable `module:right` names to the numeric `authId` the API actually writes | Not exposed by the REST API at all; only servable via the legacy `POST /index.php?q=churchauth/ajax&func=getMasterData` call ([`src/permissions/README.md`](../src/permissions/README.md)) | Permission editor (any role's right-picker enumerates the live set) | Regeneration procedure below (**Permission catalog lifecycle**) | +| Item | What it is | Why manual | Where in the CT admin UI | How to verify | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Group member-statuses | The set of "member status" values a group can assign to its members (e.g. active/candidate) — NOT the same dimension as a group's own `groupStatusId`, which IS fully managed (`ct.group({ groupStatusId })`, [`docs/group-field-decisions.md`](group-field-decisions.md)); do not conflate the two (#67) | Only `GET /group/memberstatus` exists; no create/update/delete endpoint ([`docs/api-coverage.md`](api-coverage.md) #8) | Group settings → member status admin (org-wide master data, not per-group) | `ct get raw /group/memberstatus` (no dedicated `ct get member-statuses` subcommand yet — the generic `raw` path covers it) and diff by eye against the expected list below | +| Meeting points (Treffpunkte) | A group's meeting-location master data | No endpoint at all — zero matches for `treffpunkt`/`meetingpoint` anywhere in the OpenAPI spec ([`docs/api-coverage.md`](api-coverage.md) #11) | Group admin → meeting point field on a group | No API verification possible; visually confirm in the UI. (Do not confuse with _meeting templates_ `/group/meetingtemplates` or _group meetings_ `/groups/{id}/meetings`, both full CRUD but different concepts — confirm with product if "meeting point" was meant to be one of those instead) | +| Bereiche (departments) — creating/renaming one | The Bereich master data (`/departments`) — the `cdb_bereich` permission scope dimension | `GET /departments` exists but no write verb does — no `POST`/`PUT`/`DELETE`, and no `/departments/{id}` path at all (live-probed against the instance OpenAPI spec, eqrm prod CT 3.135.2, 2026-08-13; [`docs/api-coverage.md`](api-coverage.md) #14) | Master data admin → Bereiche | `ct get departments` lists them. **Grants scoped to one ARE declarable** — `scope: [{ department: "" }]` resolves by name per host (#98) — so only CREATING or RENAMING a Bereich is manual. Renaming one in CT breaks every config reference to its old name, by design: the reference then hard-errors instead of silently regranting elsewhere | +| Permission name↔authId catalog | The mapping from human-readable `module:right` names to the numeric `authId` the API actually writes | Not exposed by the REST API at all; only servable via the legacy `POST /index.php?q=churchauth/ajax&func=getMasterData` call ([`src/permissions/README.md`](../src/permissions/README.md)) | Permission editor (any role's right-picker enumerates the live set) | Regeneration procedure below (**Permission catalog lifecycle**) | **Expected values for a given instance:** left blank here deliberately — this runbook is generic (part of `ct-cli`, the tool repo). The per-instance @@ -31,16 +31,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).** 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**) | ## Out of tool scope — deliberate, not a gap @@ -48,8 +48,8 @@ in that instance's own config repo, in a runbook following this doc's structure. | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | People, memberships, group member lists | Hard boundary enforced in code (`assertNotPeople`, `src/engine/guard.ts`) — the tool manages rights-bearing _structure_ only, never who's in it. This is permanent by design, not a roadmap item; see README's "People are never managed" | | Other CT modules — calendars, services (`churchservice`), resource booking (`churchresource`), forms, check-in, wiki, finance, sync | Never in the tool's stated mandate ("campuses, structural groups, hierarchies, group types/roles, permission & auto-groups" — README). Phase 0's coverage matrix (`docs/api-coverage.md`) only analyzed the 12 resource types relevant to that structural mandate; nothing else was assessed for CRUD support and nothing else is planned | -| Module-level settings, i18n | Out of tool scope by design — global instance configuration, not per-resource declarative structure | -| Custom field / master-data field **VALUES on individual person/group records** | People/record data is the permanent people boundary (`assertNotPeople`). Note: the field **DEFINITIONS** (schema) are **no longer out of scope** — they moved to read-only supported above (#47/#48, [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md)); only the per-record *values* stay out of scope. | +| Module-level settings, i18n | Out of tool scope by design — global instance configuration, not per-resource declarative structure | +| Custom field / master-data field **VALUES on individual person/group records** | People/record data is the permanent people boundary (`assertNotPeople`). Note: the field **DEFINITIONS** (schema) are **no longer out of scope** — they moved to read-only supported above (#47/#48, [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md)); only the per-record _values_ stay out of scope. | ## Forcing ChurchTools to evaluate auto-groups now @@ -106,7 +106,7 @@ stamps the instance's CT version into `$meta`, and rewrites the file (read-only against the instance). Review the `git diff` and commit. **Staleness signals.** `ct plan`/`ct apply` throw a clear "did you mean" error -for an unknown right *name* in a config; they now also **warn** (not fail) when +for an unknown right _name_ in a config; they now also **warn** (not fail) when the live instance's CT version differs from `$meta.ctVersion`, and when a live grant carries an `authId` the catalog cannot name (left untouched, never revoked). Both are fixed by regenerating. @@ -178,7 +178,7 @@ in where they'd otherwise be silently skipped: 7. **Field definitions & security levels** — read the current schema with `ct get person-masterdata` and `ct get data-fields` to confirm the expected fields, field groups, and security levels exist; create/edit any missing - *definitions* by hand in the master-data admin UI (no REST write endpoint — + _definitions_ by hand in the master-data admin UI (no REST write endpoint — see the field-definitions row above and [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md)). 8. **Anything from the "out of tool scope" table** — persons, memberships, per-record field values, calendars, services, resource booking, forms, diff --git a/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md b/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md index 537465f..6aa673f 100644 --- a/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md +++ b/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md @@ -42,11 +42,13 @@ ## Task 1: `preventDestroy` lifecycle flag **Files:** + - Modify: `src/engine/types.ts` - Modify: `src/config/context.ts` - Test: `tests/context.test.ts` **Interfaces:** + - Produces: `DesiredResource.preventDestroy?: boolean`; `ResourceInput.preventDestroy?: boolean`. The flag is extracted before building `fields`, so it is never diffed or sent to the API. - [ ] **Step 1: Write the failing test** — append to `tests/context.test.ts`: @@ -125,10 +127,12 @@ git commit -m "feat(config): preventDestroy lifecycle flag" ## Task 2: Registry write specs + new writable types **Files:** + - Modify: `src/resources/registry.ts` - Test: `tests/registry.test.ts` **Interfaces:** + - Produces: `AdoptableResource` gains `collectionPath: string` and `updateMethod: "PUT" | "PATCH"`. `itemPath(id)` still returns `\`${collectionPath}/${id}\``. New entries: `age-group`, `target-group`, `relationship-type`, `group-role`. - [ ] **Step 1: Write the failing test** — append to `tests/registry.test.ts`: @@ -259,10 +263,12 @@ git commit -m "feat(registry): write specs (collectionPath, updateMethod) + new ## Task 3: People-boundary guard **Files:** + - Create: `src/engine/guard.ts` - Test: `tests/guard.test.ts` **Interfaces:** + - Produces: `assertNotPeople(path: string): void` — throws for any people/membership write path; returns silently for structural paths (including `/groups/{id}/parents/{id}`). - [ ] **Step 1: Write the failing test** — create `tests/guard.test.ts`: @@ -325,9 +331,7 @@ const FORBIDDEN: RegExp[] = [ export function assertNotPeople(path: string): void { if (FORBIDDEN.some((re) => re.test(path))) { - throw new Error( - `Refusing to write to "${path}": people/memberships are never managed by this tool.`, - ); + throw new Error(`Refusing to write to "${path}": people/memberships are never managed by this tool.`); } } ``` @@ -349,10 +353,12 @@ git commit -m "feat(engine): assertNotPeople hard boundary guard" ## Task 4: Backup writer **Files:** + - Create: `src/engine/backup.ts` - Test: `tests/backup.test.ts` **Interfaces:** + - Produces: `writeBackup(dir: string, host: string, actual: Map>, now?: Date): Promise` — returns the written file path. Creates `dir` if missing. Filename `ct-backup-.json`. Content: `{ host, capturedAt, resources }`. - [ ] **Step 1: Write the failing test** — create `tests/backup.test.ts`: @@ -447,10 +453,12 @@ git commit -m "feat(engine): pre-write backup writer" ## Task 5: Confirmation prompts **Files:** + - Create: `src/ui/prompt.ts` - Test: `tests/prompt.test.ts` **Interfaces:** + - Produces: - `confirm(message: string, opts?: PromptOptions & { assumeYes?: boolean }): Promise` - `confirmTyped(expected: string, opts?: PromptOptions & { force?: boolean }): Promise` @@ -578,11 +586,13 @@ git commit -m "feat(ui): confirm + confirmTyped prompts" ## Task 6: Shared plan building (`buildPlan`) **Files:** + - Create: `src/engine/build.ts` - Modify: `src/commands/plan.ts` - Test: `tests/build.test.ts` **Interfaces:** + - Produces: `buildPlan(client, state, desired): Promise` where - `client: Pick` - `interface BuildResult { plan: Plan; actual: Map>; fetchErrors: string[] }` @@ -795,10 +805,12 @@ git commit -m "refactor(engine): extract buildPlan; plan command reuses it" ## Task 7: The executor (`executePlan`) **Files:** + - Create: `src/engine/execute.ts` - Test: `tests/execute.test.ts` **Interfaces:** + - Consumes: `RESOURCES` (Task 2), `assertNotPeople` (Task 3), `upsert`/`saveState` from `state.ts`, `Plan`/`PlanItem`/`FieldChange` from `types.ts`. - Produces: - `interface ExecuteDeps { client: Pick; state: State; statePath: string; now?: () => string; save?: (path: string, state: State) => Promise }` @@ -853,35 +865,93 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.created).toEqual(["zurich"]); - expect(calls[0]).toEqual({ method: "POST", path: "/campuses", body: { name: "Zürich", shortName: "ZH" } }); - expect(state.resources.zurich).toMatchObject({ type: "campus", id: 5, key: "zurich", fields: { name: "Zürich", shortName: "ZH" } }); + expect(calls[0]).toEqual({ + method: "POST", + path: "/campuses", + body: { name: "Zürich", shortName: "ZH" }, + }); + expect(state.resources.zurich).toMatchObject({ + type: "campus", + id: 5, + key: "zurich", + fields: { name: "Zürich", shortName: "ZH" }, + }); }); it("updates a group via PATCH with the full managed snapshot", async () => { const state = emptyState("h"); - state.resources.team = { type: "group", id: 9, key: "team", fields: { name: "Team", groupTypeId: 2, groupStatusId: 1 }, adoptedAt: "t", updatedAt: "t" }; + state.resources.team = { + type: "group", + id: 9, + key: "team", + fields: { name: "Team", groupTypeId: 2, groupStatusId: 1 }, + adoptedAt: "t", + updatedAt: "t", + }; const { client, calls } = recorder(); const plan: Plan = { items: [ - { type: "group", key: "team", id: 9, action: "update", changes: [{ field: "name", from: "Team", to: "Team A" }] }, + { + type: "group", + key: "team", + id: 9, + action: "update", + changes: [{ field: "name", from: "Team", to: "Team A" }], + }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.updated).toEqual(["team"]); - expect(calls[0]).toEqual({ method: "PATCH", path: "/groups/9", body: { name: "Team A", groupTypeId: 2, groupStatusId: 1 } }); + expect(calls[0]).toEqual({ + method: "PATCH", + path: "/groups/9", + body: { name: "Team A", groupTypeId: 2, groupStatusId: 1 }, + }); expect(state.resources.team!.fields).toEqual({ name: "Team A", groupTypeId: 2, groupStatusId: 1 }); }); it("reconciles hierarchy edges via PUT/DELETE and never stores parents in state", async () => { const state = emptyState("h"); - state.resources.parent = { type: "group", id: 1, key: "parent", fields: { name: "P" }, adoptedAt: "t", updatedAt: "t" }; - state.resources.child = { type: "group", id: 2, key: "child", fields: { name: "C" }, adoptedAt: "t", updatedAt: "t" }; + state.resources.parent = { + type: "group", + id: 1, + key: "parent", + fields: { name: "P" }, + adoptedAt: "t", + updatedAt: "t", + }; + state.resources.child = { + type: "group", + id: 2, + key: "child", + fields: { name: "C" }, + adoptedAt: "t", + updatedAt: "t", + }; const { client, calls } = recorder(); const plan: Plan = { items: [ - { type: "group", key: "child", id: 2, action: "update", changes: [{ field: "parents", from: [], to: ["parent"] }] }, + { + type: "group", + key: "child", + id: 2, + action: "update", + changes: [{ field: "parents", from: [], to: ["parent"] }], + }, ], }; await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); @@ -894,7 +964,13 @@ describe("executePlan", () => { state.resources.old = { type: "campus", id: 3, key: "old", fields: {}, adoptedAt: "t", updatedAt: "t" }; const { client, calls } = recorder(); const plan: Plan = { items: [{ type: "campus", key: "old", id: 3, action: "delete", changes: [] }] }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.skippedDeletes).toEqual(["old"]); expect(calls).toEqual([]); expect(state.resources.old).toBeDefined(); @@ -908,9 +984,23 @@ describe("executePlan", () => { }, }; const plan: Plan = { - items: [{ type: "campus", key: "zurich", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Z" }] }], + items: [ + { + type: "campus", + key: "zurich", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Z" }], + }, + ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed).toEqual({ key: "zurich", message: "boom" }); expect(result.created).toEqual([]); }); @@ -1026,7 +1116,12 @@ export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise 0) { - error(`Aborting: ${fetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`); + error( + `Aborting: ${fetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`, + ); process.exitCode = 1; return; } @@ -1202,7 +1306,9 @@ export function applyCommand(): Command { const result = await executePlan(plan, { client, state, statePath, save: saveState }); success(`Applied: ${result.created.length} created, ${result.updated.length} updated.`); if (result.failed) { - error(`Stopped at ${result.failed.key}: ${result.failed.message}. State saved up to this point — re-run to resume.`); + error( + `Stopped at ${result.failed.key}: ${result.failed.message}. State saved up to this point — re-run to resume.`, + ); process.exitCode = 1; } }); @@ -1226,10 +1332,12 @@ git commit -m "feat(cli): ct apply — plan, confirm, backup, execute" ## Task 9: `ct destroy` command **Files:** + - Create: `src/commands/destroy.ts` - Test: `tests/destroy.test.ts` (target parsing + reverse ordering + preventDestroy set) **Interfaces:** + - Consumes: `RESOURCES`, `assertNotPeople`, `confirmTyped`, `writeBackup`, `tierOf` from `engine/graph.js`, `saveState`. - Produces: `destroyCommand(): Command`; exported helpers `parseTargets(raw: string[]): string[]` and `orderDestroy(state, keys): string[]`. @@ -1249,7 +1357,14 @@ describe("parseTargets", () => { describe("orderDestroy", () => { it("orders higher tiers first (reverse of apply): groups before campuses", () => { const state = emptyState("h"); - state.resources.mainz = { type: "campus", id: 0, key: "mainz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources.mainz = { + type: "campus", + id: 0, + key: "mainz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; state.resources.team = { type: "group", id: 1, key: "team", fields: {}, adoptedAt: "t", updatedAt: "t" }; expect(orderDestroy(state, ["mainz", "team"])).toEqual(["team", "mainz"]); }); @@ -1404,11 +1519,13 @@ git commit -m "feat(cli): ct destroy — explicit, protected, reverse-order dele ## Task 10: Wire commands + integration test + cleanup **Files:** + - Modify: `src/index.ts` - Modify: `src/commands/placeholders.ts` - Test: `tests/cli.test.ts`, `tests/integration.test.ts` **Interfaces:** + - Consumes: `applyCommand`, `destroyCommand`. - Produces: `apply`/`destroy` registered on the program; `placeholders.ts` no longer lists them. @@ -1479,7 +1596,14 @@ describe("apply → re-plan shows no drift", () => { const client = fakeCt(); const state = emptyState("h"); // adopt the existing Mainz campus - state.resources.mz = { type: "campus", id: 0, key: "mz", fields: { name: "Mainz", shortName: "MZ" }, adoptedAt: "t", updatedAt: "t" }; + state.resources.mz = { + type: "campus", + id: 0, + key: "mz", + fields: { name: "Mainz", shortName: "MZ" }, + adoptedAt: "t", + updatedAt: "t", + }; const desired: DesiredResource[] = [ { type: "campus", key: "mz", fields: { name: "Mainz City", shortName: "MZ" }, dependsOn: [] }, @@ -1521,8 +1645,8 @@ import { destroyCommand } from "./commands/destroy.js"; and, after `program.addCommand(planCommand());`: ```ts - program.addCommand(applyCommand()); - program.addCommand(destroyCommand()); +program.addCommand(applyCommand()); +program.addCommand(destroyCommand()); ``` (The `for (const cmd of plannedCommands())` loop stays — it just adds nothing now.) @@ -1544,6 +1668,7 @@ git commit -m "feat(cli): wire apply/destroy; drop placeholders; DoD integration ## Task 11: Docs, live-verify field sets, lint/typecheck/build **Files:** + - Modify: `README.md` - Modify: `src/resources/registry.ts` (only if live-verify shows different fields) @@ -1574,5 +1699,5 @@ git commit -m "docs(phase-4): README apply/destroy; live-verified field sets" - **Spec coverage:** apply (T6–T8), destroy + preventDestroy (T1, T9), backup (T4, T8, T9), people boundary (T3, used in T7/T9), rate-limit/retry (existing — noted, no task), state-after-each (T7 saves; T9 saves), confirmation (T5, T8, T9), all writable types (T2), DoD integration (T10), live-verify (T11). No gaps. - **Type consistency:** `ExecuteDeps`/`ExecuteResult`/`BuildResult`/`PromptOptions` names are used identically across tasks. `snapshotFromChanges` is the single field-merge helper. `resolveBackupDir` defined in T8, reused by T9. -- **Placeholder scan:** none — every code step is complete. The only deferred item is the *provisional field sets*, which are explicitly a live-verify step (T11) with a concrete verification command, not a plan gap. +- **Placeholder scan:** none — every code step is complete. The only deferred item is the _provisional field sets_, which are explicitly a live-verify step (T11) with a concrete verification command, not a plan gap. - **Known limitation (documented):** `orderDestroy` uses reverse-tier order only (state doesn't store hierarchy edges), so deleting a parent group before its child within the same tier isn't auto-ordered; acceptable for Phase 4 and noted. diff --git a/docs/superpowers/plans/2026-07-08-auto-groups.md b/docs/superpowers/plans/2026-07-08-auto-groups.md index dc7874f..ad67832 100644 --- a/docs/superpowers/plans/2026-07-08-auto-groups.md +++ b/docs/superpowers/plans/2026-07-08-auto-groups.md @@ -40,11 +40,13 @@ ## Task 1: Capture live fixtures for a real dynamic group **Files:** + - Create: `tests/fixtures/dynamic/ruleset.get.json` (bare `RuleSet` as returned by GET) - Create: `tests/fixtures/dynamic/status.get.json` (`{ dynamicGroupStatus }`) - Create: `tests/fixtures/dynamic/README.md` (how the fixture was produced + the group id used, so it is reproducible) **Interfaces:** + - Produces: canonical example `RuleSet` + status JSON consumed by Tasks 4, 5, 7 (normalizer, synthetic-field, typed-query). All later normalizer code is validated against these files. **Why a task:** the `query.params.filter` JSONLogic internals and `handleMembership` contents are opaque in OpenAPI. The only way to write a correct normalizer + typed-query compiler is against real rulesets. @@ -58,12 +60,14 @@ ## Task 2: Generic synthetic-field seam; migrate `parents` into it **Files:** + - Create: `src/engine/synthetic.ts` - Modify: `src/engine/build.ts` (replace the inline hierarchy fold with the registry) - Modify: `src/engine/execute.ts` (route synthetic-field writes through the registry) - Test: `tests/synthetic.test.ts` **Interfaces:** + - Produces: ```ts export interface SyntheticFoldCtx { @@ -75,23 +79,26 @@ export interface SyntheticApplyCtx { client: Pick; state: State; - id: number; // CT id of the owning resource just created/updated - change: FieldChange; // the change whose `field` === this.field + id: number; // CT id of the owning resource just created/updated + change: FieldChange; // the change whose `field` === this.field } export interface SyntheticField { - field: string; // pseudo-field name, e.g. "parents", "dynamic" + field: string; // pseudo-field name, e.g. "parents", "dynamic" fold(ctx: SyntheticFoldCtx): Promise<{ desired: DesiredResource[]; errors: string[] }>; apply(ctx: SyntheticApplyCtx): Promise; } - export const SYNTHETIC_FIELDS: SyntheticField[]; // registry; parents first + export const SYNTHETIC_FIELDS: SyntheticField[]; // registry; parents first export function isSyntheticField(field: string): boolean; - export async function foldSynthetic(ctx: SyntheticFoldCtx): Promise<{ desired: DesiredResource[]; errors: string[] }>; + export async function foldSynthetic( + ctx: SyntheticFoldCtx, + ): Promise<{ desired: DesiredResource[]; errors: string[] }>; ``` - Consumes: existing `applyHierarchy`, `parentIdsByGroupId`, `HierarchyEntry` (from `hierarchy.ts`); existing `applyParentEdges` logic (moved/wrapped). - [ ] **Step 1: Write the failing test** `tests/synthetic.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { isSyntheticField, SYNTHETIC_FIELDS, foldSynthetic } from "../src/engine/synthetic.js"; @@ -107,20 +114,41 @@ describe("synthetic-field registry", () => { it("parents fold folds managed hierarchy into desired + actual", async () => { const state: State = { - version: 1, host: "h", + version: 1, + host: "h", resources: { - child: { type: "group", id: 1311, key: "child", fields: { name: "child" }, adoptedAt: "t", updatedAt: "t" }, - parent: { type: "group", id: 8, key: "parent", fields: { name: "parent" }, adoptedAt: "t", updatedAt: "t" }, + child: { + type: "group", + id: 1311, + key: "child", + fields: { name: "child" }, + adoptedAt: "t", + updatedAt: "t", + }, + parent: { + type: "group", + id: 8, + key: "parent", + fields: { name: "parent" }, + adoptedAt: "t", + updatedAt: "t", + }, }, }; const actual = new Map>([ - ["child", { name: "child" }], ["parent", { name: "parent" }], + ["child", { name: "child" }], + ["parent", { name: "parent" }], ]); const desired: DesiredResource[] = [ { type: "group", key: "child", fields: { name: "child" }, parents: ["parent"], dependsOn: ["parent"] }, { type: "group", key: "parent", fields: { name: "parent" }, dependsOn: [] }, ]; - const client = { get: async () => [{ groupId: 1311, parents: [8], children: [] }, { groupId: 8, children: [1311] }] }; + const client = { + get: async () => [ + { groupId: 1311, parents: [8], children: [] }, + { groupId: 8, children: [1311] }, + ], + }; const out = await foldSynthetic({ client, state, desired, actual }); expect(out.errors).toEqual([]); expect(actual.get("child")?.parents).toEqual(["parent"]); @@ -239,19 +267,23 @@ Expected: PASS. - [ ] **Step 5: Rewire `build.ts` to use `foldSynthetic`** In `src/engine/build.ts`, replace the hierarchy block (the `let parentIds` / `hasManagedGroups` / `applyHierarchy` section, lines ~59-76) with: + ```ts - // Synthetic sub-resource fields (parents, dynamic, …) fold into the diff on both sides. - const folded = await foldSynthetic({ client, state, desired, actual }); - fetchErrors.push(...folded.errors); - const plan = computePlan(folded.desired, state, actual, { unresolved }); +// Synthetic sub-resource fields (parents, dynamic, …) fold into the diff on both sides. +const folded = await foldSynthetic({ client, state, desired, actual }); +fetchErrors.push(...folded.errors); +const plan = computePlan(folded.desired, state, actual, { unresolved }); ``` + Update imports: remove `applyHierarchy`, `parentIdsByGroupId`, `HierarchyEntry`, and add `import { foldSynthetic } from "./synthetic.js";`. Delete the now-unused `desiredWithHierarchy`/`hierarchyOk` locals. - [ ] **Step 6: Rewire `execute.ts` to route synthetic writes through the registry** In `src/engine/execute.ts`: + - Replace `applyParentEdges(...)` calls (in both the create and update branches) with a generic `applySyntheticFields(client, state, id, item.changes)`. - Add the helper and update `snapshotFromChanges` to drop **all** synthetic fields, not just `parents`: + ```ts import { isSyntheticField, syntheticField } from "./synthetic.js"; @@ -263,7 +295,10 @@ function snapshotFromChanges(base: Record, changes: FieldChange } async function applySyntheticFields( - client: Pick, state: State, id: number, changes: FieldChange[], + client: Pick, + state: State, + id: number, + changes: FieldChange[], ): Promise { for (const c of changes) { const f = syntheticField(c.field); @@ -271,6 +306,7 @@ async function applySyntheticFields( } } ``` + Also update `hasFieldChange` in the update branch to `item.changes.some((c) => !isSyntheticField(c.field))` so a change touching only synthetic fields still skips the body PATCH. Remove the old `parentEdges`/`resolveId`/`applyParentEdges` functions. - [ ] **Step 7: Run the full suite to verify the refactor is behavior-preserving** @@ -290,18 +326,20 @@ git commit -m "refactor(engine): generic synthetic-field seam; migrate parents i ## Task 3: Accept and validate the `dynamic` block in the config DSL **Files:** + - Modify: `src/config/context.ts` (add `dynamic` handling to `toDesired` + validation) - Modify: `src/engine/types.ts` (add `dynamic?` to `DesiredResource`, define `DynamicSpec`) - Test: `tests/context.test.ts` (extend) **Interfaces:** + - Produces: ```ts // types.ts export type DynamicStatus = "active" | "inactive" | "manual" | "none"; export interface DynamicSpec { status: DynamicStatus; - ruleset: unknown; // RuleSet object, or { ref: "./path.json" }, or a typed-query build result + ruleset: unknown; // RuleSet object, or { ref: "./path.json" }, or a typed-query build result } // DesiredResource gains: dynamic?: DynamicSpec; ``` @@ -315,23 +353,32 @@ import { createContext } from "../src/config/context.js"; describe("dynamic block", () => { it("attaches a validated dynamic spec to the group and drops it from plain fields", async () => { const { ct, resources } = createContext(); - ct.group({ key: "all_mainz", name: "Alle Mainz", groupTypeId: 1, - dynamic: { status: "manual", ruleset: { description: "x", method: "ChurchQuery", params: {} } } }); + ct.group({ + key: "all_mainz", + name: "Alle Mainz", + groupTypeId: 1, + dynamic: { status: "manual", ruleset: { description: "x", method: "ChurchQuery", params: {} } }, + }); const g = resources.find((r) => r.key === "all_mainz")!; - expect(g.dynamic).toEqual({ status: "manual", ruleset: { description: "x", method: "ChurchQuery", params: {} } }); - expect(g.fields).not.toHaveProperty("dynamic"); // never a plain diffed field + expect(g.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", method: "ChurchQuery", params: {} }, + }); + expect(g.fields).not.toHaveProperty("dynamic"); // never a plain diffed field }); it("rejects an invalid status", async () => { const { ct } = createContext(); - expect(() => ct.group({ key: "g", name: "G", dynamic: { status: "bogus", ruleset: {} } as never })) - .toThrow(/dynamic.*status/i); + expect(() => + ct.group({ key: "g", name: "G", dynamic: { status: "bogus", ruleset: {} } as never }), + ).toThrow(/dynamic.*status/i); }); it("rejects dynamic on a non-group", async () => { const { ct } = createContext(); - expect(() => ct.campus({ key: "c", name: "C", dynamic: { status: "manual", ruleset: {} } } as never)) - .toThrow(/dynamic.*only.*group/i); + expect(() => + ct.campus({ key: "c", name: "C", dynamic: { status: "manual", ruleset: {} } } as never), + ).toThrow(/dynamic.*only.*group/i); }); }); ``` @@ -346,21 +393,24 @@ Expected: FAIL — `g.dynamic` undefined; no validation. In `src/engine/types.ts` add the exports above and `dynamic?: DynamicSpec;` to `DesiredResource`. In `src/config/context.ts`: + - Add `dynamic` to the destructure in `toDesired`: `const { key, parent, parents, dependsOn = [], preventDestroy, dynamic, ...fields } = input;` - After the existing validation, add: + ```ts - const DYNAMIC_STATUSES = ["active", "inactive", "manual", "none"] as const; - let dynamicSpec: DynamicSpec | undefined; - if (dynamic !== undefined) { - if (type !== "group") throw new Error(`${type} "${key}": "dynamic" is only valid on a group.`); - const d = dynamic as Record; - if (!DYNAMIC_STATUSES.includes(d.status as never)) - throw new Error(`group "${key}": "dynamic.status" must be one of ${DYNAMIC_STATUSES.join(", ")}.`); - if (d.ruleset == null || typeof d.ruleset !== "object") - throw new Error(`group "${key}": "dynamic.ruleset" must be a RuleSet object or a { ref } reference.`); - dynamicSpec = { status: d.status as DynamicStatus, ruleset: d.ruleset }; - } +const DYNAMIC_STATUSES = ["active", "inactive", "manual", "none"] as const; +let dynamicSpec: DynamicSpec | undefined; +if (dynamic !== undefined) { + if (type !== "group") throw new Error(`${type} "${key}": "dynamic" is only valid on a group.`); + const d = dynamic as Record; + if (!DYNAMIC_STATUSES.includes(d.status as never)) + throw new Error(`group "${key}": "dynamic.status" must be one of ${DYNAMIC_STATUSES.join(", ")}.`); + if (d.ruleset == null || typeof d.ruleset !== "object") + throw new Error(`group "${key}": "dynamic.ruleset" must be a RuleSet object or a { ref } reference.`); + dynamicSpec = { status: d.status as DynamicStatus, ruleset: d.ruleset }; +} ``` + - Return `dynamic: dynamicSpec` on the `DesiredResource` (add to the returned object). Import `DynamicSpec`, `DynamicStatus` from `../engine/types.js`. - Keep `dynamic` out of `fields` (it is destructured out above — verify it is not spread back in). @@ -381,21 +431,27 @@ git commit -m "feat(dynamic): accept and validate the dynamic block in the confi ## Task 4: Ruleset normalizer **Files:** + - Create: `src/engine/dynamic.ts` (normalizer + status helpers) - Test: `tests/dynamic.test.ts` **Interfaces:** + - Produces: ```ts - export interface NormalizedDynamic { status: DynamicStatus; ruleset: Record } - export function normalizeRuleset(rule: unknown): Record; // canonical form for diffing - export function stripCosmeticLabels(node: unknown): unknown; // remove dterm [label, expr] wrappers - export function coerceScalars(node: unknown): unknown; // int/string coercion of leaf values + export interface NormalizedDynamic { + status: DynamicStatus; + ruleset: Record; + } + export function normalizeRuleset(rule: unknown): Record; // canonical form for diffing + export function stripCosmeticLabels(node: unknown): unknown; // remove dterm [label, expr] wrappers + export function coerceScalars(node: unknown): unknown; // int/string coercion of leaf values export function normalizeDynamic(spec: { status: DynamicStatus; ruleset: unknown }): NormalizedDynamic; ``` - Consumes: `DynamicStatus` from `types.js`; the fixtures from Task 1. **Reference:** the golden inputs are the **real production fixtures** captured in Task 1 (`tests/fixtures/dynamic/ruleset*.get.json`) — see `tests/fixtures/dynamic/README.md`. Key realities they encode: + - **`GET …/ruleset` returns a single-element array `[RuleSet]`** — `normalizeRuleset` unwraps it (see code below). - `dterm: [label, expr]` labels appear as **both** plain strings (`"Nur aktive Personen"`) and objects (`{ title, stereotype? }`, `title` possibly an i18n key). The stripper drops element 0 regardless of its shape and keeps `expr`. - int/string inconsistency is pervasive (`1` vs `"1"`, `oneof [...["112","8"]]` vs `oneof [...[1]]`) — `coerceScalars` fixes it. Non-numeric strings (`"active"`, field names) are left alone. @@ -403,10 +459,16 @@ git commit -m "feat(dynamic): accept and validate the dynamic block in the confi - [ ] **Step 1: Write the failing test** `tests/dynamic.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; -import { normalizeRuleset, stripCosmeticLabels, coerceScalars, normalizeDynamic } from "../src/engine/dynamic.js"; +import { + normalizeRuleset, + stripCosmeticLabels, + coerceScalars, + normalizeDynamic, +} from "../src/engine/dynamic.js"; describe("stripCosmeticLabels", () => { it("unwraps a dterm with a string label to its expr, recursively", () => { @@ -414,27 +476,37 @@ describe("stripCosmeticLabels", () => { expect(stripCosmeticLabels(input)).toEqual({ and: [{ "==": [{ var: "ctgroup.campusId" }, 1] }] }); }); it("unwraps a dterm with an object label (title/stereotype/i18n key)", () => { - const input = { dterm: [{ title: "group.x.title", stereotype: ["groupmembership"] }, { isnull: [{ var: "person.dateOfDeath" }] }] }; + const input = { + dterm: [ + { title: "group.x.title", stereotype: ["groupmembership"] }, + { isnull: [{ var: "person.dateOfDeath" }] }, + ], + }; expect(stripCosmeticLabels(input)).toEqual({ isnull: [{ var: "person.dateOfDeath" }] }); }); }); describe("coerceScalars", () => { it("coerces numeric strings to numbers so int/string drift is not spurious", () => { - expect(coerceScalars({ "==": [{ var: "ctgroup.campusId" }, "1"] })) - .toEqual({ "==": [{ var: "ctgroup.campusId" }, 1] }); + expect(coerceScalars({ "==": [{ var: "ctgroup.campusId" }, "1"] })).toEqual({ + "==": [{ var: "ctgroup.campusId" }, 1], + }); }); it("coerces numeric strings inside oneof arrays but leaves non-numeric strings", () => { - expect(coerceScalars({ oneof: [{ var: "ctgroup.id" }, ["112", "8"]] })) - .toEqual({ oneof: [{ var: "ctgroup.id" }, [112, 8]] }); - expect(coerceScalars({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] })) - .toEqual({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] }); + expect(coerceScalars({ oneof: [{ var: "ctgroup.id" }, ["112", "8"]] })).toEqual({ + oneof: [{ var: "ctgroup.id" }, [112, 8]], + }); + expect(coerceScalars({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] })).toEqual({ + "==": [{ var: "groupmember.groupMemberStatus" }, "active"], + }); }); }); describe("normalizeRuleset", () => { it("drops read-only timestamps and the PUT envelope, and is idempotent", () => { - const withEnvelope = { dynamicGroupRuleSet: { description: "x", dynamicGroupUpdateStarted: "t", process: {}, query: {} } }; + const withEnvelope = { + dynamicGroupRuleSet: { description: "x", dynamicGroupUpdateStarted: "t", process: {}, query: {} }, + }; const once = normalizeRuleset(withEnvelope); expect(once).not.toHaveProperty("dynamicGroupUpdateStarted"); expect(once).not.toHaveProperty("dynamicGroupRuleSet"); @@ -450,8 +522,8 @@ describe("normalizeRuleset", () => { for (const name of ["ruleset-683", "ruleset-2022", "ruleset-1092"]) { const raw = JSON.parse(readFileSync(`tests/fixtures/dynamic/${name}.get.json`, "utf8")); // array shape const once = normalizeRuleset(raw); - expect(normalizeRuleset(once)).toEqual(once); // idempotent - expect(JSON.stringify(once)).not.toContain("dterm"); // cosmetic labels stripped + expect(normalizeRuleset(once)).toEqual(once); // idempotent + expect(JSON.stringify(once)).not.toContain("dterm"); // cosmetic labels stripped } }); }); @@ -519,7 +591,7 @@ function dropReadOnly(rule: Record): Record { /** Canonicalise a ruleset for diffing: unwrap array/PUT envelope, drop timestamps, strip labels, coerce scalars. */ export function normalizeRuleset(rule: unknown): Record { let r: unknown = rule ?? {}; - if (Array.isArray(r)) r = r[0] ?? {}; // GET returns a single-element [RuleSet] + if (Array.isArray(r)) r = r[0] ?? {}; // GET returns a single-element [RuleSet] let obj = (r ?? {}) as Record; if (obj.dynamicGroupRuleSet && typeof obj.dynamicGroupRuleSet === "object") { obj = obj.dynamicGroupRuleSet as Record; // unwrap the PUT envelope @@ -527,7 +599,10 @@ export function normalizeRuleset(rule: unknown): Record { return coerceScalars(stripCosmeticLabels(dropReadOnly(obj))) as Record; } -export interface NormalizedDynamic { status: DynamicStatus; ruleset: Record } +export interface NormalizedDynamic { + status: DynamicStatus; + ruleset: Record; +} export function normalizeDynamic(spec: { status: DynamicStatus; ruleset: unknown }): NormalizedDynamic { return { status: spec.status, ruleset: normalizeRuleset(spec.ruleset) }; @@ -551,11 +626,13 @@ git commit -m "feat(dynamic): ruleset normalizer (labels, scalars, timestamps, e ## Task 5: `dynamic` synthetic-field entry (fold + apply) **Files:** + - Modify: `src/engine/dynamic.ts` (add `resolveRulesetRef`) - Modify: `src/engine/synthetic.ts` (register the `dynamic` field) - Test: `tests/synthetic-dynamic.test.ts` **Interfaces:** + - Consumes: `normalizeDynamic`, `normalizeRuleset` (Task 4); `SyntheticField` (Task 2); `CtClient.get/request`. - Produces: a second entry in `SYNTHETIC_FIELDS` with `field: "dynamic"`. @@ -564,6 +641,7 @@ git commit -m "feat(dynamic): ruleset normalizer (labels, scalars, timestamps, e - [ ] **Step 1: Write the failing test** `tests/synthetic-dynamic.test.ts`: + ```ts import { describe, it, expect, vi } from "vitest"; import { SYNTHETIC_FIELDS, syntheticField } from "../src/engine/synthetic.js"; @@ -575,25 +653,50 @@ const dynamicField = () => syntheticField("dynamic")!; describe("dynamic synthetic field — fold", () => { it("injects normalized dynamic into desired.fields and actual for an opted-in managed group", async () => { expect(SYNTHETIC_FIELDS.some((f) => f.field === "dynamic")).toBe(true); - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], - dynamic: { status: "manual", ruleset: { description: "x", query: {}, process: {} } } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "manual", ruleset: { description: "x", query: {}, process: {} } }, + }, ]; - const client = { get: vi.fn(async (p: string) => - p.endsWith("/ruleset") ? { description: "x", query: {}, process: {}, dynamicGroupUpdateStarted: "t" } - : { dynamicGroupStatus: "manual" }) }; + const client = { + get: vi.fn(async (p: string) => + p.endsWith("/ruleset") + ? { description: "x", query: {}, process: {}, dynamicGroupUpdateStarted: "t" } + : { dynamicGroupStatus: "manual" }, + ), + }; const out = await dynamicField().fold({ client, state, desired, actual }); expect(out.errors).toEqual([]); - expect(actual.get("g")?.dynamic).toEqual({ status: "manual", ruleset: { description: "x", query: {}, process: {} } }); - expect(out.desired[0]?.fields.dynamic).toEqual({ status: "manual", ruleset: { description: "x", query: {}, process: {} } }); + expect(actual.get("g")?.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", query: {}, process: {} }, + }); + expect(out.desired[0]?.fields.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", query: {}, process: {} }, + }); }); it("ignores groups that did not opt into dynamic", async () => { - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [{ type: "group", key: "g", fields: { name: "G" }, dependsOn: [] }]; const client = { get: vi.fn() }; @@ -607,21 +710,41 @@ describe("dynamic synthetic field — apply", () => { it("PUTs the wrapped ruleset then the status", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; - await dynamicField().apply({ client: { request }, state, id: 5, - change: { field: "dynamic", from: undefined, - to: { status: "active", ruleset: { description: "x", query: {}, process: {} } } } }); - expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", - { dynamicGroupRuleSet: { description: "x", query: {}, process: {} } }); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "active" }); + await dynamicField().apply({ + client: { request }, + state, + id: 5, + change: { + field: "dynamic", + from: undefined, + to: { status: "active", ruleset: { description: "x", query: {}, process: {} } }, + }, + }); + expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", { + dynamicGroupRuleSet: { description: "x", query: {}, process: {} }, + }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "active", + }); }); it("demotes to a normal group when status is none: DELETE ruleset then status none", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; - await dynamicField().apply({ client: { request }, state, id: 5, - change: { field: "dynamic", from: { status: "active", ruleset: {} }, to: { status: "none", ruleset: {} } } }); + await dynamicField().apply({ + client: { request }, + state, + id: 5, + change: { + field: "dynamic", + from: { status: "active", ruleset: {} }, + to: { status: "none", ruleset: {} }, + }, + }); expect(request).toHaveBeenNthCalledWith(1, "DELETE", "/dynamicgroups/5/ruleset"); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "none" }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "none", + }); }); }); ``` @@ -634,6 +757,7 @@ Expected: FAIL — no `dynamic` entry registered. - [ ] **Step 3: Add `resolveRulesetRef` to `dynamic.ts`** Append to `src/engine/dynamic.ts`: + ```ts import { readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; @@ -647,11 +771,13 @@ export function resolveRulesetRef(ruleset: unknown, baseDir: string = process.cw return ruleset; } ``` + (`dirname` import is for future per-config-file resolution; if unused now, omit it to satisfy `no-unused-vars`.) - [ ] **Step 4: Register the `dynamic` field in `synthetic.ts`** Add to `src/engine/synthetic.ts`: + ```ts import { normalizeDynamic, normalizeRuleset, resolveRulesetRef } from "./dynamic.js"; import type { DynamicStatus } from "./types.js"; @@ -659,7 +785,9 @@ import type { DynamicStatus } from "./types.js"; const dynamicField: SyntheticField = { field: "dynamic", async fold({ client, state, desired, actual }) { - const optedIn = new Set(desired.filter((d) => d.type === "group" && d.dynamic !== undefined).map((d) => d.key)); + const optedIn = new Set( + desired.filter((d) => d.type === "group" && d.dynamic !== undefined).map((d) => d.key), + ); if (optedIn.size === 0) return { desired, errors: [] }; const errors: string[] = []; for (const managed of Object.values(state.resources)) { @@ -668,7 +796,9 @@ const dynamicField: SyntheticField = { if (!a) continue; // vanished from CT → handled as a recreate by the plain plan try { const ruleset = await client.get>(`/dynamicgroups/${managed.id}/ruleset`); - const statusRes = await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${managed.id}/status`); + const statusRes = await client.get<{ dynamicGroupStatus?: string }>( + `/dynamicgroups/${managed.id}/status`, + ); a.dynamic = { status: (statusRes?.dynamicGroupStatus ?? "none") as DynamicStatus, ruleset: normalizeRuleset(ruleset), @@ -680,7 +810,16 @@ const dynamicField: SyntheticField = { } const augmented = desired.map((d) => d.type === "group" && d.dynamic !== undefined - ? { ...d, fields: { ...d.fields, dynamic: normalizeDynamic({ status: d.dynamic.status, ruleset: resolveRulesetRef(d.dynamic.ruleset) }) } } + ? { + ...d, + fields: { + ...d.fields, + dynamic: normalizeDynamic({ + status: d.dynamic.status, + ruleset: resolveRulesetRef(d.dynamic.ruleset), + }), + }, + } : d, ); return { desired: augmented, errors }; @@ -703,6 +842,7 @@ const dynamicField: SyntheticField = { export const SYNTHETIC_FIELDS: SyntheticField[] = [parentsField, dynamicField]; ``` + (Replace the previous single-entry `SYNTHETIC_FIELDS` line.) - [ ] **Step 5: Run to verify it passes** @@ -727,16 +867,19 @@ git commit -m "feat(dynamic): synthetic-field fold + apply for ruleset & status" ## Task 6: End-to-end plan/apply + opt-in live round-trip test **Files:** + - Modify: `src/commands/apply.ts` (add `--refresh` flag → per-group `POST /dynamicgroups/{id}/refresh` after a successful apply of a changed dynamic group) - Test: `tests/dynamic.integration.test.ts` (opt-in, `CT_LIVE=1`) **Interfaces:** + - Consumes: `buildPlan`, `executePlan`, the `dynamic` synthetic field. - Produces: `ct apply --refresh` behavior; a green round-trip proof. - [ ] **Step 1: Write the opt-in live round-trip test** `tests/dynamic.integration.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; @@ -784,12 +927,15 @@ if (opts.refresh) { const id = state.resources[item.key]?.id; if (id === undefined) continue; const res = await client.request>( - "POST", `/dynamicgroups/${id}/refresh`); + "POST", + `/dynamicgroups/${id}/refresh`, + ); const r = res?.[0]; if (r) info(`refreshed ${item.key}: +${r.created} ~${r.updated} -${r.deleted}`); } } ``` + (Use the file's existing logging helper in place of `info`.) - [ ] **Step 4: Add a unit test for the refresh gating** (in `tests/apply.test.ts` or a new `tests/apply-refresh.test.ts`) asserting `POST /dynamicgroups/{id}/refresh` is called once per changed dynamic group and **never** `/dynamicgroups/refresh`, and not at all without `--refresh`. @@ -811,18 +957,26 @@ git commit -m "feat(dynamic): end-to-end apply, opt-in --refresh, live round-tri ## Task 7: Typed query DSL (Phase 2) **Files:** + - Create: `src/config/query.ts` (the `q.*` builder + resolver hook) - Modify: `src/config/context.ts` (expose `q` and a `campus()`/`group()` key→id resolver on the context, or export `q` standalone) - Test: `tests/query.test.ts` **Interfaces:** + - Produces: ```ts - export interface QueryNode { [op: string]: unknown } + export interface QueryNode { + [op: string]: unknown; + } export const q: { - and(...n: QueryNode[]): QueryNode; or(...n: QueryNode[]): QueryNode; not(n: QueryNode): QueryNode; - eq(varName: string, value: unknown): QueryNode; oneof(varName: string, values: unknown[]): QueryNode; - isnull(varName: string): QueryNode; var(name: string): QueryNode; + and(...n: QueryNode[]): QueryNode; + or(...n: QueryNode[]): QueryNode; + not(n: QueryNode): QueryNode; + eq(varName: string, value: unknown): QueryNode; + oneof(varName: string, values: unknown[]): QueryNode; + isnull(varName: string): QueryNode; + var(name: string): QueryNode; }; export function churchQuery(filter: QueryNode, opts?: { description?: string }): Record; ``` @@ -833,6 +987,7 @@ git commit -m "feat(dynamic): end-to-end apply, opt-in --refresh, live round-tri - [ ] **Step 1: Write the failing test** `tests/query.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { q, churchQuery } from "../src/config/query.js"; @@ -841,25 +996,35 @@ import { normalizeRuleset } from "../src/engine/dynamic.js"; describe("typed query builder", () => { it("builds a JSONLogic tree", () => { const tree = q.and(q.eq("ctgroup.campusId", 1), q.eq("person.isArchived", false)); - expect(tree).toEqual({ and: [ - { "==": [{ var: "ctgroup.campusId" }, 1] }, - { "==": [{ var: "person.isArchived" }, false] }, - ] }); + expect(tree).toEqual({ + and: [{ "==": [{ var: "ctgroup.campusId" }, 1] }, { "==": [{ var: "person.isArchived" }, false] }], + }); }); it("oneof and isnull", () => { - expect(q.oneof("ctgroup.groupTypeId", [1, 2])).toEqual({ oneof: [{ var: "ctgroup.groupTypeId" }, [1, 2]] }); + expect(q.oneof("ctgroup.groupTypeId", [1, 2])).toEqual({ + oneof: [{ var: "ctgroup.groupTypeId" }, [1, 2]], + }); expect(q.isnull("person.isArchived")).toEqual({ isnull: [{ var: "person.isArchived" }] }); }); it("churchQuery wraps the filter in the ChurchQuery envelope", () => { const cq = churchQuery(q.eq("ctgroup.campusId", 1), { description: "Mainz" }); - expect(cq).toEqual({ description: "Mainz", method: "ChurchQuery", params: { filter: { "==": [{ var: "ctgroup.campusId" }, 1] } } }); + expect(cq).toEqual({ + description: "Mainz", + method: "ChurchQuery", + params: { filter: { "==": [{ var: "ctgroup.campusId" }, 1] } }, + }); }); it("a built ruleset normalizes stably (matches read-back normalization)", () => { - const ruleset = { description: "x", importance: 0, personIdFieldName: "id", - process: {}, query: churchQuery(q.eq("ctgroup.campusId", 1)) }; + const ruleset = { + description: "x", + importance: 0, + personIdFieldName: "id", + process: {}, + query: churchQuery(q.eq("ctgroup.campusId", 1)), + }; expect(normalizeRuleset(normalizeRuleset(ruleset))).toEqual(normalizeRuleset(ruleset)); }); }); @@ -878,7 +1043,9 @@ Expected: FAIL — module missing. * tree that lives inside the ChurchQuery `params.filter`. `var` values are raw * ChurchTools ids — resolve keys → ids at config-build time and pass the number. */ -export interface QueryNode { [op: string]: unknown } +export interface QueryNode { + [op: string]: unknown; +} export const q = { and: (...n: QueryNode[]): QueryNode => ({ and: n }), @@ -917,6 +1084,7 @@ git commit -m "feat(dynamic): typed query builder + ChurchQuery envelope (Phase ## Task 8: Docs + example config **Files:** + - Modify: `README.md` (add an "Auto-groups" subsection under usage/status) - Create: `docs/dynamic-groups.md` (the full feature guide) - Create: `examples/dynamic-group.config.ts` (a runnable example config) @@ -932,13 +1100,19 @@ import { q, churchQuery } from "../src/config/query.js"; // or the published imp export default (ct: any) => { ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); ct.group({ - key: "all_mainz", name: "Alle Mainz", groupTypeId: 1, + key: "all_mainz", + name: "Alle Mainz", + groupTypeId: 1, dynamic: { status: "manual", ruleset: { description: "Alle aktiven Personen in Mainz", // description lives on the ruleset, NOT inside query - importance: 0, personIdFieldName: "person.id", process: {}, - query: churchQuery(q.and(q.eq("ctgroup.campusId", 0 /* mainz id */), q.eq("person.isArchived", false))), + importance: 0, + personIdFieldName: "person.id", + process: {}, + query: churchQuery( + q.and(q.eq("ctgroup.campusId", 0 /* mainz id */), q.eq("person.isArchived", false)), + ), }, }, }); @@ -965,6 +1139,7 @@ git commit -m "docs(dynamic): auto-groups guide + runnable example config" ## Self-Review **Spec coverage (against the #14 section of the design spec):** + - DSL `dynamic` block on a group → Task 3. ✓ - `ct plan` shows ruleset/status create/update/delete with a normalizer avoiding cosmetic false diffs → Tasks 4 (normalizer) + 5 (fold makes it a diffable field). ✓ - `ct apply` writes ruleset + status in the right order (group first); re-run is a no-op → Task 5 (apply order) + group tier 1 means the group item owns the write, exactly like `parents` (owner exists before the synthetic write). ✓ diff --git a/docs/superpowers/plans/2026-07-08-blueprints.md b/docs/superpowers/plans/2026-07-08-blueprints.md index 6eba4c3..f527121 100644 --- a/docs/superpowers/plans/2026-07-08-blueprints.md +++ b/docs/superpowers/plans/2026-07-08-blueprints.md @@ -9,6 +9,7 @@ **Tech Stack:** TypeScript (NodeNext ESM, `.js` specifiers), Vitest. ## Global Constraints + - Node ≥ 20; ESM NodeNext — all relative imports use `.js` specifiers. - Add no new runtime dependency. - People are never managed. @@ -16,6 +17,7 @@ - The example is verified by config-LOAD only (no live `plan`/`apply`; the machine is on production). ## Context from #14 / #13 (already merged on this branch) + - `dynamic: { status, ruleset }` block on a group; `q`/`churchQuery` typed query builder re-exported from `src/config/context.js`. - `ct.groupTypeRole({ key, id, grants })` with `module:right` grant names; `ct.groupRole({ key, id, grants })`. - `parents: [groupKey]` opt-in hierarchy; `evaluateConfig(mod) → { resources, permissions }`; `orderKeys(resources)` (`src/engine/graph.ts`) topologically orders by tier then declaration order. @@ -25,9 +27,11 @@ ## Task 1: Example campus blueprint config **Files:** + - Create: `examples/campus-blueprint.config.ts` **Interfaces:** + - Produces: a default-exported `(ct) => void` config that defines a `kidsArea(campus)` blueprint and instantiates it across two campuses. Consumed by Task 2's test and by users as a reference. - [ ] **Step 1: Write the blueprint example** @@ -47,7 +51,11 @@ const CAMPUSES = ["mainz", "berlin"] as const; function kidsArea(ct: ConfigContext, campus: string): void { const lead = `${campus}_kids_lead`; ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupTypeId: 2, parents: [] }); - for (const [suffix, label] of [["0_3", "0–3"], ["4_6", "4–6"], ["checkin", "Check-in"]] as const) { + for (const [suffix, label] of [ + ["0_3", "0–3"], + ["4_6", "4–6"], + ["checkin", "Check-in"], + ] as const) { ct.group({ key: `${campus}_kids_${suffix}`, name: `${campus} · Kids ${label}`, @@ -80,16 +88,22 @@ export default (ct: ConfigContext): void => { kidsArea(ct, campus); } // A permission grant (#13) on a shared group-type-role template — id is an illustrative placeholder. - ct.groupTypeRole({ key: "kids_lead_tpl", id: 2, grants: ["churchgroup:view group", "churchgroup:edit group members"] }); + ct.groupTypeRole({ + key: "kids_lead_tpl", + id: 2, + grants: ["churchgroup:view group", "churchgroup:edit group members"], + }); }; ``` - [ ] **Step 2: Verify it loads (config-load only — NO live plan/apply)** Run: + ```bash npx tsx -e "import('./src/config/load.js').then(m=>m.loadConfig('examples/campus-blueprint.config.ts')).then(r=>console.log('resources',r.resources.length,'permissions',r.permissions.length)).catch(e=>{console.error(e.message);process.exit(1)})" ``` + Expected: `resources 12 permissions 1` (2 campuses + 2×5 groups [lead + 3 teams + 1 dynamic] = 12 resources; 1 permission). No load/validation error. Do NOT run `ct plan`/`ct apply` (production login). - [ ] **Step 3: Commit** @@ -104,9 +118,11 @@ git commit -m "feat(blueprints): example parametrized campus blueprint (kids are ## Task 2: Ordered-plan test (the DoD lock) **Files:** + - Create: `tests/blueprint.test.ts` **Interfaces:** + - Consumes: `evaluateConfig` (`src/config/context.js`), `orderKeys` (`src/engine/graph.js`), and the Task 1 blueprint pattern (the test defines its own inline blueprint so it does not depend on the example file's exact ids). - [ ] **Step 1: Write the test** @@ -137,7 +153,7 @@ describe("campus blueprint", () => { const order = orderKeys(resources); const pos = (k: string) => order.indexOf(k); for (const c of ["mainz", "berlin"]) { - expect(pos(c)).toBeLessThan(pos(`${c}_lead`)); // campus (tier 0) before its groups (tier 1) + expect(pos(c)).toBeLessThan(pos(`${c}_lead`)); // campus (tier 0) before its groups (tier 1) expect(pos(`${c}_lead`)).toBeLessThan(pos(`${c}_team`)); // parent before child (intra-tier dependency) } }); @@ -168,6 +184,7 @@ git commit -m "test(blueprints): lock ordered-plan guarantee for a blueprint acr ## Task 3: Docs + README **Files:** + - Create: `docs/blueprints.md` - Modify: `README.md` @@ -191,6 +208,7 @@ git commit -m "docs(blueprints): parametrized campus blueprint guide; Phase 5 co ## Self-Review **Spec coverage (#7 design + DoD):** + - Blueprint abstraction (TS function parametrized by campus) → Task 1 (`kidsArea`), Task 2 (inline blueprint). ✓ - Reusable building blocks shared across blueprints → the `kidsArea` function + shared group type/status ids; no new abstraction needed (YAGNI). ✓ - Auto-groups inside a blueprint → Task 1 (`dynamic` block in `kidsArea`). ✓ (dynamic reconciliation idempotency is covered by #14's tests + normalizer.) diff --git a/docs/superpowers/plans/2026-07-08-permissions.md b/docs/superpowers/plans/2026-07-08-permissions.md index dcac188..6973f89 100644 --- a/docs/superpowers/plans/2026-07-08-permissions.md +++ b/docs/superpowers/plans/2026-07-08-permissions.md @@ -34,21 +34,29 @@ ## Task 1: Catalog resolver **Files:** + - Create: `src/permissions/catalog.ts` - Test: `tests/permission-catalog.test.ts` **Interfaces:** + - Produces: ```ts - export interface CatalogEntry { authId: number; scopeField: string | null; revocable: boolean; desc: string } - export function loadCatalog(): Record; // reads catalog.json once (memoized) - export function resolveAuthId(name: string): CatalogEntry; // throws with suggestions if unknown + export interface CatalogEntry { + authId: number; + scopeField: string | null; + revocable: boolean; + desc: string; + } + export function loadCatalog(): Record; // reads catalog.json once (memoized) + export function resolveAuthId(name: string): CatalogEntry; // throws with suggestions if unknown ``` - Consumes: the committed `src/permissions/catalog.json`. - [ ] **Step 1: Write the failing test** `tests/permission-catalog.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { resolveAuthId, loadCatalog } from "../src/permissions/catalog.js"; @@ -65,7 +73,9 @@ describe("permission catalog", () => { expect(e.scopeField).toBe("cdb_gruppe"); }); it("throws a helpful error for an unknown right", () => { - expect(() => resolveAuthId("churchgroup:no such right")).toThrow(/unknown permission "churchgroup:no such right"/i); + expect(() => resolveAuthId("churchgroup:no such right")).toThrow( + /unknown permission "churchgroup:no such right"/i, + ); }); it("loads the whole catalog (187 rights)", () => { expect(Object.keys(loadCatalog()).length).toBeGreaterThanOrEqual(180); @@ -90,7 +100,12 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -export interface CatalogEntry { authId: number; scopeField: string | null; revocable: boolean; desc: string } +export interface CatalogEntry { + authId: number; + scopeField: string | null; + revocable: boolean; + desc: string; +} let cache: Record | null = null; @@ -105,13 +120,16 @@ export function resolveAuthId(name: string): CatalogEntry { const entry = loadCatalog()[name]; if (!entry) { const [mod] = name.split(":"); - const near = Object.keys(loadCatalog()).filter((k) => k.startsWith(`${mod}:`)).slice(0, 6); + const near = Object.keys(loadCatalog()) + .filter((k) => k.startsWith(`${mod}:`)) + .slice(0, 6); const hint = near.length ? ` Did you mean one of: ${near.join(", ")}?` : ""; throw new Error(`Unknown permission "${name}".${hint}`); } return entry; } ``` + (`catalog.ts` compiles to `dist/permissions/`; ensure the build copies `catalog.json` next to it — see Task 8's build note. During `tsx`/vitest it reads from `src/permissions/`.) - [ ] **Step 4: Run to verify it passes** @@ -131,25 +149,41 @@ git commit -m "feat(permissions): catalog resolver (name→authId, scope, revoca ## Task 2: Grant-tuple model + set reconciliation **Files:** + - Create: `src/permissions/grants.ts` - Test: `tests/permission-grants.test.ts` **Interfaces:** + - Produces: ```ts export type DomainType = "group_role" | "group_type_role"; - export interface GrantTuple { authId: number; dataId: number[]; type: "grant" | "revoke" } // dataId sorted; [] = unscoped - export interface RawPermission { authId: number; dataId: number | null; type: "grant" | "revoke"; isInherited?: boolean; meta?: { modifiedPid?: number } } - export function tupleKey(t: { authId: number; dataId: number[]; type: string }): string; // stable identity - export function normalizeActual(rows: RawPermission[]): GrantTuple[]; // exclude baseline+inherited; scalar dataId→[] - export interface GrantDiff { toPut: GrantTuple[]; toDelete: GrantTuple[] } - export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDiff; // set reconciliation + export interface GrantTuple { + authId: number; + dataId: number[]; + type: "grant" | "revoke"; + } // dataId sorted; [] = unscoped + export interface RawPermission { + authId: number; + dataId: number | null; + type: "grant" | "revoke"; + isInherited?: boolean; + meta?: { modifiedPid?: number }; + } + export function tupleKey(t: { authId: number; dataId: number[]; type: string }): string; // stable identity + export function normalizeActual(rows: RawPermission[]): GrantTuple[]; // exclude baseline+inherited; scalar dataId→[] + export interface GrantDiff { + toPut: GrantTuple[]; + toDelete: GrantTuple[]; + } + export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDiff; // set reconciliation ``` - Consumes: nothing from other tasks. - [ ] **Step 1: Write the failing test** `tests/permission-grants.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { normalizeActual, diffGrants, tupleKey } from "../src/permissions/grants.js"; @@ -160,7 +194,7 @@ describe("normalizeActual", () => { { authId: 1104, dataId: 3, type: "grant" as const, meta: { modifiedPid: 1 } }, { authId: 1101, dataId: null, type: "grant" as const, meta: { modifiedPid: 1 } }, { authId: 9999, dataId: 1, type: "grant" as const, meta: { modifiedPid: -1 } }, // system baseline → excluded - { authId: 8888, dataId: 1, type: "grant" as const, isInherited: true }, // inherited → excluded + { authId: 8888, dataId: 1, type: "grant" as const, isInherited: true }, // inherited → excluded ]; expect(normalizeActual(rows)).toEqual([ { authId: 1104, dataId: [3], type: "grant" }, @@ -173,11 +207,11 @@ describe("diffGrants", () => { it("adds missing, deletes extra, no-ops identical (order-independent dataId)", () => { const desired = [ { authId: 1104, dataId: [7, 3], type: "grant" as const }, // present but reordered - { authId: 1101, dataId: [], type: "grant" as const }, // new + { authId: 1101, dataId: [], type: "grant" as const }, // new ]; const actual = [ { authId: 1104, dataId: [3, 7], type: "grant" as const }, // same tuple, different order - { authId: 2000, dataId: [], type: "grant" as const }, // extra → delete + { authId: 2000, dataId: [], type: "grant" as const }, // extra → delete ]; const d = diffGrants(desired, actual); expect(d.toPut.map(tupleKey)).toEqual([tupleKey({ authId: 1101, dataId: [], type: "grant" })]); @@ -201,10 +235,17 @@ Expected: FAIL — module missing. */ export type DomainType = "group_role" | "group_type_role"; -export interface GrantTuple { authId: number; dataId: number[]; type: "grant" | "revoke" } +export interface GrantTuple { + authId: number; + dataId: number[]; + type: "grant" | "revoke"; +} export interface RawPermission { - authId: number; dataId: number | null; type: "grant" | "revoke"; - isInherited?: boolean; meta?: { modifiedPid?: number }; + authId: number; + dataId: number | null; + type: "grant" | "revoke"; + isInherited?: boolean; + meta?: { modifiedPid?: number }; } export function tupleKey(t: { authId: number; dataId: number[]; type: string }): string { @@ -215,14 +256,17 @@ export function normalizeActual(rows: RawPermission[]): GrantTuple[] { const out: GrantTuple[] = []; for (const r of rows) { if (r.meta?.modifiedPid === -1) continue; // system baseline — invisible to reconciliation - if (r.isInherited) continue; // inherited — not directly owned here + if (r.isInherited) continue; // inherited — not directly owned here const dataId = r.dataId == null ? [] : [r.dataId]; out.push({ authId: r.authId, dataId: dataId.sort((a, b) => a - b), type: r.type }); } return out; } -export interface GrantDiff { toPut: GrantTuple[]; toDelete: GrantTuple[] } +export interface GrantDiff { + toPut: GrantTuple[]; + toDelete: GrantTuple[]; +} export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDiff { const desiredKeys = new Map(desired.map((t) => [tupleKey(t), t])); @@ -250,16 +294,23 @@ git commit -m "feat(permissions): grant-tuple model + set reconciliation (baseli ## Task 3: DSL — `ct.groupRole` / `ct.groupTypeRole` **Files:** + - Modify: `src/config/context.ts` (add the two declarations) - Create: `src/permissions/types.ts` (the desired-permission shape) - Test: `tests/context.test.ts` (extend) **Interfaces:** + - Produces: ```ts // src/permissions/types.ts export type Grant = string | { right: string; scope: string[] }; - export interface DesiredPermission { key: string; domainType: DomainType; domainId: number; grants: Grant[] } + export interface DesiredPermission { + key: string; + domainType: DomainType; + domainId: number; + grants: Grant[]; + } // ConfigContext gains: groupRole(input), groupTypeRole(input) // evaluateConfig now returns BOTH resource declarations and permission declarations. ``` @@ -273,10 +324,11 @@ git commit -m "feat(permissions): grant-tuple model + set reconciliation (baseli describe("permission declarations", () => { it("collects groupRole / groupTypeRole with validated grants", async () => { const mod = (ct: any) => { - ct.groupTypeRole({ key: "leiter_tpl", id: 8, grants: [ - "churchgroup:view group", - { right: "churchdb:view group", scope: ["kids_area"] }, - ]}); + ct.groupTypeRole({ + key: "leiter_tpl", + id: 8, + grants: ["churchgroup:view group", { right: "churchdb:view group", scope: ["kids_area"] }], + }); ct.groupRole({ key: "kids_lead", id: 2882, grants: ["churchgroup:edit group members"] }); }; const { permissions } = await evaluateConfig(mod); // evaluateConfig now returns {resources, permissions} @@ -285,13 +337,16 @@ describe("permission declarations", () => { expect(permissions[1]).toMatchObject({ key: "kids_lead", domainType: "group_role", domainId: 2882 }); }); it("rejects a non-numeric id and an empty right name", async () => { - await expect(evaluateConfig((ct: any) => ct.groupRole({ key: "x", id: "nope", grants: [] }))) - .rejects.toThrow(/id.*number/i); - await expect(evaluateConfig((ct: any) => ct.groupRole({ key: "x", id: 1, grants: [""] }))) - .rejects.toThrow(/grant/i); + await expect( + evaluateConfig((ct: any) => ct.groupRole({ key: "x", id: "nope", grants: [] })), + ).rejects.toThrow(/id.*number/i); + await expect( + evaluateConfig((ct: any) => ct.groupRole({ key: "x", id: 1, grants: [""] })), + ).rejects.toThrow(/grant/i); }); }); ``` + (Adjust the existing `evaluateConfig` import/usages in this file to the new `{ resources, permissions }` return.) - [ ] **Step 2: Run to verify it fails** @@ -304,8 +359,10 @@ Expected: FAIL — `groupRole`/`groupTypeRole` not defined; `evaluateConfig` ret Create `src/permissions/types.ts` with the interfaces above. In `src/config/context.ts`: + - Add a `permissions: DesiredPermission[]` accumulator alongside `resources` in `createContext`. - Add `groupRole` and `groupTypeRole` to `ConfigContext` and the returned `ct`: + ```ts const definePermission = (domainType: DomainType) => (input: { key: string; id: number; grants: Grant[] }): void => { if (typeof input.key !== "string" || !input.key) throw new Error(`${domainType} declaration missing a string "key".`); @@ -324,7 +381,9 @@ In `src/config/context.ts`: groupRole: definePermission("group_role"), groupTypeRole: definePermission("group_type_role"), ``` + (Share the existing `seen` set so keys stay globally unique across resources and permissions.) + - Change `createContext` to return `{ ct, resources, permissions }` and `evaluateConfig` to return `{ resources, permissions }`. Update `validateReferences` call site accordingly. In `src/config/load.ts`: `loadConfig` returns `{ resources, permissions }`. Update `src/commands/plan.ts` and `src/commands/apply.ts` to destructure (they currently do `const desired = await loadConfig(...)`) — thread `permissions` through (used in Task 5/6). Keep existing resource behavior identical. @@ -346,13 +405,15 @@ git commit -m "feat(permissions): groupRole/groupTypeRole DSL + threaded permiss ## Task 4: Scope resolver (scope keys → dataId[]) **Files:** + - Create: `src/permissions/scope.ts` - Test: `tests/permission-scope.test.ts` **Interfaces:** + - Produces: ```ts - export function resolveScope(scopeKeys: string[], state: State): number[]; // logical group keys → group ids, sorted + export function resolveScope(scopeKeys: string[], state: State): number[]; // logical group keys → group ids, sorted ``` - Consumes: `State` (`src/state/state.js`). @@ -361,15 +422,20 @@ git commit -m "feat(permissions): groupRole/groupTypeRole DSL + threaded permiss - [ ] **Step 1: Write the failing test** `tests/permission-scope.test.ts`: + ```ts import { describe, it, expect } from "vitest"; import { resolveScope } from "../src/permissions/scope.js"; import type { State } from "../src/state/state.js"; -const state: State = { version: 1, 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" }, -}}; +const state: State = { + version: 1, + 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" }, + }, +}; describe("resolveScope", () => { it("maps managed group keys to sorted ids", () => { @@ -397,7 +463,9 @@ export function resolveScope(scopeKeys: string[], state: State): number[] { for (const key of scopeKeys) { const m = state.resources[key]; if (!m || m.type !== "group") { - throw new Error(`Scope key "${key}" does not resolve to a managed group. Declare/adopt it, or use a group already under management.`); + throw new Error( + `Scope key "${key}" does not resolve to a managed group. Declare/adopt it, or use a group already under management.`, + ); } ids.push(m.id); } @@ -422,21 +490,31 @@ git commit -m "feat(permissions): scope-key → group dataId resolver" ## Task 5: Build the permission plan (desired tuples + actuals + diff + validation) **Files:** + - Create: `src/permissions/plan.ts` - Test: `tests/permission-plan.test.ts` **Interfaces:** + - Produces: ```ts - export interface PermissionPlanItem { key: string; domainType: DomainType; domainId: number; diff: GrantDiff } - export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[]; // resolve names+scope, validate domain rules + export interface PermissionPlanItem { + key: string; + domainType: DomainType; + domainId: number; + diff: GrantDiff; + } + export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[]; // resolve names+scope, validate domain rules export async function buildPermissionPlan( - client: Pick, state: State, permissions: DesiredPermission[], + client: Pick, + state: State, + permissions: DesiredPermission[], ): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[] }>; ``` - Consumes: `resolveAuthId` (T1), `normalizeActual`/`diffGrants`/`GrantTuple` (T2), `resolveScope` (T4), `DesiredPermission`/`DomainType` (T3). **Domain-rule validation in `desiredTuples`** (throw a clear error): + - A scoped grant (`{right, scope}`) requires the catalog entry's `scopeField` to be non-null; a bare-string grant on a right whose `scopeField` is non-null is allowed (means "unscoped/all") — permitted. - `type: "revoke"` is only produced for `group_role` (MVP only emits `grant`; revoke support is a later extension — do NOT emit revoke here, but keep the tuple `type` field). - `group_type_role` + `authId >= 10000` → throw (write rule: those domains require authId < 10000). @@ -446,44 +524,77 @@ git commit -m "feat(permissions): scope-key → group dataId resolver" - [ ] **Step 1: Write the failing test** `tests/permission-plan.test.ts`: + ```ts import { describe, it, expect, vi } from "vitest"; import { desiredTuples, buildPermissionPlan } from "../src/permissions/plan.js"; import type { State } from "../src/state/state.js"; -const state: State = { version: 1, host: "h", resources: { - kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, -}}; +const state: State = { + version: 1, + host: "h", + resources: { + kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, +}; describe("desiredTuples", () => { it("resolves names and scope to tuples", () => { const tuples = desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - "churchgroup:view group", // authId 1104, unscoped - { right: "churchgroup:view group", scope: ["kids_area"] }, // authId 1104, dataId [42] - ]}, state); + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [ + "churchgroup:view group", // authId 1104, unscoped + { right: "churchgroup:view group", scope: ["kids_area"] }, // authId 1104, dataId [42] + ], + }, + state, + ); expect(tuples).toEqual([ { authId: 1104, dataId: [], type: "grant" }, { authId: 1104, dataId: [42], type: "grant" }, ]); }); it("rejects authId >= 10000 on group_type_role", () => { - expect(() => desiredTuples({ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchdb:+see persons"] }, state)) - .toThrow(/10000/); // churchdb:+see persons is authId 10101 + expect(() => + desiredTuples( + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchdb:+see persons"] }, + state, + ), + ).toThrow(/10000/); // churchdb:+see persons is authId 10101 }); }); describe("buildPermissionPlan", () => { it("diffs desired vs actual (bulk fetch filtered to managed domainIds)", async () => { - const client = { get: vi.fn(async () => [ - { domainType: "group_type_role", domainId: 8, authId: 1104, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, - { domainType: "group_type_role", domainId: 99, authId: 1, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, // unmanaged domainId → ignored - ]) }; - const { items, fetchErrors } = await buildPermissionPlan(client as never, state, - [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:view group"] }]); + const client = { + get: vi.fn(async () => [ + { + domainType: "group_type_role", + domainId: 8, + authId: 1104, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, + { + domainType: "group_type_role", + domainId: 99, + authId: 1, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, // unmanaged domainId → ignored + ]), + }; + const { items, fetchErrors } = await buildPermissionPlan(client as never, state, [ + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:view group"] }, + ]); expect(fetchErrors).toEqual([]); - expect(items[0].diff.toPut).toEqual([]); // 1104 unscoped already present - expect(items[0].diff.toDelete).toEqual([]); // domainId 99 is unmanaged → invisible + expect(items[0].diff.toPut).toEqual([]); // 1104 unscoped already present + expect(items[0].diff.toDelete).toEqual([]); // domainId 99 is unmanaged → invisible }); }); ``` @@ -501,17 +612,31 @@ import { CtApiError } from "../api/ctClient.js"; import type { State } from "../state/state.js"; import { resolveAuthId } from "./catalog.js"; import { resolveScope } from "./scope.js"; -import { normalizeActual, diffGrants, type GrantTuple, type GrantDiff, type DomainType, type RawPermission } from "./grants.js"; +import { + normalizeActual, + diffGrants, + type GrantTuple, + type GrantDiff, + type DomainType, + type RawPermission, +} from "./grants.js"; import type { DesiredPermission } from "./types.js"; -export interface PermissionPlanItem { key: string; domainType: DomainType; domainId: number; diff: GrantDiff } +export interface PermissionPlanItem { + key: string; + domainType: DomainType; + domainId: number; + diff: GrantDiff; +} export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[] { return p.grants.map((g) => { const name = typeof g === "string" ? g : g.right; const entry = resolveAuthId(name); - if ((p.domainType === "group_type_role") && entry.authId >= 10000) { - throw new Error(`${p.domainType} "${p.key}": "${name}" (authId ${entry.authId}) is not writable — ${p.domainType} requires authId < 10000.`); + if (p.domainType === "group_type_role" && entry.authId >= 10000) { + throw new Error( + `${p.domainType} "${p.key}": "${name}" (authId ${entry.authId}) is not writable — ${p.domainType} requires authId < 10000.`, + ); } const dataId = typeof g === "string" ? [] : resolveScope(g.scope, state); return { authId: entry.authId, dataId, type: "grant" as const }; @@ -519,7 +644,9 @@ export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[] } export async function buildPermissionPlan( - client: Pick, state: State, permissions: DesiredPermission[], + client: Pick, + state: State, + permissions: DesiredPermission[], ): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[] }> { const items: PermissionPlanItem[] = []; const fetchErrors: string[] = []; @@ -538,11 +665,17 @@ export async function buildPermissionPlan( const all = byType.get(p.domainType); if (all == null) continue; // fetch failed for this domainType — recorded above const actual = normalizeActual(all.filter((r) => (r as { domainId?: number }).domainId === p.domainId)); - items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state), actual) }); + items.push({ + key: p.key, + domainType: p.domainType, + domainId: p.domainId, + diff: diffGrants(desiredTuples(p, state), actual), + }); } return { items, fetchErrors }; } ``` + (Add `domainId` to `RawPermission` in `grants.ts` — `domainId: number` — so the filter typechecks.) - [ ] **Step 4: Run to verify it passes** @@ -562,15 +695,20 @@ git commit -m "feat(permissions): build permission plan (tuples, bulk actuals, m ## Task 6: Apply permissions + render + wire into commands + catalog helper **Files:** + - Create: `src/permissions/apply.ts`, `src/permissions/render.ts` - Modify: `src/commands/plan.ts`, `src/commands/apply.ts` (render + execute the permission plan) - Modify: `src/commands/get.ts` (add `permissions-catalog`) - Test: `tests/permission-apply.test.ts` **Interfaces:** + - Produces: ```ts - export async function applyPermissionPlan(items: PermissionPlanItem[], client: Pick): Promise<{ granted: number; deleted: number }>; + export async function applyPermissionPlan( + items: PermissionPlanItem[], + client: Pick, + ): Promise<{ granted: number; deleted: number }>; export function renderPermissionPlan(items: PermissionPlanItem[]): string; ``` - Consumes: `PermissionPlanItem` (T5), `assertNotPeople`. @@ -582,6 +720,7 @@ git commit -m "feat(permissions): build permission plan (tuples, bulk actuals, m - [ ] **Step 1: Write the failing test** `tests/permission-apply.test.ts`: + ```ts import { describe, it, expect, vi } from "vitest"; import { applyPermissionPlan } from "../src/permissions/apply.js"; @@ -589,17 +728,37 @@ import { applyPermissionPlan } from "../src/permissions/apply.js"; describe("applyPermissionPlan", () => { it("PUTs each grant and DELETEs each removed tuple with the array dataId body", async () => { const request = vi.fn(async () => ({})); - const res = await applyPermissionPlan([{ - key: "t", domainType: "group_type_role", domainId: 8, - diff: { - toPut: [{ authId: 1104, dataId: [42], type: "grant" }, { authId: 1101, dataId: [], type: "grant" }], - toDelete: [{ authId: 2000, dataId: [], type: "grant" }], - }, - }], { request } as never); + const res = await applyPermissionPlan( + [ + { + key: "t", + domainType: "group_type_role", + domainId: 8, + diff: { + toPut: [ + { authId: 1104, dataId: [42], type: "grant" }, + { authId: 1101, dataId: [], type: "grant" }, + ], + toDelete: [{ authId: 2000, dataId: [], type: "grant" }], + }, + }, + ], + { request } as never, + ); expect(res).toEqual({ granted: 2, deleted: 1 }); - expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { authId: 1104, dataId: [42], type: "grant" }); - expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { authId: 1101, type: "grant" }); // no dataId when unscoped - expect(request).toHaveBeenCalledWith("DELETE", "/permissions/group_type_role/8", { authId: 2000, type: "grant" }); + expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { + authId: 1104, + dataId: [42], + type: "grant", + }); + expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { + authId: 1101, + type: "grant", + }); // no dataId when unscoped + expect(request).toHaveBeenCalledWith("DELETE", "/permissions/group_type_role/8", { + authId: 2000, + type: "grant", + }); }); }); ``` @@ -625,18 +784,27 @@ function body(t: GrantTuple): Record { } export async function applyPermissionPlan( - items: PermissionPlanItem[], client: Pick, + items: PermissionPlanItem[], + client: Pick, ): Promise<{ granted: number; deleted: number }> { - let granted = 0, deleted = 0; + let granted = 0, + deleted = 0; for (const item of items) { const path = `/permissions/${item.domainType}/${item.domainId}`; assertNotPeople(path); - for (const t of item.diff.toPut) { await client.request("PUT", path, body(t)); granted++; } - for (const t of item.diff.toDelete) { await client.request("DELETE", path, body(t)); deleted++; } + for (const t of item.diff.toPut) { + await client.request("PUT", path, body(t)); + granted++; + } + for (const t of item.diff.toDelete) { + await client.request("DELETE", path, body(t)); + deleted++; + } } return { granted, deleted }; } ``` + `render.ts`: format each item as `group_type_role #8: +2 grant(s), -1 revoke(s)` with per-tuple lines (reuse `picocolors` like `engine/render.ts`). - [ ] **Step 4: Run to verify it passes** @@ -667,11 +835,12 @@ git commit -m "feat(permissions): apply grants (PUT/DELETE) + plan/apply wiring ## Task 7: Live round-trip + domain-rule tests (gated, DEV-only) **Files:** + - Test: `tests/permission.integration.test.ts` (opt-in, `CT_LIVE=1`, **DEV instance only**) - [ ] **Step 1: Write the gated integration test** -`describe.runIf(process.env.CT_LIVE === "1")`: against a **dev** instance, pick a disposable `group_type_role` domainId (`CT_PERM_FIXTURE_ID`), read its grants, and assert `buildPermissionPlan` for a config that declares its *current* user-authored grants is a no-op (`toPut`/`toDelete` empty) — i.e. read→diff is drift-free. Do NOT write in the read-only assertion. A second, explicitly-guarded block may PUT+DELETE a single throwaway grant and assert idempotency, but only when `CT_LIVE_WRITE=1` AND the host is not production. +`describe.runIf(process.env.CT_LIVE === "1")`: against a **dev** instance, pick a disposable `group_type_role` domainId (`CT_PERM_FIXTURE_ID`), read its grants, and assert `buildPermissionPlan` for a config that declares its _current_ user-authored grants is a no-op (`toPut`/`toDelete` empty) — i.e. read→diff is drift-free. Do NOT write in the read-only assertion. A second, explicitly-guarded block may PUT+DELETE a single throwaway grant and assert idempotency, but only when `CT_LIVE_WRITE=1` AND the host is not production. - [ ] **Step 2: Confirm it SKIPS by default** @@ -690,6 +859,7 @@ git commit -m "test(permissions): gated DEV-only round-trip + domain-rule checks ## Task 8: Build asset copy, docs, example **Files:** + - Modify: `package.json` (ensure `catalog.json` ships to `dist/permissions/`) or `tsup.config` — verify the built `ct` binary can read the catalog. If tsup doesn't copy JSON, add a copy step or `import` the JSON so it's bundled. **Verify `node dist/index.js get permissions-catalog` works after build.** - Create: `docs/permissions.md` - Create: `examples/permissions.config.ts` @@ -719,6 +889,7 @@ git commit -m "docs(permissions): guide + example; ensure catalog ships in build ## Self-Review **Spec coverage (#13 section of the design spec):** + - DSL declares named grants on `group_role`/`group_type_role`; names resolve to authId → Tasks 1, 3, 5. ✓ - `ct plan` shows accurate grant create/delete diffs vs actual; unmanaged domain objects invisible → Tasks 5 (managed-guard), 6 (render/wiring). ✓ - `ct apply` reconciles via PUT/DELETE; re-run is a no-op → Tasks 2 (set reconciliation), 6 (apply). ✓ diff --git a/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md b/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md index 90aee5b..feacc95 100644 --- a/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md +++ b/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md @@ -26,7 +26,7 @@ each successful action (crash-safe / resumable). 2. **Destroy-protection = a config `preventDestroy` lifecycle flag** plus a typed confirmation. A targeted destroy of a flagged resource hard-fails until the flag (or the whole declaration) is removed from config. -3. **Backup = a JSON snapshot** of the current *actual* ChurchTools values of all +3. **Backup = a JSON snapshot** of the current _actual_ ChurchTools values of all managed resources, written to `backups/ct-backup-.json` beside the state file, before any write. Directory overridable via `--backup-dir` / `CT_BACKUP_DIR`. @@ -99,15 +99,15 @@ Extend `AdoptableResource`: Add read+write entries for the new types. **Provisional** managed fields (to be confirmed against the live API — fetch one of each with `ct get`, adjust): -| type | collection path | update | provisional managed fields | -| ------------------- | --------------------------- | ------ | ----------------------------------- | -| `campus` | `/campuses` | PUT | `name`, `shortName` | -| `group` | `/groups` | PATCH | `name`, `groupTypeId`, `groupStatusId` | -| `group-type` | `/group/grouptypes` | PUT | `name`, `nameTranslated` | -| `age-group` | `/group/agegroups` | PUT | `name`, `sortKey` | -| `target-group` | `/group/targetgroups` | PUT | `name`, `sortKey` | +| type | collection path | update | provisional managed fields | +| ------------------- | --------------------------- | ------ | ---------------------------------------- | +| `campus` | `/campuses` | PUT | `name`, `shortName` | +| `group` | `/groups` | PATCH | `name`, `groupTypeId`, `groupStatusId` | +| `group-type` | `/group/grouptypes` | PUT | `name`, `nameTranslated` | +| `age-group` | `/group/agegroups` | PUT | `name`, `sortKey` | +| `target-group` | `/group/targetgroups` | PUT | `name`, `sortKey` | | `relationship-type` | `/person/relationshiptypes` | PUT | `name`, `degreeForward`, `degreeReverse` | -| `group-role` | `/group/roles` | PUT | `name`, `groupTypeId` | +| `group-role` | `/group/roles` | PUT | `name`, `groupTypeId` | Provisional field sets are a registry-local detail; the engine is field-agnostic, so refining them later touches only this file (+ its test). @@ -182,14 +182,14 @@ Add `commands/apply.ts` and `commands/destroy.ts`; remove both entries from ## Guardrail → mechanism map -| Guardrail (issue #6) | Mechanism | -| -------------------------------- | --------------------------------------------------------------- | -| Confirmation before any change | `confirm` (apply) / `confirmTyped` (destroy) | -| Automatic backup before apply | `writeBackup(actual)` before the first write | -| People/memberships never touched | structural-only registry + `assertNotPeople` denylist | +| Guardrail (issue #6) | Mechanism | +| -------------------------------- | -------------------------------------------------------------------- | +| Confirmation before any change | `confirm` (apply) / `confirmTyped` (destroy) | +| Automatic backup before apply | `writeBackup(actual)` before the first write | +| People/memberships never touched | structural-only registry + `assertNotPeople` denylist | | Rate-limit + retry on writes | existing `fetchWithRetry` (429 retried; 5xx/network not, for writes) | -| State updated after each action | `save` after every create/update/edge/delete | -| Destroy-protection | `preventDestroy` flag + explicit `--target` + typed confirm | +| State updated after each action | `save` after every create/update/edge/delete | +| Destroy-protection | `preventDestroy` flag + explicit `--target` + typed confirm | Rate-limit/retry needs **no code change**: `fetchWithRetry` already retries `429` for every method (safe — rejected before processing) and deliberately does not diff --git a/docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.md b/docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.md index 5b8adc6..2eb6bb8 100644 --- a/docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.md +++ b/docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.md @@ -22,9 +22,16 @@ The instance is a **write-safe dev box**, so round-trip integration tests may ge - **Permission catalog** (`GET /permissions/global`): `module → { "right name": true | number[] }`. `true` = global right; `number[]` = the scoped `dataId`s the right is granted for. - **Raw assignment tuple** (`GET /permissions/group_role`, `/permissions/group_type_role`) confirmed: ```json - { "domainType": "group_role", "domainId": 2787, "authId": 113, "dataId": 3, - "isInherited": false, "type": "grant", "reason": null, - "meta": { "modifiedDate": "2026-04-21T11:50:57Z", "modifiedPid": 1 } } + { + "domainType": "group_role", + "domainId": 2787, + "authId": 113, + "dataId": 3, + "isInherited": false, + "type": "grant", + "reason": null, + "meta": { "modifiedDate": "2026-04-21T11:50:57Z", "modifiedPid": 1 } + } ``` - `group_type_role` rows carry `authId: 10101` (> 10000). **This falsifies issue #13's claim that `authId < 10000` for `group_type_role`.** Domain rules will be derived empirically, not from the issue text. - `meta.modifiedPid: -1` = system-authored baseline grant (seen live on `group_type_role` domainId 8). These re-add themselves. @@ -41,15 +48,15 @@ The instance is a **write-safe dev box**, so round-trip integration tests may ge ## Architecture — synthetic sub-resource fields -The engine already has the pattern both features need: the opt-in `parents` set-field (`src/engine/hierarchy.ts`). `parents` is a *pseudo-field* — not a real API column — folded into the diff on both the desired and actual sides in `build.ts`, then routed at apply time to a dedicated endpoint (`PUT/DELETE /groups/{id}/parents/{pid}`) instead of the resource body (`execute.ts` `applyParentEdges`). +The engine already has the pattern both features need: the opt-in `parents` set-field (`src/engine/hierarchy.ts`). `parents` is a _pseudo-field_ — not a real API column — folded into the diff on both the desired and actual sides in `build.ts`, then routed at apply time to a dedicated endpoint (`PUT/DELETE /groups/{id}/parents/{pid}`) instead of the resource body (`execute.ts` `applyParentEdges`). Both new features are the same shape: -| Feature | Attaches to | Field kind | Actual source | Write routing | -|---|---|---|---|---| -| `parents` (exists) | group | set of keys | `GET /groups/hierarchies` | `PUT/DELETE /groups/{id}/parents/{pid}` | -| **grants** (#13) | group-role / group-type-role | set of tuples | `GET /permissions/{domainType}` | `PUT/DELETE /permissions/{domainType}/{domainId}` | -| **dynamic** (#14) | group | object (ruleset + status) | `GET /dynamicgroups/{id}/ruleset` + `/status` | `PUT …/ruleset` then `PUT …/status` | +| Feature | Attaches to | Field kind | Actual source | Write routing | +| ------------------ | ---------------------------- | ------------------------- | --------------------------------------------- | ------------------------------------------------- | +| `parents` (exists) | group | set of keys | `GET /groups/hierarchies` | `PUT/DELETE /groups/{id}/parents/{pid}` | +| **grants** (#13) | group-role / group-type-role | set of tuples | `GET /permissions/{domainType}` | `PUT/DELETE /permissions/{domainType}/{domainId}` | +| **dynamic** (#14) | group | object (ruleset + status) | `GET /dynamicgroups/{id}/ruleset` + `/status` | `PUT …/ruleset` then `PUT …/status` | ### The refactor @@ -69,14 +76,20 @@ The `assertNotPeople` guard extends to every new write path. ### DSL ```ts -ct.group({ key: "all_mainz", name: "Alle Mainz", groupTypeId: 1, +ct.group({ + key: "all_mainz", + name: "Alle Mainz", + groupTypeId: 1, dynamic: { - status: "active", // "active" | "manual" | "inactive" | "none" (=demote) - ruleset: q.and( // typed builder → JSONLogic (Phase 2) - q.eq("ctgroup.campusId", campus("mainz")), // name→id resolution for var values - q.eq("person.isArchived", false)), + status: "active", // "active" | "manual" | "inactive" | "none" (=demote) + ruleset: q.and( + // typed builder → JSONLogic (Phase 2) + q.eq("ctgroup.campusId", campus("mainz")), // name→id resolution for var values + q.eq("person.isArchived", false), + ), // …or an opaque blob, or { ref: "./rulesets/all_mainz.json" } (Phase 1) — interchangeable - }}) + }, +}); ``` - `dynamic` is an **object-field** on a group, opt-in like `parents`: `undefined` = not managed as a dynamic group; present = managed. It carries the full `RuleSet` (`{ description, shorty, personIdFieldName, importance, query, process }`) plus `status`. @@ -109,10 +122,16 @@ Both the typed builder and an opaque blob compile to the **same normalized JSONL ### DSL ```ts -ct.groupRole({ key: "kids_lead", id: 51, - grants: ["churchgroup:view group", "churchgroup:edit group members"] }); -ct.groupTypeRole({ key: "leiter_tpl", id: 8, - grants: [{ right: "churchdb:view group", scope: ["kids_area"] }] }); // scope keys → dataId[] +ct.groupRole({ + key: "kids_lead", + id: 51, + grants: ["churchgroup:view group", "churchgroup:edit group members"], +}); +ct.groupTypeRole({ + key: "leiter_tpl", + id: 8, + grants: [{ right: "churchdb:view group", scope: ["kids_area"] }], +}); // scope keys → dataId[] ``` - `grants` is a **set-field** on a role domain object. A grant is either a bare `"module:right"` string (global) or `{ right, scope: [key…] }` (scoped; scope keys resolve to `dataId[]`). @@ -147,7 +166,10 @@ const kidsArea = (campus: string) => { ct.group({ key: `${campus}_kids_0_3`, name: `${campus} · Kids 0–3`, parents: [`${campus}_kids_lead`] }); // … }; -for (const c of ["mainz", "berlin", "koblenz"]) { ct.campus({ key: c, name: `Campus ${c}` }); kidsArea(c); } +for (const c of ["mainz", "berlin", "koblenz"]) { + ct.campus({ key: c, name: `Campus ${c}` }); + kidsArea(c); +} ``` Deliverables (mostly non-code): diff --git a/examples/campus-blueprint.config.ts b/examples/campus-blueprint.config.ts index c0a8ca1..7dba279 100644 --- a/examples/campus-blueprint.config.ts +++ b/examples/campus-blueprint.config.ts @@ -21,7 +21,11 @@ const CAMPUSES = ["mainz", "berlin"] as const; function kidsArea(ct: ConfigContext, campus: string): void { const lead = `${campus}_kids_lead`; ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupType: "ministry_team", campus, parents: [] }); - for (const [suffix, label] of [["0_3", "0–3"], ["4_6", "4–6"], ["checkin", "Check-in"]] as const) { + for (const [suffix, label] of [ + ["0_3", "0–3"], + ["4_6", "4–6"], + ["checkin", "Check-in"], + ] as const) { ct.group({ key: `${campus}_kids_${suffix}`, name: `${campus} · Kids ${label}`, diff --git a/examples/portable.config.ts b/examples/portable.config.ts index ae1060e..92553c3 100644 --- a/examples/portable.config.ts +++ b/examples/portable.config.ts @@ -25,8 +25,20 @@ export default (ct: ConfigContext): void => { // Group type + campus by name — no numeric ids. `groupType: "ministry_team"` resolves against // the live /group/grouptypes catalog on whichever host you apply to. - ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupType: "ministry_team", campus: "mainz", parents: [] }); - ct.group({ key: "mainz_kids_team", name: "Mainz · Kids Team", groupType: "ministry_team", campus: "mainz", parents: ["mainz_kids_lead"] }); + ct.group({ + key: "mainz_kids_lead", + name: "Mainz · Kids Leitung", + groupType: "ministry_team", + campus: "mainz", + parents: [], + }); + ct.group({ + key: "mainz_kids_team", + name: "Mainz · Kids Team", + groupType: "ministry_team", + campus: "mainz", + parents: ["mainz_kids_lead"], + }); // A dynamic auto-group whose ruleset filters by campus — again by reference, not id. ct.group({ diff --git a/scripts/regenerate-permission-catalog.ts b/scripts/regenerate-permission-catalog.ts index 89fa7a2..a8fa92e 100644 --- a/scripts/regenerate-permission-catalog.ts +++ b/scripts/regenerate-permission-catalog.ts @@ -62,7 +62,10 @@ function cookieHeader(res: Response): string { const raw = (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? (res.headers.get("set-cookie") ? [res.headers.get("set-cookie") as string] : []); - return raw.map((c) => c.split(";")[0]).filter(Boolean).join("; "); + return raw + .map((c) => c.split(";")[0]) + .filter(Boolean) + .join("; "); } async function main(): Promise { @@ -78,8 +81,13 @@ async function main(): Promise { if (!cookie) throw new Error("Login succeeded but no session cookie was returned."); // 2. Read the instance CT version (for the catalog's provenance stamp). - const infoRes = await fetch(`${host}/api/info`, { headers: { Accept: "application/json", Cookie: cookie } }); - const infoBody = (await infoRes.json().catch(() => ({}))) as { data?: { version?: string }; version?: string }; + const infoRes = await fetch(`${host}/api/info`, { + headers: { Accept: "application/json", Cookie: cookie }, + }); + const infoBody = (await infoRes.json().catch(() => ({}))) as { + data?: { version?: string }; + version?: string; + }; const ctVersion = infoBody.data?.version ?? infoBody.version ?? "unknown"; // 3. Fetch the permission master data from the legacy AJAX endpoint. @@ -96,7 +104,9 @@ async function main(): Promise { const master = (await res.json()) as MasterData; const authTable = master.data?.auth_table ?? master.auth_table; if (!authTable || typeof authTable !== "object") { - throw new Error("Unexpected response: no data.auth_table in getMasterData. Is the endpoint/shape unchanged?"); + throw new Error( + "Unexpected response: no data.auth_table in getMasterData. Is the endpoint/shape unchanged?", + ); } // 4. Flatten `auth_table[module][right]` → `"module:right" → CatalogEntry`, preserving iteration order. diff --git a/src/api/ctClient.ts b/src/api/ctClient.ts index 91c0432..608bc9f 100644 --- a/src/api/ctClient.ts +++ b/src/api/ctClient.ts @@ -184,7 +184,12 @@ export class CtClient { const pageMeta = envelope?.meta; meta = pageMeta ?? meta; const pagination = pageMeta?.pagination; - if (pageItems.length === 0 || !pagination || pagination.current === undefined || pagination.lastPage === undefined) { + if ( + pageItems.length === 0 || + !pagination || + pagination.current === undefined || + pagination.lastPage === undefined + ) { break; } if (pagination.current >= pagination.lastPage) { diff --git a/src/auth/tokenStore.ts b/src/auth/tokenStore.ts index ba02975..8a92e78 100644 --- a/src/auth/tokenStore.ts +++ b/src/auth/tokenStore.ts @@ -60,16 +60,7 @@ export function parseCredentials(raw: string): Credentials | null { } async function keychainSet(account: string, value: string): Promise { - await run("security", [ - "add-generic-password", - "-U", - "-s", - KEYCHAIN_SERVICE, - "-a", - account, - "-w", - value, - ]); + await run("security", ["add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", account, "-w", value]); } /** diff --git a/src/commands/adopt-group.ts b/src/commands/adopt-group.ts index 4057ca2..f268c75 100644 --- a/src/commands/adopt-group.ts +++ b/src/commands/adopt-group.ts @@ -322,7 +322,9 @@ export function adoptGroupCommand(): Command { lines.map((l) => ` ${l}`).join("\n"), ); } - warn(`${relPath} keeps ${warnings.length} host-specific id(s) — NOT portable to another host:`); + warn( + `${relPath} keeps ${warnings.length} host-specific id(s) — NOT portable to another host:`, + ); for (const line of lines) info(` ${line}`); } } else { diff --git a/src/commands/apply.ts b/src/commands/apply.ts index ec309e7..7a316f2 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -45,10 +45,7 @@ export function applyCommand(): Command { .option("-c, --config ", "config file (or set CT_CONFIG)") .option("-s, --state ", "state file (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") - .option( - "--confirm-env ", - "confirm a protected env non-interactively (must match --env exactly)", - ) + .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( @@ -72,11 +69,13 @@ export function applyCommand(): Command { const resolver = new Resolver({ client, state, desired, host: config.host }); // Independent fetches: the resource plan and the permission plan (whose instance-wide // /permissions/ reads are slow) run concurrently rather than back-to-back. - const [{ plan, actual, fetchErrors }, { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }] = - await Promise.all([ - buildPlan(client, state, desired, { configDir, resolver }), - buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), - ]); + const [ + { plan, actual, fetchErrors }, + { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }, + ] = await Promise.all([ + buildPlan(client, state, desired, { configDir, resolver }), + buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), + ]); // Permission catalog warnings (#25): stale-version / unknown-authId (untouched, never revoked). for (const w of permWarnings) warn(w); @@ -104,10 +103,7 @@ export function applyCommand(): Command { } const s = summarize(plan); - const permChangeCount = permItems.reduce( - (n, i) => n + i.diff.toPut.length + i.diff.toDelete.length, - 0, - ); + const permChangeCount = permItems.reduce((n, i) => n + i.diff.toPut.length + i.diff.toDelete.length, 0); const changeCount = s.create + s.update + permChangeCount; if (changeCount === 0) { success("No changes to apply."); @@ -130,11 +126,7 @@ export function applyCommand(): Command { return; } - const backupPath = await writeBackup( - resolveBackupDir(opts.backupDir, statePath), - config.host, - actual, - ); + const backupPath = await writeBackup(resolveBackupDir(opts.backupDir, statePath), config.host, actual); info(`Backup written: ${backupPath}`); const result = await executePlan(plan, { client, state, statePath, save: saveState }); @@ -160,7 +152,9 @@ export function applyCommand(): Command { `${permResult.failed.length} permission write(s) failed — re-run to resume (grant reconciliation is idempotent):`, ); for (const f of permResult.failed) { - info(` ${f.method} ${f.path} (authId ${f.authId}${f.dataId.length ? ` dataId ${f.dataId.join(",")}` : ""}): ${f.message}`); + info( + ` ${f.method} ${f.path} (authId ${f.authId}${f.dataId.length ? ` dataId ${f.dataId.join(",")}` : ""}): ${f.message}`, + ); } process.exitCode = 1; return; @@ -174,7 +168,10 @@ export function applyCommand(): Command { // so a freshly created auto-group is legitimately EMPTY right now. Saying so costs one line // and removes the "did it work?" that otherwise follows every first apply. const dynamicKeys = plan.items - .filter((i) => i.action !== "no-op" && i.action !== "delete" && i.changes.some((c) => c.field === "dynamic")) + .filter( + (i) => + i.action !== "no-op" && i.action !== "delete" && i.changes.some((c) => c.field === "dynamic"), + ) .map((i) => i.key); if (dynamicKeys.length > 0) { info( diff --git a/src/commands/coverage.ts b/src/commands/coverage.ts index 9011617..5429f9a 100644 --- a/src/commands/coverage.ts +++ b/src/commands/coverage.ts @@ -61,7 +61,9 @@ export function coverageCommand(): Command { if (opts.type) { const wanted = opts.type.trim(); const typeIds = new Set( - report.byType.filter((t) => t.name === wanted || slug(t.name) === slug(wanted)).map((t) => t.groupTypeId), + report.byType + .filter((t) => t.name === wanted || slug(t.name) === slug(wanted)) + .map((t) => t.groupTypeId), ); if (typeIds.size === 0) { throw new Error( diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 2ef905b..dc354c8 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -91,7 +91,9 @@ async function fetchParentEdges( } return edges; } catch (err) { - warn(`Failed to fetch group hierarchies for destroy ordering: ${formatError(err)}. Falling back to tier-only order.`); + warn( + `Failed to fetch group hierarchies for destroy ordering: ${formatError(err)}. Falling back to tier-only order.`, + ); return new Map(); } } @@ -119,10 +121,7 @@ export function destroyCommand(): Command { .requiredOption("--target ", "logical key(s) to destroy (repeatable or comma-separated)") .option("-s, --state ", "state file (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") - .option( - "--confirm-env ", - "confirm a protected env non-interactively (must match --env exactly)", - ) + .option("--confirm-env ", "confirm a protected env non-interactively (must match --env exactly)") .option("--backup-dir ", "directory for the pre-destroy backup (or set CT_BACKUP_DIR)") .option( "--force", @@ -166,7 +165,10 @@ export function destroyCommand(): Command { // Backup: fetch each target's current actual values via the same fetchActual as plan/apply // (404 → skip: already gone in CT, nothing to back up). A non-404 failure must ABORT before // any DELETE — proceeding would irreversibly delete a target with no backup of its state. - const { actual, fetchErrors } = await fetchActual(client, ordered.map((k) => state.resources[k]!)); + const { actual, fetchErrors } = await fetchActual( + client, + ordered.map((k) => state.resources[k]!), + ); if (fetchErrors.length > 0) { error( `Backup fetch failed for: ${fetchErrors.join("; ")}. ` + @@ -243,7 +245,9 @@ export async function runDeleteLoop(ctx: DeleteLoopCtx): Promise { if (err instanceof CtApiError && err.status === 404) { delete state.resources[key]; await save(statePath, state); - success(`${managed.type}.${key} (#${managed.id}) already deleted in ChurchTools — removed from state`); + success( + `${managed.type}.${key} (#${managed.id}) already deleted in ChurchTools — removed from state`, + ); continue; } // Same formatter the top-level handler uses (#50) so a non-404 CtApiError's HTTP status + diff --git a/src/commands/get.ts b/src/commands/get.ts index 811642e..637c780 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -192,5 +192,9 @@ function reportRows(data: unknown, meta: CtMeta | undefined): void { ); return; } - info(total !== undefined && total !== data.length ? `${data.length} of ${total} total` : `${data.length} total`); + info( + total !== undefined && total !== data.length + ? `${data.length} of ${total} total` + : `${data.length} total`, + ); } diff --git a/src/commands/permissions.ts b/src/commands/permissions.ts index 43618f4..f7e3553 100644 --- a/src/commands/permissions.ts +++ b/src/commands/permissions.ts @@ -55,7 +55,9 @@ export function permissionsCommand(): Command { ); } if (!loaded) { - info(` refresh it for this instance: \`ct permissions catalog --refresh${opts.env ? ` --env ${opts.env}` : ""}\``); + info( + ` refresh it for this instance: \`ct permissions catalog --refresh${opts.env ? ` --env ${opts.env}` : ""}\``, + ); } return; } @@ -65,10 +67,10 @@ export function permissionsCommand(): Command { const path = await writeHostCatalog(config.host, catalog); const meta = catalog.$meta as { rightCount: number; ctVersion: string }; success(`Wrote ${path} — ${meta.rightCount} rights · ChurchTools ${meta.ctVersion} · ${config.host}`); - info("Commit this file: every plan/apply against this host will use it instead of the bundled catalog."); - warn( - "Review the diff before committing — an authId that MOVED changes what a declared right grants.", + info( + "Commit this file: every plan/apply against this host will use it instead of the bundled catalog.", ); + warn("Review the diff before committing — an authId that MOVED changes what a declared right grants."); }); return cmd; diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 6b9e51c..26a04ea 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -50,11 +50,13 @@ export function planCommand(): Command { // instance means each master-data catalog is fetched at most once (cache is Promise-keyed). const resolver = new Resolver({ client, state, desired, host: config.host }); // Independent fetches run concurrently (see commands/apply.ts). - const [{ plan, fetchErrors }, { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }] = - await Promise.all([ - buildPlan(client, state, desired, { configDir, resolver }), - buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), - ]); + const [ + { plan, fetchErrors }, + { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }, + ] = await Promise.all([ + buildPlan(client, state, desired, { configDir, resolver }), + buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), + ]); // "Changes present" for --detailed-exitcode / the JSON summary: anything `ct apply` would // actually act on — a resource item whose action isn't a no-op, OR a permission item with a // grant/revoke to write. Drift by itself does NOT count: an item can carry `drift` while diff --git a/src/config/context.ts b/src/config/context.ts index b2c59dc..e4daf81 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -191,7 +191,9 @@ function resolveDomainInput(domainType: DomainType, input: PermissionInput): num if (input.personStatus !== undefined) { if (hasId) throw bothError('"personStatus"'); if (typeof input.personStatus !== "string" || !input.personStatus) - throw new Error(`${domainType} "${input.key}": "personStatus" must be a non-empty person-status key.`); + throw new Error( + `${domainType} "${input.key}": "personStatus" must be a non-empty person-status key.`, + ); return ref.personStatus(input.personStatus); } } else { @@ -316,7 +318,16 @@ function desugarDynamic(type: string, key: string, dynamic: unknown): DynamicSpe } function toDesired(type: string, input: ResourceInput, location?: string): DesiredResource { - const { key, parent, parents, dependsOn = [], preventDestroy, dynamic, allowDuplicateName, ...fields } = input; + const { + key, + parent, + parents, + dependsOn = [], + preventDestroy, + dynamic, + allowDuplicateName, + ...fields + } = input; if (!key || typeof key !== "string") { throw new Error(`${type} declaration is missing a string "key".`); } diff --git a/src/config/query-refs.ts b/src/config/query-refs.ts index 0b6a4b3..a6bf75f 100644 --- a/src/config/query-refs.ts +++ b/src/config/query-refs.ts @@ -157,7 +157,7 @@ export function portablizeRuleset( ): PortablizeResult { const warnings: PortablizeWarning[] = []; - const marker = (kind: RefKind, key: string): SimpleRef => ({ __ctRef: true, kind, key } as SimpleRef); + const marker = (kind: RefKind, key: string): SimpleRef => ({ __ctRef: true, kind, key }) as SimpleRef; const mapScalar = (value: unknown, kind: RefKind, varName: string): unknown => { if (typeof value !== "number") return value; // booleans/strings/nulls are literals, never entity ids @@ -246,10 +246,7 @@ export function portablizeRuleset( for (const [k, v] of Object.entries(node as Record)) { // `handleMembership.groupTypeRoleId` sits outside the query, so it has no `{ var }` leaf to key // off — rewrite it by object-key match, through the same role catalog as the `role.id` operand. - out[k] = - k === ROLE_FIELD_NAME && typeof v === "number" - ? mapRoleScalar(v, ROLE_FIELD_NAME) - : walk(v); + out[k] = k === ROLE_FIELD_NAME && typeof v === "number" ? mapRoleScalar(v, ROLE_FIELD_NAME) : walk(v); } return out; } diff --git a/src/coverage/report.ts b/src/coverage/report.ts index 74e8478..6d80335 100644 --- a/src/coverage/report.ts +++ b/src/coverage/report.ts @@ -77,7 +77,13 @@ export interface TypeCoverage { export interface CoverageReport { host: string; groups: { total: number; managed: number; dynamic: number; managedDynamic: number }; - grants: { authored: number; roleInstances: number; declarable: number; blocked: number; blockingDimensions: string[] }; + grants: { + authored: number; + roleInstances: number; + declarable: number; + blocked: number; + blockingDimensions: string[]; + }; byType: TypeCoverage[]; roleInstances: RoleInstanceCoverage[]; } diff --git a/src/engine/dynamic.ts b/src/engine/dynamic.ts index fe17a4d..0753f90 100644 --- a/src/engine/dynamic.ts +++ b/src/engine/dynamic.ts @@ -61,7 +61,7 @@ function dropReadOnly(rule: Record): Record { /** Canonicalise a ruleset for diffing: unwrap array/PUT envelope, drop timestamps; strip labels + coerce scalars WITHIN the query subtree only. */ export function normalizeRuleset(rule: unknown): Record { let r: unknown = rule ?? {}; - if (Array.isArray(r)) r = r[0] ?? {}; // GET returns a single-element [RuleSet] + if (Array.isArray(r)) r = r[0] ?? {}; // GET returns a single-element [RuleSet] let obj = (r ?? {}) as Record; if (obj.dynamicGroupRuleSet && typeof obj.dynamicGroupRuleSet === "object") { // Defensive: tolerate the `{ dynamicGroupRuleSet: ... }` wrapper if one is ever fed through @@ -91,13 +91,16 @@ export function normalizeRuleset(rule: unknown): Record { * The single source of truth for the PUT envelope — every writer (apply path, live-gated tests) * must go through this so the envelope can't drift out of sync again. */ -export function putRulesetBody( - ruleset: Record, -): { dynamicGroupRuleSet: [Record] } { +export function putRulesetBody(ruleset: Record): { + dynamicGroupRuleSet: [Record]; +} { return { dynamicGroupRuleSet: [ruleset] }; } -export interface NormalizedDynamic { status: DynamicStatus; ruleset: Record } +export interface NormalizedDynamic { + status: DynamicStatus; + ruleset: Record; +} export function normalizeDynamic(spec: { status: DynamicStatus; ruleset: unknown }): NormalizedDynamic { return { status: spec.status, ruleset: normalizeRuleset(spec.ruleset) }; @@ -109,7 +112,11 @@ export function normalizeDynamic(spec: { status: DynamicStatus; ruleset: unknown * so a config is portable regardless of where `ct` is invoked. Missing/unreadable/invalid-JSON * ref files raise a clear error naming the group and the resolved path instead of a raw ENOENT. */ -export function resolveRulesetRef(ruleset: unknown, baseDir: string = process.cwd(), groupKey?: string): unknown { +export function resolveRulesetRef( + ruleset: unknown, + baseDir: string = process.cwd(), + groupKey?: string, +): unknown { if (ruleset && typeof ruleset === "object" && typeof (ruleset as { ref?: unknown }).ref === "string") { const ref = (ruleset as { ref: string }).ref; const p = resolve(baseDir, ref); @@ -118,12 +125,16 @@ export function resolveRulesetRef(ruleset: unknown, baseDir: string = process.cw try { raw = readFileSync(p, "utf8"); } catch (err) { - throw new Error(`${where}: cannot read ruleset ref "${ref}" (resolved to ${p}): ${(err as Error).message}`); + throw new Error( + `${where}: cannot read ruleset ref "${ref}" (resolved to ${p}): ${(err as Error).message}`, + ); } try { return JSON.parse(raw); } catch (err) { - throw new Error(`${where}: ruleset ref "${ref}" (resolved to ${p}) is not valid JSON: ${(err as Error).message}`); + throw new Error( + `${where}: ruleset ref "${ref}" (resolved to ${p}) is not valid JSON: ${(err as Error).message}`, + ); } } return ruleset; diff --git a/src/engine/execute.ts b/src/engine/execute.ts index 5131d20..22b2242 100644 --- a/src/engine/execute.ts +++ b/src/engine/execute.ts @@ -94,7 +94,10 @@ function snapshotFromChanges(base: Record, changes: FieldChange } async function applySyntheticFields( - client: Pick, state: State, id: number, changes: FieldChange[], + client: Pick, + state: State, + id: number, + changes: FieldChange[], ): Promise { for (const c of changes) { const f = syntheticField(c.field); @@ -207,7 +210,12 @@ export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise re.test(path))) { - throw new Error( - `Refusing to write to "${path}": people/memberships are never managed by this tool.`, - ); + throw new Error(`Refusing to write to "${path}": people/memberships are never managed by this tool.`); } } diff --git a/src/engine/render.ts b/src/engine/render.ts index 638ca54..848596d 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -88,7 +88,9 @@ export function renderPlan(plan: Plan): string { if (fetchFailed.length > 0) { lines.push(""); - lines.push(pc.yellow("Fetch failed (could not read from ChurchTools — diff unavailable, left untouched):")); + lines.push( + pc.yellow("Fetch failed (could not read from ChurchTools — diff unavailable, left untouched):"), + ); for (const item of fetchFailed) { lines.push(` ? ${item.type}.${item.key} (#${item.id}) — fetch failed (${item.detail ?? "error"})`); } diff --git a/src/engine/synthetic.ts b/src/engine/synthetic.ts index 63dc9a2..c39b389 100644 --- a/src/engine/synthetic.ts +++ b/src/engine/synthetic.ts @@ -146,7 +146,9 @@ const dynamicField: SyntheticField = { return [`dynamic ${managed.key} (#${managed.id}): ${formatError(err)}`]; } try { - const statusRes = await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${managed.id}/status`); + const statusRes = await client.get<{ dynamicGroupStatus?: string }>( + `/dynamicgroups/${managed.id}/status`, + ); a.dynamic = { status: (statusRes?.dynamicGroupStatus ?? "none") as DynamicStatus, ruleset: normalizeRuleset(ruleset), diff --git a/src/env/context.ts b/src/env/context.ts index 06e4da9..ca9623b 100644 --- a/src/env/context.ts +++ b/src/env/context.ts @@ -53,10 +53,7 @@ async function wireEnv(opts: EnvOpts, env: NodeJS.ProcessEnv): Promise { +export async function prepareEnv(opts: EnvOpts, env: NodeJS.ProcessEnv = process.env): Promise { const profile = await wireEnv(opts, env); const statePath = resolveStatePath(opts.state, env, profile?.statePath); return { name: profile?.name ?? null, protected: profile?.protected ?? false, statePath }; diff --git a/src/env/envs.ts b/src/env/envs.ts index 9f8338c..a8da2a1 100644 --- a/src/env/envs.ts +++ b/src/env/envs.ts @@ -87,9 +87,7 @@ async function loadEnvsFile(path: string): Promise> { try { parsed = JSON.parse(raw); } catch (err) { - throw new Error( - `Malformed environments file ${path}: not valid JSON (${(err as Error).message}).`, - ); + throw new Error(`Malformed environments file ${path}: not valid JSON (${(err as Error).message}).`); } return validateEnvsFile(parsed, path); } diff --git a/src/permissions/README.md b/src/permissions/README.md index ae4df85..34219fd 100644 --- a/src/permissions/README.md +++ b/src/permissions/README.md @@ -21,9 +21,24 @@ changes materially. ```json { - "$meta": { "capturedFrom": "eqrm.church.tools", "ctVersion": "3.134.0", "capturedAt": "2026-07-08", "rightCount": 187 }, - "churchgroup:view group": { "authId": 1104, "scopeField": "cdb_gruppe", "revocable": false, "desc": "View group incl. its group members" }, - "churchcore:administer settings": { "authId": 1, "scopeField": null, "revocable": false, "desc": "Edit system settings" } + "$meta": { + "capturedFrom": "eqrm.church.tools", + "ctVersion": "3.134.0", + "capturedAt": "2026-07-08", + "rightCount": 187 + }, + "churchgroup:view group": { + "authId": 1104, + "scopeField": "cdb_gruppe", + "revocable": false, + "desc": "View group incl. its group members" + }, + "churchcore:administer settings": { + "authId": 1, + "scopeField": null, + "revocable": false, + "desc": "Edit system settings" + } } ``` diff --git a/src/permissions/adopt.ts b/src/permissions/adopt.ts index 678635d..b0f4ec1 100644 --- a/src/permissions/adopt.ts +++ b/src/permissions/adopt.ts @@ -136,7 +136,9 @@ export function buildAdoptedGrants(args: AdoptGrantsArgs): AdoptedGrantsBlock { if (key !== undefined) { body.push(` key: ${JSON.stringify(key)},`); } else { - body.push(` key: "${domainType}_${domainId}", // a logical key, unique across the config — rename to taste`); + body.push( + ` key: "${domainType}_${domainId}", // a logical key, unique across the config — rename to taste`, + ); } if (domain) { // Portable form: resolved per host at plan time, so the same block applies to dev and prod. @@ -165,9 +167,15 @@ export function buildAdoptedGrants(args: AdoptGrantsArgs): AdoptedGrantsBlock { // Reconciliation is set-based: a live grant absent from the declaration lands in `toDelete`. // So every grant left below as a comment WILL BE REVOKED by the next apply of this block — // this must be impossible to miss, hence the header. - lines.push(`// WARNING: ${omitted} live grant(s) could not be expressed as config and are left as comments below.`); - lines.push("// They are still ACTIVE on the instance — applying this block as-is will REVOKE them, because"); - lines.push("// reconciliation deletes any live grant missing from the declaration. Resolve every WARNING/NOTE"); + lines.push( + `// WARNING: ${omitted} live grant(s) could not be expressed as config and are left as comments below.`, + ); + lines.push( + "// They are still ACTIVE on the instance — applying this block as-is will REVOKE them, because", + ); + lines.push( + "// reconciliation deletes any live grant missing from the declaration. Resolve every WARNING/NOTE", + ); lines.push("// comment (adopt the group, regenerate the catalog, …) before running `ct apply`."); } lines.push(...body); @@ -200,11 +208,7 @@ interface GrantLinesResult { * reject (a scoped right without a declarable scope, an unscoped right carrying dataIds) is emitted * as a comment instead. */ -function grantLines( - g: CollapsedGrant, - rev: Map, - state: State, -): GrantLinesResult { +function grantLines(g: CollapsedGrant, rev: Map, state: State): GrantLinesResult { const entry = rev.get(g.authId); if (!entry) { // Unknown authId → no name to emit, and a numeric right is not declarable in the DSL. Surface @@ -236,8 +240,12 @@ function grantLines( const out: string[] = []; let omitted = false; if (g.hasUnscoped) { - out.push(` // WARNING: "${entry.name}" is granted GLOBALLY here (scoped right, no dataId). The config`); - out.push(" // DSL cannot declare a global grant of a scoped right; re-grant it with an explicit"); + out.push( + ` // WARNING: "${entry.name}" is granted GLOBALLY here (scoped right, no dataId). The config`, + ); + out.push( + " // DSL cannot declare a global grant of a scoped right; re-grant it with an explicit", + ); out.push(" // scope in CT, or leave this domain unmanaged."); omitted = true; } @@ -286,7 +294,9 @@ function grantLines( // Reached only for a dimension with NO logical form at all (`cc_securitylevel`, `oauth_client`, // …). A catalog-only dimension (`cdb_bereich`) already got its own NOTE above — emitting this // line there too would contradict it ("not a group, use numbers" vs "portable form exists"). - out.push(` // "${entry.name}" scopes by "${entry.scopeField}", not a group — using its numeric dataId(s) directly.`); + out.push( + ` // "${entry.name}" scopes by "${entry.scopeField}", not a group — using its numeric dataId(s) directly.`, + ); } out.push(` { right: ${JSON.stringify(entry.name)}, scope: [${entries.join(", ")}] },`); } @@ -313,8 +323,12 @@ function grantLines( // A scoped right granted with dataId null = granted GLOBALLY in CT. The DSL cannot declare // that (a bare string for a scoped right is rejected at plan time precisely to prevent // accidental global grants), so it can only be surfaced as a comment. - out.push(` // WARNING: "${entry.name}" is granted GLOBALLY here (scoped right, no dataId). The config`); - out.push(" // DSL cannot declare a global grant of a scoped right; re-grant it with an explicit"); + out.push( + ` // WARNING: "${entry.name}" is granted GLOBALLY here (scoped right, no dataId). The config`, + ); + out.push( + " // DSL cannot declare a global grant of a scoped right; re-grant it with an explicit", + ); out.push(" // scope in CT, or leave this domain unmanaged."); omitted = true; } @@ -331,7 +345,9 @@ function grantLines( } else if (unmanaged.length > 0) { // Every scope target is unmanaged: there is no valid key to emit, so the grant itself is a // commented placeholder the user completes after adopting the group(s) above. - out.push(` // { right: ${JSON.stringify(entry.name)}, scope: [/* adopt the group(s) above first */] },`); + out.push( + ` // { right: ${JSON.stringify(entry.name)}, scope: [/* adopt the group(s) above first */] },`, + ); } return { lines: out, omitted }; } @@ -344,7 +360,9 @@ function grantLines( out.push( ` // WARNING: "${entry.name}" is unscoped per the catalog, but CT returned it with dataId(s)`, ); - out.push(` // ${g.dataIds.join(", ")} — the catalog may be stale. Regenerate it, then re-adopt.`); + out.push( + ` // ${g.dataIds.join(", ")} — the catalog may be stale. Regenerate it, then re-adopt.`, + ); omitted = true; } if (g.hasUnscoped) { diff --git a/src/permissions/apply.ts b/src/permissions/apply.ts index 1ecffbe..837fc3d 100644 --- a/src/permissions/apply.ts +++ b/src/permissions/apply.ts @@ -22,7 +22,9 @@ function body(t: GrantTuple): Record { if (t.pending) { // A pending tuple's dataId is unknown until it is re-resolved against post-execute state. // Reaching here means re-resolution was skipped — refuse rather than emit a silent GLOBAL grant. - throw new Error(`Grant scoped to "${t.scopeKey}" was not re-resolved before apply — refusing to write it without a dataId.`); + throw new Error( + `Grant scoped to "${t.scopeKey}" was not re-resolved before apply — refusing to write it without a dataId.`, + ); } const b: Record = { authId: t.authId, type: t.type }; if (t.dataId.length) b.dataId = t.dataId; // omit when unscoped diff --git a/src/permissions/catalog.json b/src/permissions/catalog.json index e14884f..b66e78a 100644 --- a/src/permissions/catalog.json +++ b/src/permissions/catalog.json @@ -1,1132 +1,1132 @@ { - "$meta": { - "capturedFrom": "eqrm.church.tools", - "ctVersion": "3.134.0", - "capturedAt": "2026-07-08", - "rightCount": 187, - "source": "POST /index.php?q=churchauth/ajax func=getMasterData", - "regenerate": "npm run regenerate:permission-catalog (see src/permissions/README.md)" - }, - "churchcore:administer settings": { - "authId": 1, - "scopeField": null, - "revocable": false, - "desc": "Edit system settings" - }, - "churchcore:edit public profiles": { - "authId": 10, - "scopeField": null, - "revocable": false, - "desc": "Manage public profiles" - }, - "churchcore:view website": { - "authId": 11, - "scopeField": null, - "revocable": false, - "desc": "View overview" - }, - "churchcore:edit website staff": { - "authId": 12, - "scopeField": null, - "revocable": false, - "desc": "Manage users" - }, - "churchcore:edit website releases": { - "authId": 13, - "scopeField": null, - "revocable": false, - "desc": "Manage permissions" - }, - "churchcore:administer persons": { - "authId": 2, - "scopeField": null, - "revocable": false, - "desc": "Manage permissions" - }, - "churchcore:view logfile": { - "authId": 3, - "scopeField": null, - "revocable": false, - "desc": "View log" - }, - "churchcore:edit languages": { - "authId": 9, - "scopeField": "cc_language", - "revocable": false, - "desc": "Edit translations" - }, - "churchcore:edit translations masterdata": { - "authId": 5, - "scopeField": null, - "revocable": false, - "desc": "Manage languages" - }, - "churchcore:simulate persons": { - "authId": 7, - "scopeField": null, - "revocable": false, - "desc": "Simulate persons, change password and block access" - }, - "churchcore:invite persons": { - "authId": 6, - "scopeField": null, - "revocable": false, - "desc": "Invite person to ChurchTools" - }, - "churchcore:administer church html templates": { - "authId": 16, - "scopeField": null, - "revocable": false, - "desc": "Manage church HTML templates" - }, - "churchcore:use church html templates": { - "authId": 17, - "scopeField": "cc_html_template", - "revocable": false, - "desc": "Use church HTML templates" - }, - "churchcore:view links": { - "authId": 19, - "scopeField": "cc_further_link", - "revocable": false, - "desc": "View the entries in the links-widget" - }, - "churchcore:login to external system": { - "authId": 18, - "scopeField": "oauth_client", - "revocable": false, - "desc": "Log in to external system with ChurchTools" - }, - "churchcore:administer custom modules": { - "authId": 15, - "scopeField": null, - "revocable": false, - "desc": "Manage extensions" - }, - "churchcheckin:view": { - "authId": 601, - "scopeField": null, - "revocable": false, - "desc": "View \"Check-in\"" - }, - "churchcheckin:create person": { - "authId": 602, - "scopeField": null, - "revocable": false, - "desc": "Create people" - }, - "churchcheckin:edit masterdata": { - "authId": 699, - "scopeField": null, - "revocable": false, - "desc": "Manage printer" - }, - "churchdb:view": { - "authId": 101, - "scopeField": null, - "revocable": false, - "desc": "View \"People\"" - }, - "churchdb:view station": { - "authId": 124, - "scopeField": "cdb_station", - "revocable": false, - "desc": "View campus" - }, - "churchdb:view alldata": { - "authId": 102, - "scopeField": "cdb_bereich", - "revocable": false, - "desc": "View people of a department" - }, - "churchdb:security level person": { - "authId": 125, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View person data - up to the selected security level" - }, - "churchdb:security level view own data": { - "authId": 131, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View your own personal data - up to the selected security level" - }, - "churchdb:security level edit own data": { - "authId": 132, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "Edit own personal data - up to the selected security level" - }, - "churchdb:create person": { - "authId": 119, - "scopeField": null, - "revocable": false, - "desc": "Create people" - }, - "churchdb:write access": { - "authId": 111, - "scopeField": null, - "revocable": false, - "desc": "Edit people" - }, - "churchdb:delete persons": { - "authId": 129, - "scopeField": null, - "revocable": false, - "desc": "Delete persons" - }, - "churchdb:push/pull archive": { - "authId": 118, - "scopeField": null, - "revocable": false, - "desc": "Archive people" - }, - "churchdb:view archive": { - "authId": 116, - "scopeField": null, - "revocable": false, - "desc": "View archive of people" - }, - "churchdb:view statistics": { - "authId": 106, - "scopeField": null, - "revocable": false, - "desc": "View person statistics" - }, - "churchdb:view person tags": { - "authId": 107, - "scopeField": null, - "revocable": false, - "desc": "View tags of people " - }, - "churchdb:view comments": { - "authId": 113, - "scopeField": "cdb_comment_viewer", - "revocable": false, - "desc": "View comments of people" - }, - "churchdb:export data": { - "authId": 112, - "scopeField": null, - "revocable": false, - "desc": "Export data of people" - }, - "churchdb:edit bulkletter": { - "authId": 128, - "scopeField": null, - "revocable": false, - "desc": "Create bulk letters" - }, - "churchdb:create print labels": { - "authId": 130, - "scopeField": null, - "revocable": false, - "desc": "Create labels" - }, - "churchdb:send sms": { - "authId": 117, - "scopeField": null, - "revocable": false, - "desc": "Use SMS interface" - }, - "churchdb:view person history": { - "authId": 108, - "scopeField": null, - "revocable": false, - "desc": "View person history" - }, - "churchdb:edit relations": { - "authId": 109, - "scopeField": null, - "revocable": false, - "desc": "Edit relations" - }, - "churchdb:view memberliste": { - "authId": 122, - "scopeField": null, - "revocable": false, - "desc": "View member list" - }, - "churchdb:complex filter": { - "authId": 120, - "scopeField": null, - "revocable": false, - "desc": "Use \"additional filters\"" - }, - "churchdb:administer global filters": { - "authId": 127, - "scopeField": null, - "revocable": false, - "desc": "Administer global filters" - }, - "churchdb:edit masterdata": { - "authId": 199, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "churchdb:+see group": { - "authId": 10102, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View group infos" - }, - "churchdb:+edit group infos": { - "authId": 10122, - "scopeField": null, - "revocable": false, - "desc": "Edit group info" - }, - "churchdb:+edit group basic settings": { - "authId": 10116, - "scopeField": null, - "revocable": false, - "desc": "Edit basic group settings (group status, visibility, registration)" - }, - "churchdb:+admin group member fields": { - "authId": 10117, - "scopeField": null, - "revocable": false, - "desc": "Manage group member fields" - }, - "churchdb:+edit group hierarchy": { - "authId": 10114, - "scopeField": null, - "revocable": false, - "desc": "Edit group hierarchy" - }, - "churchdb:+create group": { - "authId": 10115, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "Create and copy subgroups" - }, - "churchdb:+see hidden group": { - "authId": 10119, - "scopeField": null, - "revocable": false, - "desc": "View group when visibility is set to \"Hidden\"" - }, - "churchdb:+see group tags": { - "authId": 10105, - "scopeField": null, - "revocable": false, - "desc": "View tags of group" - }, - "churchdb:+see persons": { - "authId": 10101, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View group members" - }, - "churchdb:+edit person fields of group members": { - "authId": 10106, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "Edit person info fields of group members" - }, - "churchdb:+see group member fields": { - "authId": 10126, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View group member fields" - }, - "churchdb:+edit group member fields": { - "authId": 10133, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "Edit group member fields" - }, - "churchdb:+see own group member fields": { - "authId": 10132, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View your own group member fields" - }, - "churchdb:+edit own group member fields": { - "authId": 10125, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "Edit your own group member fields" - }, - "churchdb:+edit basic group memberships": { - "authId": 10108, - "scopeField": null, - "revocable": false, - "desc": "Edit group memberships (role, status, notes, etc.)" - }, - "churchdb:+add person": { - "authId": 10107, - "scopeField": null, - "revocable": false, - "desc": "Add group members" - }, - "churchdb:+remove from group": { - "authId": 10118, - "scopeField": null, - "revocable": false, - "desc": "Remove group members" - }, - "churchdb:+exit group": { - "authId": 10136, - "scopeField": null, - "revocable": false, - "desc": "Exit the group" - }, - "churchdb:+invite person": { - "authId": 10103, - "scopeField": null, - "revocable": false, - "desc": "Invite group members to ChurchTools" - }, - "churchdb:+export group members": { - "authId": 10112, - "scopeField": null, - "revocable": false, - "desc": "Allowed to export group members" - }, - "churchdb:+mail group members": { - "authId": 10113, - "scopeField": null, - "revocable": false, - "desc": "Contact group members" - }, - "churchdb:+see tags": { - "authId": 10104, - "scopeField": null, - "revocable": false, - "desc": "View tags of group members" - }, - "churchdb:+admin routines": { - "authId": 10135, - "scopeField": null, - "revocable": false, - "desc": "Manage routines" - }, - "churchdb:+admin followup": { - "authId": 10123, - "scopeField": null, - "revocable": false, - "desc": "Plan follow-ups" - }, - "churchdb:+do followup": { - "authId": 10110, - "scopeField": null, - "revocable": false, - "desc": "Carry out follow-ups" - }, - "churchdb:+admin meetings": { - "authId": 10124, - "scopeField": null, - "revocable": false, - "desc": "Edit settings for group meetings" - }, - "churchdb:+do group meeting": { - "authId": 10111, - "scopeField": null, - "revocable": false, - "desc": "View and execute group meetings" - }, - "churchdb:+create notes": { - "authId": 10134, - "scopeField": null, - "revocable": false, - "desc": "Create notes" - }, - "churchdb:+view history": { - "authId": 10128, - "scopeField": null, - "revocable": false, - "desc": "View history of group members" - }, - "churchdb:+admin group chat": { - "authId": 10121, - "scopeField": null, - "revocable": false, - "desc": "Manage group chat" - }, - "churchdb:+get emails": { - "authId": 10109, - "scopeField": null, - "revocable": false, - "desc": "Receive e-mail for changes" - }, - "churchdb:+admin posts": { - "authId": 10129, - "scopeField": null, - "revocable": false, - "desc": "Manage posts" - }, - "churchdb:+create post group intern": { - "authId": 10130, - "scopeField": null, - "revocable": true, - "desc": "Create posts: For group members" - }, - "churchdb:+create post group visible": { - "authId": 10131, - "scopeField": null, - "revocable": true, - "desc": "Create posts: With visibility of the group" - }, - "churchgroup:view": { - "authId": 1101, - "scopeField": null, - "revocable": false, - "desc": "View \"Groups\"" - }, - "churchgroup:view group": { - "authId": 1104, - "scopeField": "cdb_gruppe", - "revocable": false, - "desc": "View group incl. its group members" - }, - "churchgroup:edit group": { - "authId": 1105, - "scopeField": "cdb_gruppe", - "revocable": false, - "desc": "Edit group" - }, - "churchgroup:delete group": { - "authId": 1106, - "scopeField": "cdb_gruppe", - "revocable": false, - "desc": "Delete group" - }, - "churchgroup:create groups of grouptype": { - "authId": 1107, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "Create groups of grouptype" - }, - "churchgroup:view groups of grouptype": { - "authId": 1108, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "View groups of grouptype - except hidden ones" - }, - "churchgroup:edit groups of grouptype": { - "authId": 1109, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "Edit groups of grouptype - except hidden ones" - }, - "churchgroup:delete groups of grouptype": { - "authId": 1110, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "Delete groups of grouptype - except hidden ones" - }, - "churchgroup:edit group memberships of grouptype": { - "authId": 1111, - "scopeField": "cdb_gruppentyp", - "revocable": false, - "desc": "Edit group memberships of groups of a group type" - }, - "churchgroup:edit group memberships of group": { - "authId": 1112, - "scopeField": "cdb_gruppe", - "revocable": false, - "desc": "Edit group memberships of a group" - }, - "churchgroup:administer groups": { - "authId": 1113, - "scopeField": null, - "revocable": false, - "desc": "Administer groups - i.e. create, edit, delete, and more" - }, - "churchgroup:security level group": { - "authId": 1114, - "scopeField": "cc_securitylevel", - "revocable": false, - "desc": "View group infos - up to the selected security level" - }, - "churchgroup:view group history": { - "authId": 1102, - "scopeField": null, - "revocable": false, - "desc": "View history of group members" - }, - "churchgroup:view group tags": { - "authId": 1103, - "scopeField": null, - "revocable": false, - "desc": "View group tags" - }, - "churchgroup:administer global views": { - "authId": 1115, - "scopeField": null, - "revocable": false, - "desc": "Manage global saved views" - }, - "churchgroup:edit masterdata": { - "authId": 1116, - "scopeField": null, - "revocable": false, - "desc": "Manage module settings" - }, - "churchcal:view": { - "authId": 401, - "scopeField": null, - "revocable": false, - "desc": "View \"Calendar\"" - }, - "churchcal:view category": { - "authId": 403, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "View single calendar " - }, - "churchcal:edit category": { - "authId": 404, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "Create / edit events in single calendars" - }, - "churchcal:edit calendar entry template": { - "authId": 410, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "Create and edit appointment templates in individual calendars." - }, - "churchcal:assistance mode": { - "authId": 409, - "scopeField": null, - "revocable": false, - "desc": "Create bookings for others" - }, - "churchcal:create personal category": { - "authId": 407, - "scopeField": null, - "revocable": false, - "desc": "Create personal calendar" - }, - "churchcal:admin personal category": { - "authId": 406, - "scopeField": null, - "revocable": false, - "desc": "Administer personal calendar" - }, - "churchcal:create group category": { - "authId": 408, - "scopeField": null, - "revocable": false, - "desc": "Create group calendar" - }, - "churchcal:admin group category": { - "authId": 405, - "scopeField": null, - "revocable": false, - "desc": "Administer group calendar" - }, - "churchcal:admin church category": { - "authId": 402, - "scopeField": null, - "revocable": false, - "desc": "administer church calendar" - }, - "churchcal:admin appointment tags": { - "authId": 411, - "scopeField": null, - "revocable": false, - "desc": "Administer appointment tags" - }, - "churchresource:view": { - "authId": 201, - "scopeField": null, - "revocable": false, - "desc": "View \"Resources\"" - }, - "churchresource:view resource": { - "authId": 205, - "scopeField": "cr_resource", - "revocable": false, - "desc": "View Resource" - }, - "churchresource:create bookings": { - "authId": 206, - "scopeField": "cr_resource", - "revocable": false, - "desc": "Create own booking requests" - }, - "churchresource:create virtual bookings": { - "authId": 204, - "scopeField": null, - "revocable": false, - "desc": "Create requests for resources with multiple bookings" - }, - "churchresource:administer bookings": { - "authId": 202, - "scopeField": "cr_resource", - "revocable": false, - "desc": "Manage bookings" - }, - "churchresource:assistance mode": { - "authId": 203, - "scopeField": null, - "revocable": false, - "desc": "Create bookings for others" - }, - "churchresource:edit masterdata": { - "authId": 299, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "churchservice:view": { - "authId": 301, - "scopeField": null, - "revocable": false, - "desc": "View \"Events\"" - }, - "churchservice:view servicegroup": { - "authId": 304, - "scopeField": "cs_servicegroup", - "revocable": false, - "desc": "See roles of individual role categories" - }, - "churchservice:edit servicegroup": { - "authId": 305, - "scopeField": "cs_servicegroup", - "revocable": false, - "desc": "Edit roles of individual role categories" - }, - "churchservice:view history": { - "authId": 302, - "scopeField": null, - "revocable": false, - "desc": "View request history" - }, - "churchservice:view events": { - "authId": 306, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "View events from individual calendars" - }, - "churchservice:edit events": { - "authId": 303, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "Create, edit, delete events" - }, - "churchservice:edit template": { - "authId": 309, - "scopeField": null, - "revocable": false, - "desc": "Edit event templates" - }, - "churchservice:manage absent": { - "authId": 307, - "scopeField": null, - "revocable": false, - "desc": "View and manage absences for all people" - }, - "churchservice:view fact": { - "authId": 321, - "scopeField": "cs_fact", - "revocable": false, - "desc": "View facts" - }, - "churchservice:edit fact": { - "authId": 308, - "scopeField": "cs_fact", - "revocable": false, - "desc": "Edit facts" - }, - "churchservice:export facts": { - "authId": 322, - "scopeField": null, - "revocable": false, - "desc": "Export facts" - }, - "churchservice:view agenda": { - "authId": 331, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "View orders for single calendar" - }, - "churchservice:edit agenda": { - "authId": 332, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "Edit orders for single calendar" - }, - "churchservice:edit agenda templates": { - "authId": 333, - "scopeField": "cc_calcategory", - "revocable": false, - "desc": "Edit order templates for single calendar" - }, - "churchservice:view songcategory": { - "authId": 313, - "scopeField": "cs_songcategory", - "revocable": false, - "desc": "View single song category" - }, - "churchservice:edit songcategory": { - "authId": 312, - "scopeField": "cs_songcategory", - "revocable": false, - "desc": "Edit songs and upload files" - }, - "churchservice:view song statistics": { - "authId": 314, - "scopeField": null, - "revocable": false, - "desc": "view song statistics" - }, - "churchservice:use ccli": { - "authId": 341, - "scopeField": null, - "revocable": false, - "desc": "Is allowed to use CCLI in \"Events\"" - }, - "churchservice:edit masterdata": { - "authId": 399, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "churchservice:+view service": { - "authId": 10301, - "scopeField": null, - "revocable": false, - "desc": "View role assignments" - }, - "churchservice:+entry free": { - "authId": 10302, - "scopeField": null, - "revocable": false, - "desc": "Register for free roles" - }, - "churchservice:+entry all": { - "authId": 10303, - "scopeField": null, - "revocable": false, - "desc": "Planner, that means also edit role assignments" - }, - "churchservice:+edit service": { - "authId": 10304, - "scopeField": null, - "revocable": false, - "desc": "Add and remove roles to/from event" - }, - "churchservice:+admin service": { - "authId": 10305, - "scopeField": null, - "revocable": false, - "desc": "Edit role master data" - }, - "churchservice:+see workload": { - "authId": 10306, - "scopeField": null, - "revocable": false, - "desc": "View utilization" - }, - "churchservice:+see history": { - "authId": 10307, - "scopeField": null, - "revocable": false, - "desc": "View history of role assignments" - }, - "churchservice:+see reason": { - "authId": 10308, - "scopeField": null, - "revocable": false, - "desc": "View decline and commitment comments" - }, - "churchservice:+attach file": { - "authId": 10309, - "scopeField": null, - "revocable": false, - "desc": "Upload files to events" - }, - "churchservice:+edit info": { - "authId": 10310, - "scopeField": null, - "revocable": false, - "desc": "Edit event info" - }, - "churchservice:+edit event": { - "authId": 10311, - "scopeField": null, - "revocable": false, - "desc": "Edit event" - }, - "churchservice:+edit agenda": { - "authId": 10313, - "scopeField": null, - "revocable": false, - "desc": "Edit order" - }, - "churchservice:+view absence": { - "authId": 10321, - "scopeField": null, - "revocable": false, - "desc": "View absence" - }, - "churchservice:+start event chat": { - "authId": 10322, - "scopeField": null, - "revocable": false, - "desc": "Start Event Chat" - }, - "churchwiki:view": { - "authId": 501, - "scopeField": null, - "revocable": false, - "desc": "View \"Wiki\"" - }, - "churchwiki:view category": { - "authId": 502, - "scopeField": "cc_wikicategory", - "revocable": false, - "desc": "View single wiki category" - }, - "churchwiki:edit category": { - "authId": 503, - "scopeField": "cc_wikicategory", - "revocable": false, - "desc": "Edit single wiki category" - }, - "churchwiki:edit masterdata": { - "authId": 599, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "churchreport:view": { - "authId": 701, - "scopeField": null, - "revocable": false, - "desc": "View \"Report\"" - }, - "churchreport:view query": { - "authId": 702, - "scopeField": "crp_query", - "revocable": false, - "desc": "Call query" - }, - "churchreport:edit masterdata": { - "authId": 799, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "finance:view": { - "authId": 901, - "scopeField": null, - "revocable": false, - "desc": "View Finances " - }, - "finance:view accounting period": { - "authId": 902, - "scopeField": "cf_accounting_periods", - "revocable": false, - "desc": "View accounting years and clients" - }, - "finance:edit accounting period": { - "authId": 903, - "scopeField": "cf_accounting_periods", - "revocable": false, - "desc": "Creating and Editing transactions for the Accounting Year and Client" - }, - "finance:edit masterdata": { - "authId": 999, - "scopeField": null, - "revocable": false, - "desc": "Edit master data" - }, - "finance:+see cost center budgets": { - "authId": 10904, - "scopeField": null, - "revocable": false, - "desc": "View budgets in associated cost centers" - }, - "finance:+see cost centers": { - "authId": 10901, - "scopeField": null, - "revocable": false, - "desc": "View transactions and budgets in associated cost centers" - }, - "finance:+see donators in cost centers": { - "authId": 10903, - "scopeField": null, - "revocable": false, - "desc": "View donors in associated cost centers" - }, - "finance:+allow posting": { - "authId": 10902, - "scopeField": null, - "revocable": false, - "desc": "Create transactions for linked cost centers" - }, - "churchsync:view": { - "authId": 1001, - "scopeField": null, - "revocable": false, - "desc": "Sync view and administer" - }, - "jpmFlowManagerPreview:view": { - "authId": 2080, - "scopeField": null, - "revocable": false, - "desc": "View \"Flow 2.0 (Beta)\"" - }, - "jpmFlowManagerPreview:view custom category": { - "authId": 2081, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View categories" - }, - "jpmFlowManagerPreview:create custom category": { - "authId": 2082, - "scopeField": null, - "revocable": false, - "desc": "Create categories" - }, - "jpmFlowManagerPreview:edit custom category": { - "authId": 2083, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit categories" - }, - "jpmFlowManagerPreview:delete custom category": { - "authId": 2084, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete categories" - }, - "jpmFlowManagerPreview:view custom data": { - "authId": 2085, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View data in category" - }, - "jpmFlowManagerPreview:create custom data": { - "authId": 2086, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Create data in category" - }, - "jpmFlowManagerPreview:edit custom data": { - "authId": 2087, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit data in category" - }, - "jpmFlowManagerPreview:delete custom data": { - "authId": 2088, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete data in category" - }, - "group_hierarchies:view": { - "authId": 2070, - "scopeField": null, - "revocable": false, - "desc": "View \"Admin\"" - }, - "group_hierarchies:view custom category": { - "authId": 2071, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View categories" - }, - "group_hierarchies:create custom category": { - "authId": 2072, - "scopeField": null, - "revocable": false, - "desc": "Create categories" - }, - "group_hierarchies:edit custom category": { - "authId": 2073, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit categories" - }, - "group_hierarchies:delete custom category": { - "authId": 2074, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete categories" - }, - "group_hierarchies:view custom data": { - "authId": 2075, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View data in category" - }, - "group_hierarchies:create custom data": { - "authId": 2076, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Create data in category" - }, - "group_hierarchies:edit custom data": { - "authId": 2077, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit data in category" - }, - "group_hierarchies:delete custom data": { - "authId": 2078, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete data in category" - }, - "jpmFlowManager:view": { - "authId": 2050, - "scopeField": null, - "revocable": false, - "desc": "View \"Flow\"" - }, - "jpmFlowManager:view custom category": { - "authId": 2051, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View categories" - }, - "jpmFlowManager:create custom category": { - "authId": 2052, - "scopeField": null, - "revocable": false, - "desc": "Create categories" - }, - "jpmFlowManager:edit custom category": { - "authId": 2053, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit categories" - }, - "jpmFlowManager:delete custom category": { - "authId": 2054, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete categories" - }, - "jpmFlowManager:view custom data": { - "authId": 2055, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "View data in category" - }, - "jpmFlowManager:create custom data": { - "authId": 2056, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Create data in category" - }, - "jpmFlowManager:edit custom data": { - "authId": 2057, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Edit data in category" - }, - "jpmFlowManager:delete custom data": { - "authId": 2058, - "scopeField": "ccm_data_category", - "revocable": false, - "desc": "Delete data in category" - } -} \ No newline at end of file + "$meta": { + "capturedFrom": "eqrm.church.tools", + "ctVersion": "3.134.0", + "capturedAt": "2026-07-08", + "rightCount": 187, + "source": "POST /index.php?q=churchauth/ajax func=getMasterData", + "regenerate": "npm run regenerate:permission-catalog (see src/permissions/README.md)" + }, + "churchcore:administer settings": { + "authId": 1, + "scopeField": null, + "revocable": false, + "desc": "Edit system settings" + }, + "churchcore:edit public profiles": { + "authId": 10, + "scopeField": null, + "revocable": false, + "desc": "Manage public profiles" + }, + "churchcore:view website": { + "authId": 11, + "scopeField": null, + "revocable": false, + "desc": "View overview" + }, + "churchcore:edit website staff": { + "authId": 12, + "scopeField": null, + "revocable": false, + "desc": "Manage users" + }, + "churchcore:edit website releases": { + "authId": 13, + "scopeField": null, + "revocable": false, + "desc": "Manage permissions" + }, + "churchcore:administer persons": { + "authId": 2, + "scopeField": null, + "revocable": false, + "desc": "Manage permissions" + }, + "churchcore:view logfile": { + "authId": 3, + "scopeField": null, + "revocable": false, + "desc": "View log" + }, + "churchcore:edit languages": { + "authId": 9, + "scopeField": "cc_language", + "revocable": false, + "desc": "Edit translations" + }, + "churchcore:edit translations masterdata": { + "authId": 5, + "scopeField": null, + "revocable": false, + "desc": "Manage languages" + }, + "churchcore:simulate persons": { + "authId": 7, + "scopeField": null, + "revocable": false, + "desc": "Simulate persons, change password and block access" + }, + "churchcore:invite persons": { + "authId": 6, + "scopeField": null, + "revocable": false, + "desc": "Invite person to ChurchTools" + }, + "churchcore:administer church html templates": { + "authId": 16, + "scopeField": null, + "revocable": false, + "desc": "Manage church HTML templates" + }, + "churchcore:use church html templates": { + "authId": 17, + "scopeField": "cc_html_template", + "revocable": false, + "desc": "Use church HTML templates" + }, + "churchcore:view links": { + "authId": 19, + "scopeField": "cc_further_link", + "revocable": false, + "desc": "View the entries in the links-widget" + }, + "churchcore:login to external system": { + "authId": 18, + "scopeField": "oauth_client", + "revocable": false, + "desc": "Log in to external system with ChurchTools" + }, + "churchcore:administer custom modules": { + "authId": 15, + "scopeField": null, + "revocable": false, + "desc": "Manage extensions" + }, + "churchcheckin:view": { + "authId": 601, + "scopeField": null, + "revocable": false, + "desc": "View \"Check-in\"" + }, + "churchcheckin:create person": { + "authId": 602, + "scopeField": null, + "revocable": false, + "desc": "Create people" + }, + "churchcheckin:edit masterdata": { + "authId": 699, + "scopeField": null, + "revocable": false, + "desc": "Manage printer" + }, + "churchdb:view": { + "authId": 101, + "scopeField": null, + "revocable": false, + "desc": "View \"People\"" + }, + "churchdb:view station": { + "authId": 124, + "scopeField": "cdb_station", + "revocable": false, + "desc": "View campus" + }, + "churchdb:view alldata": { + "authId": 102, + "scopeField": "cdb_bereich", + "revocable": false, + "desc": "View people of a department" + }, + "churchdb:security level person": { + "authId": 125, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View person data - up to the selected security level" + }, + "churchdb:security level view own data": { + "authId": 131, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View your own personal data - up to the selected security level" + }, + "churchdb:security level edit own data": { + "authId": 132, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "Edit own personal data - up to the selected security level" + }, + "churchdb:create person": { + "authId": 119, + "scopeField": null, + "revocable": false, + "desc": "Create people" + }, + "churchdb:write access": { + "authId": 111, + "scopeField": null, + "revocable": false, + "desc": "Edit people" + }, + "churchdb:delete persons": { + "authId": 129, + "scopeField": null, + "revocable": false, + "desc": "Delete persons" + }, + "churchdb:push/pull archive": { + "authId": 118, + "scopeField": null, + "revocable": false, + "desc": "Archive people" + }, + "churchdb:view archive": { + "authId": 116, + "scopeField": null, + "revocable": false, + "desc": "View archive of people" + }, + "churchdb:view statistics": { + "authId": 106, + "scopeField": null, + "revocable": false, + "desc": "View person statistics" + }, + "churchdb:view person tags": { + "authId": 107, + "scopeField": null, + "revocable": false, + "desc": "View tags of people " + }, + "churchdb:view comments": { + "authId": 113, + "scopeField": "cdb_comment_viewer", + "revocable": false, + "desc": "View comments of people" + }, + "churchdb:export data": { + "authId": 112, + "scopeField": null, + "revocable": false, + "desc": "Export data of people" + }, + "churchdb:edit bulkletter": { + "authId": 128, + "scopeField": null, + "revocable": false, + "desc": "Create bulk letters" + }, + "churchdb:create print labels": { + "authId": 130, + "scopeField": null, + "revocable": false, + "desc": "Create labels" + }, + "churchdb:send sms": { + "authId": 117, + "scopeField": null, + "revocable": false, + "desc": "Use SMS interface" + }, + "churchdb:view person history": { + "authId": 108, + "scopeField": null, + "revocable": false, + "desc": "View person history" + }, + "churchdb:edit relations": { + "authId": 109, + "scopeField": null, + "revocable": false, + "desc": "Edit relations" + }, + "churchdb:view memberliste": { + "authId": 122, + "scopeField": null, + "revocable": false, + "desc": "View member list" + }, + "churchdb:complex filter": { + "authId": 120, + "scopeField": null, + "revocable": false, + "desc": "Use \"additional filters\"" + }, + "churchdb:administer global filters": { + "authId": 127, + "scopeField": null, + "revocable": false, + "desc": "Administer global filters" + }, + "churchdb:edit masterdata": { + "authId": 199, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "churchdb:+see group": { + "authId": 10102, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View group infos" + }, + "churchdb:+edit group infos": { + "authId": 10122, + "scopeField": null, + "revocable": false, + "desc": "Edit group info" + }, + "churchdb:+edit group basic settings": { + "authId": 10116, + "scopeField": null, + "revocable": false, + "desc": "Edit basic group settings (group status, visibility, registration)" + }, + "churchdb:+admin group member fields": { + "authId": 10117, + "scopeField": null, + "revocable": false, + "desc": "Manage group member fields" + }, + "churchdb:+edit group hierarchy": { + "authId": 10114, + "scopeField": null, + "revocable": false, + "desc": "Edit group hierarchy" + }, + "churchdb:+create group": { + "authId": 10115, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "Create and copy subgroups" + }, + "churchdb:+see hidden group": { + "authId": 10119, + "scopeField": null, + "revocable": false, + "desc": "View group when visibility is set to \"Hidden\"" + }, + "churchdb:+see group tags": { + "authId": 10105, + "scopeField": null, + "revocable": false, + "desc": "View tags of group" + }, + "churchdb:+see persons": { + "authId": 10101, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View group members" + }, + "churchdb:+edit person fields of group members": { + "authId": 10106, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "Edit person info fields of group members" + }, + "churchdb:+see group member fields": { + "authId": 10126, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View group member fields" + }, + "churchdb:+edit group member fields": { + "authId": 10133, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "Edit group member fields" + }, + "churchdb:+see own group member fields": { + "authId": 10132, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View your own group member fields" + }, + "churchdb:+edit own group member fields": { + "authId": 10125, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "Edit your own group member fields" + }, + "churchdb:+edit basic group memberships": { + "authId": 10108, + "scopeField": null, + "revocable": false, + "desc": "Edit group memberships (role, status, notes, etc.)" + }, + "churchdb:+add person": { + "authId": 10107, + "scopeField": null, + "revocable": false, + "desc": "Add group members" + }, + "churchdb:+remove from group": { + "authId": 10118, + "scopeField": null, + "revocable": false, + "desc": "Remove group members" + }, + "churchdb:+exit group": { + "authId": 10136, + "scopeField": null, + "revocable": false, + "desc": "Exit the group" + }, + "churchdb:+invite person": { + "authId": 10103, + "scopeField": null, + "revocable": false, + "desc": "Invite group members to ChurchTools" + }, + "churchdb:+export group members": { + "authId": 10112, + "scopeField": null, + "revocable": false, + "desc": "Allowed to export group members" + }, + "churchdb:+mail group members": { + "authId": 10113, + "scopeField": null, + "revocable": false, + "desc": "Contact group members" + }, + "churchdb:+see tags": { + "authId": 10104, + "scopeField": null, + "revocable": false, + "desc": "View tags of group members" + }, + "churchdb:+admin routines": { + "authId": 10135, + "scopeField": null, + "revocable": false, + "desc": "Manage routines" + }, + "churchdb:+admin followup": { + "authId": 10123, + "scopeField": null, + "revocable": false, + "desc": "Plan follow-ups" + }, + "churchdb:+do followup": { + "authId": 10110, + "scopeField": null, + "revocable": false, + "desc": "Carry out follow-ups" + }, + "churchdb:+admin meetings": { + "authId": 10124, + "scopeField": null, + "revocable": false, + "desc": "Edit settings for group meetings" + }, + "churchdb:+do group meeting": { + "authId": 10111, + "scopeField": null, + "revocable": false, + "desc": "View and execute group meetings" + }, + "churchdb:+create notes": { + "authId": 10134, + "scopeField": null, + "revocable": false, + "desc": "Create notes" + }, + "churchdb:+view history": { + "authId": 10128, + "scopeField": null, + "revocable": false, + "desc": "View history of group members" + }, + "churchdb:+admin group chat": { + "authId": 10121, + "scopeField": null, + "revocable": false, + "desc": "Manage group chat" + }, + "churchdb:+get emails": { + "authId": 10109, + "scopeField": null, + "revocable": false, + "desc": "Receive e-mail for changes" + }, + "churchdb:+admin posts": { + "authId": 10129, + "scopeField": null, + "revocable": false, + "desc": "Manage posts" + }, + "churchdb:+create post group intern": { + "authId": 10130, + "scopeField": null, + "revocable": true, + "desc": "Create posts: For group members" + }, + "churchdb:+create post group visible": { + "authId": 10131, + "scopeField": null, + "revocable": true, + "desc": "Create posts: With visibility of the group" + }, + "churchgroup:view": { + "authId": 1101, + "scopeField": null, + "revocable": false, + "desc": "View \"Groups\"" + }, + "churchgroup:view group": { + "authId": 1104, + "scopeField": "cdb_gruppe", + "revocable": false, + "desc": "View group incl. its group members" + }, + "churchgroup:edit group": { + "authId": 1105, + "scopeField": "cdb_gruppe", + "revocable": false, + "desc": "Edit group" + }, + "churchgroup:delete group": { + "authId": 1106, + "scopeField": "cdb_gruppe", + "revocable": false, + "desc": "Delete group" + }, + "churchgroup:create groups of grouptype": { + "authId": 1107, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "Create groups of grouptype" + }, + "churchgroup:view groups of grouptype": { + "authId": 1108, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "View groups of grouptype - except hidden ones" + }, + "churchgroup:edit groups of grouptype": { + "authId": 1109, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "Edit groups of grouptype - except hidden ones" + }, + "churchgroup:delete groups of grouptype": { + "authId": 1110, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "Delete groups of grouptype - except hidden ones" + }, + "churchgroup:edit group memberships of grouptype": { + "authId": 1111, + "scopeField": "cdb_gruppentyp", + "revocable": false, + "desc": "Edit group memberships of groups of a group type" + }, + "churchgroup:edit group memberships of group": { + "authId": 1112, + "scopeField": "cdb_gruppe", + "revocable": false, + "desc": "Edit group memberships of a group" + }, + "churchgroup:administer groups": { + "authId": 1113, + "scopeField": null, + "revocable": false, + "desc": "Administer groups - i.e. create, edit, delete, and more" + }, + "churchgroup:security level group": { + "authId": 1114, + "scopeField": "cc_securitylevel", + "revocable": false, + "desc": "View group infos - up to the selected security level" + }, + "churchgroup:view group history": { + "authId": 1102, + "scopeField": null, + "revocable": false, + "desc": "View history of group members" + }, + "churchgroup:view group tags": { + "authId": 1103, + "scopeField": null, + "revocable": false, + "desc": "View group tags" + }, + "churchgroup:administer global views": { + "authId": 1115, + "scopeField": null, + "revocable": false, + "desc": "Manage global saved views" + }, + "churchgroup:edit masterdata": { + "authId": 1116, + "scopeField": null, + "revocable": false, + "desc": "Manage module settings" + }, + "churchcal:view": { + "authId": 401, + "scopeField": null, + "revocable": false, + "desc": "View \"Calendar\"" + }, + "churchcal:view category": { + "authId": 403, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "View single calendar " + }, + "churchcal:edit category": { + "authId": 404, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "Create / edit events in single calendars" + }, + "churchcal:edit calendar entry template": { + "authId": 410, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "Create and edit appointment templates in individual calendars." + }, + "churchcal:assistance mode": { + "authId": 409, + "scopeField": null, + "revocable": false, + "desc": "Create bookings for others" + }, + "churchcal:create personal category": { + "authId": 407, + "scopeField": null, + "revocable": false, + "desc": "Create personal calendar" + }, + "churchcal:admin personal category": { + "authId": 406, + "scopeField": null, + "revocable": false, + "desc": "Administer personal calendar" + }, + "churchcal:create group category": { + "authId": 408, + "scopeField": null, + "revocable": false, + "desc": "Create group calendar" + }, + "churchcal:admin group category": { + "authId": 405, + "scopeField": null, + "revocable": false, + "desc": "Administer group calendar" + }, + "churchcal:admin church category": { + "authId": 402, + "scopeField": null, + "revocable": false, + "desc": "administer church calendar" + }, + "churchcal:admin appointment tags": { + "authId": 411, + "scopeField": null, + "revocable": false, + "desc": "Administer appointment tags" + }, + "churchresource:view": { + "authId": 201, + "scopeField": null, + "revocable": false, + "desc": "View \"Resources\"" + }, + "churchresource:view resource": { + "authId": 205, + "scopeField": "cr_resource", + "revocable": false, + "desc": "View Resource" + }, + "churchresource:create bookings": { + "authId": 206, + "scopeField": "cr_resource", + "revocable": false, + "desc": "Create own booking requests" + }, + "churchresource:create virtual bookings": { + "authId": 204, + "scopeField": null, + "revocable": false, + "desc": "Create requests for resources with multiple bookings" + }, + "churchresource:administer bookings": { + "authId": 202, + "scopeField": "cr_resource", + "revocable": false, + "desc": "Manage bookings" + }, + "churchresource:assistance mode": { + "authId": 203, + "scopeField": null, + "revocable": false, + "desc": "Create bookings for others" + }, + "churchresource:edit masterdata": { + "authId": 299, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "churchservice:view": { + "authId": 301, + "scopeField": null, + "revocable": false, + "desc": "View \"Events\"" + }, + "churchservice:view servicegroup": { + "authId": 304, + "scopeField": "cs_servicegroup", + "revocable": false, + "desc": "See roles of individual role categories" + }, + "churchservice:edit servicegroup": { + "authId": 305, + "scopeField": "cs_servicegroup", + "revocable": false, + "desc": "Edit roles of individual role categories" + }, + "churchservice:view history": { + "authId": 302, + "scopeField": null, + "revocable": false, + "desc": "View request history" + }, + "churchservice:view events": { + "authId": 306, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "View events from individual calendars" + }, + "churchservice:edit events": { + "authId": 303, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "Create, edit, delete events" + }, + "churchservice:edit template": { + "authId": 309, + "scopeField": null, + "revocable": false, + "desc": "Edit event templates" + }, + "churchservice:manage absent": { + "authId": 307, + "scopeField": null, + "revocable": false, + "desc": "View and manage absences for all people" + }, + "churchservice:view fact": { + "authId": 321, + "scopeField": "cs_fact", + "revocable": false, + "desc": "View facts" + }, + "churchservice:edit fact": { + "authId": 308, + "scopeField": "cs_fact", + "revocable": false, + "desc": "Edit facts" + }, + "churchservice:export facts": { + "authId": 322, + "scopeField": null, + "revocable": false, + "desc": "Export facts" + }, + "churchservice:view agenda": { + "authId": 331, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "View orders for single calendar" + }, + "churchservice:edit agenda": { + "authId": 332, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "Edit orders for single calendar" + }, + "churchservice:edit agenda templates": { + "authId": 333, + "scopeField": "cc_calcategory", + "revocable": false, + "desc": "Edit order templates for single calendar" + }, + "churchservice:view songcategory": { + "authId": 313, + "scopeField": "cs_songcategory", + "revocable": false, + "desc": "View single song category" + }, + "churchservice:edit songcategory": { + "authId": 312, + "scopeField": "cs_songcategory", + "revocable": false, + "desc": "Edit songs and upload files" + }, + "churchservice:view song statistics": { + "authId": 314, + "scopeField": null, + "revocable": false, + "desc": "view song statistics" + }, + "churchservice:use ccli": { + "authId": 341, + "scopeField": null, + "revocable": false, + "desc": "Is allowed to use CCLI in \"Events\"" + }, + "churchservice:edit masterdata": { + "authId": 399, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "churchservice:+view service": { + "authId": 10301, + "scopeField": null, + "revocable": false, + "desc": "View role assignments" + }, + "churchservice:+entry free": { + "authId": 10302, + "scopeField": null, + "revocable": false, + "desc": "Register for free roles" + }, + "churchservice:+entry all": { + "authId": 10303, + "scopeField": null, + "revocable": false, + "desc": "Planner, that means also edit role assignments" + }, + "churchservice:+edit service": { + "authId": 10304, + "scopeField": null, + "revocable": false, + "desc": "Add and remove roles to/from event" + }, + "churchservice:+admin service": { + "authId": 10305, + "scopeField": null, + "revocable": false, + "desc": "Edit role master data" + }, + "churchservice:+see workload": { + "authId": 10306, + "scopeField": null, + "revocable": false, + "desc": "View utilization" + }, + "churchservice:+see history": { + "authId": 10307, + "scopeField": null, + "revocable": false, + "desc": "View history of role assignments" + }, + "churchservice:+see reason": { + "authId": 10308, + "scopeField": null, + "revocable": false, + "desc": "View decline and commitment comments" + }, + "churchservice:+attach file": { + "authId": 10309, + "scopeField": null, + "revocable": false, + "desc": "Upload files to events" + }, + "churchservice:+edit info": { + "authId": 10310, + "scopeField": null, + "revocable": false, + "desc": "Edit event info" + }, + "churchservice:+edit event": { + "authId": 10311, + "scopeField": null, + "revocable": false, + "desc": "Edit event" + }, + "churchservice:+edit agenda": { + "authId": 10313, + "scopeField": null, + "revocable": false, + "desc": "Edit order" + }, + "churchservice:+view absence": { + "authId": 10321, + "scopeField": null, + "revocable": false, + "desc": "View absence" + }, + "churchservice:+start event chat": { + "authId": 10322, + "scopeField": null, + "revocable": false, + "desc": "Start Event Chat" + }, + "churchwiki:view": { + "authId": 501, + "scopeField": null, + "revocable": false, + "desc": "View \"Wiki\"" + }, + "churchwiki:view category": { + "authId": 502, + "scopeField": "cc_wikicategory", + "revocable": false, + "desc": "View single wiki category" + }, + "churchwiki:edit category": { + "authId": 503, + "scopeField": "cc_wikicategory", + "revocable": false, + "desc": "Edit single wiki category" + }, + "churchwiki:edit masterdata": { + "authId": 599, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "churchreport:view": { + "authId": 701, + "scopeField": null, + "revocable": false, + "desc": "View \"Report\"" + }, + "churchreport:view query": { + "authId": 702, + "scopeField": "crp_query", + "revocable": false, + "desc": "Call query" + }, + "churchreport:edit masterdata": { + "authId": 799, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "finance:view": { + "authId": 901, + "scopeField": null, + "revocable": false, + "desc": "View Finances " + }, + "finance:view accounting period": { + "authId": 902, + "scopeField": "cf_accounting_periods", + "revocable": false, + "desc": "View accounting years and clients" + }, + "finance:edit accounting period": { + "authId": 903, + "scopeField": "cf_accounting_periods", + "revocable": false, + "desc": "Creating and Editing transactions for the Accounting Year and Client" + }, + "finance:edit masterdata": { + "authId": 999, + "scopeField": null, + "revocable": false, + "desc": "Edit master data" + }, + "finance:+see cost center budgets": { + "authId": 10904, + "scopeField": null, + "revocable": false, + "desc": "View budgets in associated cost centers" + }, + "finance:+see cost centers": { + "authId": 10901, + "scopeField": null, + "revocable": false, + "desc": "View transactions and budgets in associated cost centers" + }, + "finance:+see donators in cost centers": { + "authId": 10903, + "scopeField": null, + "revocable": false, + "desc": "View donors in associated cost centers" + }, + "finance:+allow posting": { + "authId": 10902, + "scopeField": null, + "revocable": false, + "desc": "Create transactions for linked cost centers" + }, + "churchsync:view": { + "authId": 1001, + "scopeField": null, + "revocable": false, + "desc": "Sync view and administer" + }, + "jpmFlowManagerPreview:view": { + "authId": 2080, + "scopeField": null, + "revocable": false, + "desc": "View \"Flow 2.0 (Beta)\"" + }, + "jpmFlowManagerPreview:view custom category": { + "authId": 2081, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View categories" + }, + "jpmFlowManagerPreview:create custom category": { + "authId": 2082, + "scopeField": null, + "revocable": false, + "desc": "Create categories" + }, + "jpmFlowManagerPreview:edit custom category": { + "authId": 2083, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit categories" + }, + "jpmFlowManagerPreview:delete custom category": { + "authId": 2084, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete categories" + }, + "jpmFlowManagerPreview:view custom data": { + "authId": 2085, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View data in category" + }, + "jpmFlowManagerPreview:create custom data": { + "authId": 2086, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Create data in category" + }, + "jpmFlowManagerPreview:edit custom data": { + "authId": 2087, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit data in category" + }, + "jpmFlowManagerPreview:delete custom data": { + "authId": 2088, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete data in category" + }, + "group_hierarchies:view": { + "authId": 2070, + "scopeField": null, + "revocable": false, + "desc": "View \"Admin\"" + }, + "group_hierarchies:view custom category": { + "authId": 2071, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View categories" + }, + "group_hierarchies:create custom category": { + "authId": 2072, + "scopeField": null, + "revocable": false, + "desc": "Create categories" + }, + "group_hierarchies:edit custom category": { + "authId": 2073, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit categories" + }, + "group_hierarchies:delete custom category": { + "authId": 2074, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete categories" + }, + "group_hierarchies:view custom data": { + "authId": 2075, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View data in category" + }, + "group_hierarchies:create custom data": { + "authId": 2076, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Create data in category" + }, + "group_hierarchies:edit custom data": { + "authId": 2077, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit data in category" + }, + "group_hierarchies:delete custom data": { + "authId": 2078, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete data in category" + }, + "jpmFlowManager:view": { + "authId": 2050, + "scopeField": null, + "revocable": false, + "desc": "View \"Flow\"" + }, + "jpmFlowManager:view custom category": { + "authId": 2051, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View categories" + }, + "jpmFlowManager:create custom category": { + "authId": 2052, + "scopeField": null, + "revocable": false, + "desc": "Create categories" + }, + "jpmFlowManager:edit custom category": { + "authId": 2053, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit categories" + }, + "jpmFlowManager:delete custom category": { + "authId": 2054, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete categories" + }, + "jpmFlowManager:view custom data": { + "authId": 2055, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "View data in category" + }, + "jpmFlowManager:create custom data": { + "authId": 2056, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Create data in category" + }, + "jpmFlowManager:edit custom data": { + "authId": 2057, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Edit data in category" + }, + "jpmFlowManager:delete custom data": { + "authId": 2058, + "scopeField": "ccm_data_category", + "revocable": false, + "desc": "Delete data in category" + } +} diff --git a/src/permissions/catalog.ts b/src/permissions/catalog.ts index 21d54c8..6bd6e19 100644 --- a/src/permissions/catalog.ts +++ b/src/permissions/catalog.ts @@ -9,7 +9,12 @@ */ import catalogData from "./catalog.json" with { type: "json" }; -export interface CatalogEntry { authId: number; scopeField: string | null; revocable: boolean; desc: string } +export interface CatalogEntry { + authId: number; + scopeField: string | null; + revocable: boolean; + desc: string; +} /** * Provenance for the catalog (#25). Recorded as a reserved top-level `$meta` key in catalog.json so @@ -121,7 +126,9 @@ export function resolveAuthId(name: string): CatalogEntry { const entry = CATALOG[name]; if (!entry) { const [mod] = name.split(":"); - const near = Object.keys(CATALOG).filter((k) => k.startsWith(`${mod}:`)).slice(0, 6); + const near = Object.keys(CATALOG) + .filter((k) => k.startsWith(`${mod}:`)) + .slice(0, 6); const hint = near.length ? ` Did you mean one of: ${near.join(", ")}?` : ""; throw new Error(`Unknown permission "${name}".${hint}`); } diff --git a/src/permissions/grants.ts b/src/permissions/grants.ts index 893fbe4..005ecec 100644 --- a/src/permissions/grants.ts +++ b/src/permissions/grants.ts @@ -49,8 +49,12 @@ export interface GrantTuple { pending?: boolean; } export interface RawPermission { - authId: number; dataId: number | null; type: "grant" | "revoke"; domainId: number; - isInherited?: boolean; meta?: { modifiedPid?: number }; + authId: number; + dataId: number | null; + type: "grant" | "revoke"; + domainId: number; + isInherited?: boolean; + meta?: { modifiedPid?: number }; } /** @@ -59,8 +63,15 @@ export interface RawPermission { * and guarantees it can never collide with an actual row (actuals never carry a scopeKey), so it * always lands in `toPut`. */ -export function tupleKey(t: { authId: number; dataId: number[]; type: string; scopeKey?: string; pending?: boolean }): string { - const scope = t.pending && t.scopeKey != null ? `pending:${t.scopeKey}` : [...t.dataId].sort((a, b) => a - b).join(","); +export function tupleKey(t: { + authId: number; + dataId: number[]; + type: string; + scopeKey?: string; + pending?: boolean; +}): string { + const scope = + t.pending && t.scopeKey != null ? `pending:${t.scopeKey}` : [...t.dataId].sort((a, b) => a - b).join(","); return `${t.type}:${t.authId}:${scope}`; } @@ -68,7 +79,7 @@ export function normalizeActual(rows: RawPermission[]): GrantTuple[] { const out: GrantTuple[] = []; for (const r of rows) { if (r.meta?.modifiedPid === -1) continue; // system baseline — invisible to reconciliation - if (r.isInherited) continue; // inherited — not directly owned here + if (r.isInherited) continue; // inherited — not directly owned here // dataId is [] or a single element (CT reads scoped grants back one row per dataId), so there is // nothing to sort here — and tupleKey sorts defensively anyway when it builds the identity key. out.push({ authId: r.authId, dataId: r.dataId == null ? [] : [r.dataId], type: r.type }); diff --git a/src/permissions/plan.ts b/src/permissions/plan.ts index c1c4d87..749c77b 100644 --- a/src/permissions/plan.ts +++ b/src/permissions/plan.ts @@ -40,7 +40,13 @@ import { isPendingRef, refKey, refLabel, type Ref } from "../resolve/refs.js"; * (#20/#46) and the scope pending path (#29). A pending domain has no live grants yet, so its diff * is `desired → toPut` against an empty actual set. */ -export interface PermissionPlanItem { key: string; domainType: DomainType; domainId: number | null; pendingDomain?: Ref; diff: GrantDiff } +export interface PermissionPlanItem { + key: string; + domainType: DomainType; + domainId: number | null; + pendingDomain?: Ref; + diff: GrantDiff; +} /** * Fan out each grant to (authId, dataId) tuples. ChurchTools reads a scoped grant back as @@ -69,7 +75,9 @@ export function desiredTuples( return [{ authId: entry.authId, dataId: [], type: "grant" as const }]; } if (entry.scopeField == null) { - throw new Error(`${p.domainType} "${p.key}": "${name}" is not a scoped right (no scopeField) — remove "scope" or use a scoped right.`); + throw new Error( + `${p.domainType} "${p.key}": "${name}" is not a scoped right (no scopeField) — remove "scope" or use a scoped right.`, + ); } // Retain the symbolic scopeKey (and its managed resource type, #98) on every scoped tuple so its // dataId is re-resolved against post-execute state at apply time. `id === null` means the target is @@ -84,7 +92,14 @@ export function desiredTuples( }); return scoped.map(({ key, id, numeric, type }) => id === null - ? { authId: entry.authId, dataId: [], type: "grant" as const, scopeKey: key, scopeType: type, pending: true } + ? { + authId: entry.authId, + dataId: [], + type: "grant" as const, + scopeKey: key, + scopeType: type, + pending: true, + } : numeric ? { authId: entry.authId, dataId: [id], type: "grant" as const } : { authId: entry.authId, dataId: [id], type: "grant" as const, scopeKey: key, scopeType: type }, @@ -100,7 +115,9 @@ export function desiredTuples( * catalog cannot name never reaches here — `buildPermissionPlan` already excludes those from the diff * entirely (they are warned about and left untouched, never revoked). */ -export function preservePredicateFor(preserveUnknown: PreserveUnknown | undefined): PreservePredicate | undefined { +export function preservePredicateFor( + preserveUnknown: PreserveUnknown | undefined, +): PreservePredicate | undefined { if (preserveUnknown === undefined || preserveUnknown === false) return undefined; if (preserveUnknown === true) return () => true; const dimensions = new Set(preserveUnknown); @@ -137,7 +154,8 @@ type ResolvedPermission = * would otherwise each diff against the other's grants and churn forever. Mirrors config/context.ts. */ async function resolveDomainIds( - permissions: DesiredPermission[], resolver: Resolver, + permissions: DesiredPermission[], + resolver: Resolver, ): Promise { const resolved: ResolvedPermission[] = []; for (const p of permissions) { @@ -179,8 +197,12 @@ async function resolveDomainIds( } export async function buildPermissionPlan( - client: Pick, state: State, permissions: DesiredPermission[], desired: DesiredResource[] = [], - resolver?: Resolver, instanceVersion?: string, + client: Pick, + state: State, + permissions: DesiredPermission[], + desired: DesiredResource[] = [], + resolver?: Resolver, + instanceVersion?: string, ): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[]; warnings: string[] }> { const items: PermissionPlanItem[] = []; const fetchErrors: string[] = []; @@ -243,7 +265,10 @@ export async function buildPermissionPlan( pendingDomain: p.pendingDomain, // domainId is irrelevant to desiredTuples (it only reads key/domainType/grants); pass the // pending Ref through so the shape stays a valid DesiredPermission. - diff: diffGrants(desiredTuples({ ...p, domainId: p.pendingDomain }, state, declaredGroupKeys, scopeRefs), []), + diff: diffGrants( + desiredTuples({ ...p, domainId: p.pendingDomain }, state, declaredGroupKeys, scopeRefs), + [], + ), }); continue; } diff --git a/src/permissions/render.ts b/src/permissions/render.ts index 3086b0b..2a2298c 100644 --- a/src/permissions/render.ts +++ b/src/permissions/render.ts @@ -13,9 +13,7 @@ import { refLabel } from "../resolve/refs.js"; * resource pending-ref rendering (src/engine/render.ts). Its real id is filled in at apply time. */ function fmtDomain(item: PermissionPlanItem): string { - return item.pendingDomain - ? `<${refLabel(item.pendingDomain)} (created this apply)>` - : `#${item.domainId}`; + return item.pendingDomain ? `<${refLabel(item.pendingDomain)} (created this apply)>` : `#${item.domainId}`; } function fmtTuple(t: GrantTuple): string { @@ -38,9 +36,7 @@ export function renderPermissionPlan(items: PermissionPlanItem[]): string { if (changed.length === 0) { const preserved = items.reduce((n, i) => n + i.diff.preserved.length, 0); - const suffix = preserved > 0 - ? ` (${preserved} pre-existing deny row(s) left untouched.)` - : ""; + const suffix = preserved > 0 ? ` (${preserved} pre-existing deny row(s) left untouched.)` : ""; return pc.green(`No permission changes. Desired grants match ChurchTools.${suffix}`); } @@ -78,6 +74,8 @@ export function renderPermissionPlan(items: PermissionPlanItem[]): string { // Preserved grants are counted separately and never folded into the change totals: they are // explicitly what apply will NOT do. const preservedSummary = totalPreserved > 0 ? `, ${totalPreserved} preserved (not managed)` : ""; - lines.push(pc.bold(`Permission plan: ${totalGrant} to grant, ${totalRevoke} to remove${preservedSummary}.`)); + lines.push( + pc.bold(`Permission plan: ${totalGrant} to grant, ${totalRevoke} to remove${preservedSummary}.`), + ); return lines.join("\n"); } diff --git a/src/permissions/scope.ts b/src/permissions/scope.ts index 18073d1..bb80078 100644 --- a/src/permissions/scope.ts +++ b/src/permissions/scope.ts @@ -2,7 +2,16 @@ import type { State } from "../state/state.js"; import type { GrantTuple } from "./grants.js"; import type { DesiredPermission, ScopeEntry } from "./types.js"; import type { Resolver } from "../resolve/resolver.js"; -import { isPendingRef, isRef, ref, refKey, refLabel, type Ref, type RefKind, type SimpleRef } from "../resolve/refs.js"; +import { + isPendingRef, + isRef, + ref, + refKey, + refLabel, + type Ref, + type RefKind, + type SimpleRef, +} from "../resolve/refs.js"; import { resolveAuthId } from "./catalog.js"; /** @@ -14,7 +23,12 @@ import { resolveAuthId } from "./catalog.js"; * unchanged when it has no `scopeKey`). `type` is the MANAGED RESOURCE TYPE behind `key` (#98): * "group" for the historical group dimension, "campus"/"group-type" for a typed logical scope ref. */ -export interface ScopeResolution { key: string; id: number | null; numeric?: boolean; type?: string } +export interface ScopeResolution { + key: string; + id: number | null; + numeric?: boolean; + type?: string; +} /** * ChurchTools' "every value of this dimension" dataId. CT both accepts it on write and reads it back @@ -117,7 +131,11 @@ export function normalizeScopeEntry(entry: unknown, where: string): string | num * state-backed identity to re-resolve at apply time. A ref that resolved through a live master-data * catalog carries neither: its id is already final and host-correct. */ -export interface ScopeRefResolution { id: number | null; managedKey?: string; managedType?: string } +export interface ScopeRefResolution { + id: number | null; + managedKey?: string; + managedType?: string; +} /** Typed scope refs resolved for this host, keyed by {@link refKey}. */ export type ScopeRefMap = ReadonlyMap; diff --git a/src/permissions/types.ts b/src/permissions/types.ts index 050ab53..59b7515 100644 --- a/src/permissions/types.ts +++ b/src/permissions/types.ts @@ -14,10 +14,7 @@ import type { Ref } from "../resolve/refs.js"; * (see `normalizeScopeEntry` in src/permissions/scope.ts), so `ref.campus("koblenz")` is identical. */ export type ScopeSugar = - | { group: string } - | { campus: string } - | { groupType: string } - | { department: string }; + { group: string } | { campus: string } | { groupType: string } | { department: string }; /** * One entry of a scoped grant's `scope`: diff --git a/src/resolve/refs.ts b/src/resolve/refs.ts index 43e716e..df6392a 100644 --- a/src/resolve/refs.ts +++ b/src/resolve/refs.ts @@ -107,17 +107,37 @@ export const ref = { * So a department can be REFERENCED by name on any host, but never declared, adopted or created — * an unresolvable name is a hard error, not a create. */ - department: (key: string): SimpleRef => ({ __ctRef: true, kind: "department", key: requireKey("department", key) }), - groupType: (key: string): SimpleRef => ({ __ctRef: true, kind: "group-type", key: requireKey("group-type", key) }), - status: (key: string): SimpleRef => ({ __ctRef: true, kind: "group-status", key: requireKey("group-status", key) }), + department: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "department", + key: requireKey("department", key), + }), + groupType: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "group-type", + key: requireKey("group-type", key), + }), + status: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "group-status", + key: requireKey("group-status", key), + }), /** * 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. */ - personStatus: (key: string): SimpleRef => ({ __ctRef: true, kind: "person-status", key: requireKey("person-status", key) }), - roleDef: (key: string): SimpleRef => ({ __ctRef: true, kind: "role-def", key: requireKey("role-def", key) }), + personStatus: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "person-status", + key: requireKey("person-status", key), + }), + roleDef: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "role-def", + key: requireKey("role-def", key), + }), group: (key: string): SimpleRef => ({ __ctRef: true, kind: "group", key: requireKey("group", key) }), /** * A `group_role` permission domain, by its (group, role) pair (#25). The resolver maps it to the @@ -189,9 +209,7 @@ export function pendingRef(r: Ref): PendingRef { export function isPendingRef(value: unknown): value is PendingRef { return ( - typeof value === "object" && - value !== null && - isRef((value as { __pendingRef?: unknown }).__pendingRef) + typeof value === "object" && value !== null && isRef((value as { __pendingRef?: unknown }).__pendingRef) ); } diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 2aea3b4..5ed8ca1 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -418,7 +418,8 @@ export function reresolvePendingValue(value: unknown, state: State): unknown { if (Array.isArray(value)) return value.map((v) => reresolvePendingValue(v, state)); if (value !== null && typeof value === "object") { const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) out[k] = reresolvePendingValue(v, state); + for (const [k, v] of Object.entries(value as Record)) + out[k] = reresolvePendingValue(v, state); return out; } return value; diff --git a/src/resolve/reverse.ts b/src/resolve/reverse.ts index e0a8f7d..c3c645e 100644 --- a/src/resolve/reverse.ts +++ b/src/resolve/reverse.ts @@ -127,7 +127,11 @@ export class ReverseResolver { if (Array.isArray(rows)) { for (const row of rows) { const groupTypeId = Number(row?.groupTypeId); - if (typeof row?.id === "number" && typeof row.name === "string" && Number.isFinite(groupTypeId)) { + if ( + typeof row?.id === "number" && + typeof row.name === "string" && + Number.isFinite(groupTypeId) + ) { map.set(row.id, { groupTypeId, name: row.name }); } } diff --git a/src/ui/prompt.ts b/src/ui/prompt.ts index 9225258..6930ea6 100644 --- a/src/ui/prompt.ts +++ b/src/ui/prompt.ts @@ -74,8 +74,6 @@ export async function confirmEnv( return false; } const ask = opts.ask ?? realAsk; - const answer = await ask( - `Protected environment "${envName}". Type the environment name to confirm: `, - ); + const answer = await ask(`Protected environment "${envName}". Type the environment name to confirm: `); return answer.trim() === envName; } diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index ae40721..c118661 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -431,17 +431,17 @@ describe("ct adopt group --with-dynamic --portable-rulesets (#76 Stage 3)", () = run(["group", "33", "--with-dynamic", "--strict-rulesets", "--state", statePath]), ).rejects.toThrow(/--strict-rulesets/); // Nothing was written — the refusal must not leave a half-portable file behind. - await expect( - readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8"), - ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); }); it("--dry-run --portable-rulesets writes no file", async () => { await run(["group", "10", "--state", statePath]); await run(["group", "33", "--with-dynamic", "--portable-rulesets", "--dry-run", "--state", statePath]); - await expect( - readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8"), - ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); }); }); diff --git a/tests/apply-env-command.test.ts b/tests/apply-env-command.test.ts index 117aa0f..45e8e02 100644 --- a/tests/apply-env-command.test.ts +++ b/tests/apply-env-command.test.ts @@ -14,7 +14,13 @@ import type { Plan } from "../src/engine/types.js"; const plan: Plan = { items: [ - { type: "campus", key: "mainz", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Mainz" }] }, + { + type: "campus", + key: "mainz", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Mainz" }], + }, ], }; diff --git a/tests/apply-refresh.test.ts b/tests/apply-refresh.test.ts index 6f60d31..fb953b6 100644 --- a/tests/apply-refresh.test.ts +++ b/tests/apply-refresh.test.ts @@ -80,8 +80,16 @@ describe("runPostApplyHooks (dynamic refresh)", () => { const refreshCalls = calls.filter((c) => c.path.startsWith("/dynamicgroups/")); expect(refreshCalls).toHaveLength(2); - expect(refreshCalls).toContainEqual({ method: "POST", path: "/dynamicgroups/42/refresh", body: undefined }); - expect(refreshCalls).toContainEqual({ method: "POST", path: "/dynamicgroups/43/refresh", body: undefined }); + expect(refreshCalls).toContainEqual({ + method: "POST", + path: "/dynamicgroups/42/refresh", + body: undefined, + }); + expect(refreshCalls).toContainEqual({ + method: "POST", + path: "/dynamicgroups/43/refresh", + body: undefined, + }); }); it("never calls the all-groups /dynamicgroups/refresh endpoint", async () => { @@ -129,7 +137,13 @@ describe("runPostApplyHooks (dynamic refresh)", () => { key: "dyn_a", id: 42, action: "update", - changes: [{ field: "dynamic", from: { status: "active", ruleset: {} }, to: { status: "none", ruleset: {} } }], + changes: [ + { + field: "dynamic", + from: { status: "active", ruleset: {} }, + to: { status: "none", ruleset: {} }, + }, + ], }, { type: "group", @@ -179,8 +193,16 @@ describe("runPostApplyHooks (dynamic refresh)", () => { await expect(runPostApplyHooks(plan, state, client)).resolves.toBeUndefined(); const refreshCalls = calls.filter((c) => c.path.startsWith("/dynamicgroups/")); expect(refreshCalls).toHaveLength(2); - expect(refreshCalls).toContainEqual({ method: "POST", path: "/dynamicgroups/42/refresh", body: undefined }); - expect(refreshCalls).toContainEqual({ method: "POST", path: "/dynamicgroups/43/refresh", body: undefined }); + expect(refreshCalls).toContainEqual({ + method: "POST", + path: "/dynamicgroups/42/refresh", + body: undefined, + }); + expect(refreshCalls).toContainEqual({ + method: "POST", + path: "/dynamicgroups/43/refresh", + body: undefined, + }); }); it("skips a changed-dynamic item whose id is not yet resolvable in state (explicit undefined check, not truthiness)", async () => { @@ -204,7 +226,9 @@ describe("runPostApplyHooks (dynamic refresh)", () => { }, ], }; - const { client, calls } = recorder({ "POST /dynamicgroups/0/refresh": [{ created: 0, updated: 0, deleted: 0 }] }); + const { client, calls } = recorder({ + "POST /dynamicgroups/0/refresh": [{ created: 0, updated: 0, deleted: 0 }], + }); await runPostApplyHooks(plan, state, client); expect(calls).toEqual([{ method: "POST", path: "/dynamicgroups/0/refresh", body: undefined }]); }); diff --git a/tests/blueprint.test.ts b/tests/blueprint.test.ts index 2130a0e..f2690de 100644 --- a/tests/blueprint.test.ts +++ b/tests/blueprint.test.ts @@ -23,7 +23,7 @@ describe("campus blueprint", () => { const order = orderKeys(resources); const pos = (k: string) => order.indexOf(k); for (const c of ["mainz", "berlin"]) { - expect(pos(c)).toBeLessThan(pos(`${c}_lead`)); // campus (tier 0) before its groups (tier 1) + expect(pos(c)).toBeLessThan(pos(`${c}_lead`)); // campus (tier 0) before its groups (tier 1) expect(pos(`${c}_lead`)).toBeLessThan(pos(`${c}_team`)); // parent before child (intra-tier dependency) } }); diff --git a/tests/config.test.ts b/tests/config.test.ts index a211eed..c61c284 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -12,7 +12,10 @@ describe("normalizeHost", () => { describe("resolveConfig", () => { it("prefers CT_HOST env over the stored host", async () => { - const config = await resolveConfig({ CT_HOST: "https://env.church.tools" }, async () => "https://stored.church.tools"); + const config = await resolveConfig( + { CT_HOST: "https://env.church.tools" }, + async () => "https://stored.church.tools", + ); expect(config.host).toBe("https://env.church.tools"); }); diff --git a/tests/coverage.test.ts b/tests/coverage.test.ts index 6dd295f..a706c94 100644 --- a/tests/coverage.test.ts +++ b/tests/coverage.test.ts @@ -102,7 +102,10 @@ describe("buildCoverageReport (#103)", () => { host: "h", state, groups, - groupTypeNames: new Map([[9, "Struktur"], [12, "Local Lead"]]), + groupTypeNames: new Map([ + [9, "Struktur"], + [12, "Local Lead"], + ]), dynamicGroupIds: new Set([1, 3]), groupRolePermissions: [ row({ authId: VIEW_GROUP, dataId: 42, domainId: 100 }), @@ -128,10 +131,20 @@ describe("buildCoverageReport (#103)", () => { it("counts managed / dynamic / unmanaged-with-grants per group type", () => { const byName = new Map(report.byType.map((t) => [t.name, t])); - expect(byName.get("Struktur")).toMatchObject({ total: 1, managed: 1, dynamic: 1, unmanagedWithGrants: 0 }); + expect(byName.get("Struktur")).toMatchObject({ + total: 1, + managed: 1, + dynamic: 1, + unmanagedWithGrants: 0, + }); // Group 2 is unmanaged AND carries grants — the "you are missing this" number. Group 3 is // unmanaged too but has no grants, so it must not inflate it. - expect(byName.get("Local Lead")).toMatchObject({ total: 2, managed: 0, dynamic: 1, unmanagedWithGrants: 1 }); + expect(byName.get("Local Lead")).toMatchObject({ + total: 2, + managed: 0, + dynamic: 1, + unmanagedWithGrants: 1, + }); }); it("skips role instances with no authored grants entirely", () => { diff --git a/tests/ctClient.test.ts b/tests/ctClient.test.ts index a985fb6..09be860 100644 --- a/tests/ctClient.test.ts +++ b/tests/ctClient.test.ts @@ -18,7 +18,10 @@ describe("CtClient", () => { const fetchMock = vi .fn() .mockResolvedValueOnce( - jsonResponse({ data: { id: 7, firstName: "Ada" } }, { setCookie: "ChurchTools_ct_mychurch=abc; Path=/" }), + jsonResponse( + { data: { id: 7, firstName: "Ada" } }, + { setCookie: "ChurchTools_ct_mychurch=abc; Path=/" }, + ), ) .mockResolvedValueOnce(jsonResponse({ data: "csrf-123" })); vi.stubGlobal("fetch", fetchMock); @@ -55,7 +58,10 @@ describe("CtClient", () => { expect(headers.get("Cookie")).toContain("s=1"); }); - async function authedClient(): Promise<{ client: CtClient; fetchMock: ReturnType> }> { + async function authedClient(): Promise<{ + client: CtClient; + fetchMock: ReturnType>; + }> { const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ data: { id: 1 } }, { setCookie: "s=1; Path=/" })) diff --git a/tests/destroy.test.ts b/tests/destroy.test.ts index 6483ecb..6cb65b7 100644 --- a/tests/destroy.test.ts +++ b/tests/destroy.test.ts @@ -6,7 +6,14 @@ import { CtApiError, type CtClient } from "../src/api/ctClient.js"; function stateWith(...entries: Array<{ key: string; type: string; id: number }>): State { const state = emptyState("h"); for (const e of entries) { - state.resources[e.key] = { type: e.type, id: e.id, key: e.key, fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources[e.key] = { + type: e.type, + id: e.id, + key: e.key, + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; } return state; } @@ -40,10 +47,7 @@ describe("orderDestroy", () => { }); it("orders a child before its parent within the group tier (live hierarchy edges)", () => { - const state = stateWith( - { key: "area", type: "group", id: 1 }, - { key: "kids", type: "group", id: 2 }, - ); + const state = stateWith({ key: "area", type: "group", id: 1 }, { key: "kids", type: "group", id: 2 }); // kids → parent area. State carries no edges, so the command supplies them from /groups/hierarchies. const edges = new Map([["kids", ["area"]]]); // Input order deliberately parent-first: a tier-only sort would delete area before kids. @@ -74,10 +78,16 @@ describe("runDeleteLoop", () => { }); const save = vi.fn(async () => {}); - await runDeleteLoop({ client: asClient(request), state, statePath: "s.json", ordered: ["gone", "other"], save }); + await runDeleteLoop({ + client: asClient(request), + state, + statePath: "s.json", + ordered: ["gone", "other"], + save, + }); - expect(state.resources.gone).toBeUndefined(); // already-deleted target removed from state - expect(state.resources.other).toBeUndefined(); // loop continued and deleted the rest + expect(state.resources.gone).toBeUndefined(); // already-deleted target removed from state + expect(state.resources.other).toBeUndefined(); // loop continued and deleted the rest expect(request).toHaveBeenCalledTimes(2); expect(save).toHaveBeenCalledTimes(2); }); @@ -91,11 +101,17 @@ describe("runDeleteLoop", () => { const save = vi.fn(async () => {}); const prevExit = process.exitCode; - await runDeleteLoop({ client: asClient(request), state, statePath: "s.json", ordered: ["boom", "later"], save }); + await runDeleteLoop({ + client: asClient(request), + state, + statePath: "s.json", + ordered: ["boom", "later"], + save, + }); - expect(state.resources.boom).toBeDefined(); // not removed — the DELETE failed - expect(state.resources.later).toBeDefined(); // never reached - expect(request).toHaveBeenCalledTimes(1); // stopped at the first target + expect(state.resources.boom).toBeDefined(); // not removed — the DELETE failed + expect(state.resources.later).toBeDefined(); // never reached + expect(request).toHaveBeenCalledTimes(1); // stopped at the first target expect(save).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); process.exitCode = prevExit; @@ -104,7 +120,8 @@ describe("runDeleteLoop", () => { it("renders a CtApiError's HTTP status + body in the stop message, via the shared formatter (#71)", async () => { const state = stateWith({ key: "boom", type: "group", id: 1 }); const request = vi.fn(async (_m: string, path: string) => { - if (path === "/groups/1") throw new CtApiError("DELETE /groups/1 failed", 403, { message: "no permission" }); + if (path === "/groups/1") + throw new CtApiError("DELETE /groups/1 failed", 403, { message: "no permission" }); return {}; }); const save = vi.fn(async () => {}); diff --git a/tests/dynamic.integration.test.ts b/tests/dynamic.integration.test.ts index 3b44993..c445abf 100644 --- a/tests/dynamic.integration.test.ts +++ b/tests/dynamic.integration.test.ts @@ -111,7 +111,10 @@ describe.runIf(live && liveWrite)("dynamic ruleset round-trip pin (#36, live wri const changedFields = [...allFields].filter((f) => !isDeepStrictEqual(wantNorm[f], gotNorm[f])); if (changedFields.length > 0) { const detail = changedFields - .map((f) => ` ${f}:\n authored: ${JSON.stringify(wantNorm[f])}\n returned: ${JSON.stringify(gotNorm[f])}`) + .map( + (f) => + ` ${f}:\n authored: ${JSON.stringify(wantNorm[f])}\n returned: ${JSON.stringify(gotNorm[f])}`, + ) .join("\n"); throw new Error( `CT rewrote ${changedFields.length} RuleSet field(s) on PUT — extend normalizeRuleset ` + @@ -127,13 +130,15 @@ describe.runIf(live && liveWrite)("dynamic ruleset round-trip pin (#36, live wri // "manual" (e.g. the committed fixture at tests/fixtures/dynamic/status.get.json is "active"). // This test only ever writes the ruleset (never a status PUT), so reading the live status here // isolates exactly the #36 property under test — ruleset field rewriting — from status drift. - const liveStatus = ( - await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${GID}/status`) - )?.dynamicGroupStatus ?? "none"; + const liveStatus = + (await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${GID}/status`)) + ?.dynamicGroupStatus ?? "none"; const state: State = { version: 1, host: expectedHost, - resources: { pin36: { type: "group", id: GID, key: "pin36", fields: {}, adoptedAt: "t", updatedAt: "t" } }, + resources: { + pin36: { type: "group", id: GID, key: "pin36", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, }; const actual = new Map>([["pin36", {}]]); const desired: DesiredResource[] = [ @@ -150,7 +155,10 @@ describe.runIf(live && liveWrite)("dynamic ruleset round-trip pin (#36, live wri const dynamicChange = diffFields(folded.desired[0]!.fields, actual.get("pin36")!).find( (c) => c.field === "dynamic", ); - expect(dynamicChange, `plan is not a no-op after the PUT: ${JSON.stringify(dynamicChange)}`).toBeUndefined(); + expect( + dynamicChange, + `plan is not a no-op after the PUT: ${JSON.stringify(dynamicChange)}`, + ).toBeUndefined(); } finally { // Restore the prior ruleset so the dev instance isn't left mutated by this test. await client.request("PUT", `/dynamicgroups/${GID}/ruleset`, putRulesetBody(normalizeRuleset(before))); diff --git a/tests/dynamic.test.ts b/tests/dynamic.test.ts index 3a0acc0..514ebe0 100644 --- a/tests/dynamic.test.ts +++ b/tests/dynamic.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; -import { normalizeRuleset, stripCosmeticLabels, coerceScalars, normalizeDynamic, putRulesetBody } from "../src/engine/dynamic.js"; +import { + normalizeRuleset, + stripCosmeticLabels, + coerceScalars, + normalizeDynamic, + putRulesetBody, +} from "../src/engine/dynamic.js"; describe("stripCosmeticLabels", () => { it("unwraps a dterm with a string label to its expr, recursively", () => { @@ -8,41 +14,49 @@ describe("stripCosmeticLabels", () => { expect(stripCosmeticLabels(input)).toEqual({ and: [{ "==": [{ var: "ctgroup.campusId" }, 1] }] }); }); it("unwraps a dterm with an object label (title/stereotype/i18n key)", () => { - const input = { dterm: [{ title: "group.x.title", stereotype: ["groupmembership"] }, { isnull: [{ var: "person.dateOfDeath" }] }] }; + const input = { + dterm: [ + { title: "group.x.title", stereotype: ["groupmembership"] }, + { isnull: [{ var: "person.dateOfDeath" }] }, + ], + }; expect(stripCosmeticLabels(input)).toEqual({ isnull: [{ var: "person.dateOfDeath" }] }); }); }); describe("coerceScalars", () => { it("coerces numeric strings to numbers so int/string drift is not spurious", () => { - expect(coerceScalars({ "==": [{ var: "ctgroup.campusId" }, "1"] })) - .toEqual({ "==": [{ var: "ctgroup.campusId" }, 1] }); + expect(coerceScalars({ "==": [{ var: "ctgroup.campusId" }, "1"] })).toEqual({ + "==": [{ var: "ctgroup.campusId" }, 1], + }); }); it("coerces numeric strings inside oneof arrays but leaves non-numeric strings", () => { - expect(coerceScalars({ oneof: [{ var: "ctgroup.id" }, ["112", "8"]] })) - .toEqual({ oneof: [{ var: "ctgroup.id" }, [112, 8]] }); - expect(coerceScalars({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] })) - .toEqual({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] }); + expect(coerceScalars({ oneof: [{ var: "ctgroup.id" }, ["112", "8"]] })).toEqual({ + oneof: [{ var: "ctgroup.id" }, [112, 8]], + }); + expect(coerceScalars({ "==": [{ var: "groupmember.groupMemberStatus" }, "active"] })).toEqual({ + "==": [{ var: "groupmember.groupMemberStatus" }, "active"], + }); }); it("leaves leading-zero and >2^53 numeric strings as strings (no corruption, no precision loss)", () => { // A leading-zero zip code is a semantic string; parseInt would drop the zero and break the compare. - expect(coerceScalars({ "==": [{ var: "person.zip" }, "01067"] })) - .toEqual({ "==": [{ var: "person.zip" }, "01067"] }); + expect(coerceScalars({ "==": [{ var: "person.zip" }, "01067"] })).toEqual({ + "==": [{ var: "person.zip" }, "01067"], + }); // A digit string beyond MAX_SAFE_INTEGER can't be represented exactly — must stay a string. const big = "90071992547409910"; // > 2^53 - expect(coerceScalars({ "==": [{ var: "x.id" }, big] })) - .toEqual({ "==": [{ var: "x.id" }, big] }); + expect(coerceScalars({ "==": [{ var: "x.id" }, big] })).toEqual({ "==": [{ var: "x.id" }, big] }); // Canonical ints still coerce, so a 5 vs "5" int/string pair keeps diffing equal. - expect(coerceScalars({ "==": [{ var: "x.n" }, "5"] })) - .toEqual({ "==": [{ var: "x.n" }, 5] }); - expect(coerceScalars({ "==": [{ var: "x.n" }, "0"] })) - .toEqual({ "==": [{ var: "x.n" }, 0] }); + expect(coerceScalars({ "==": [{ var: "x.n" }, "5"] })).toEqual({ "==": [{ var: "x.n" }, 5] }); + expect(coerceScalars({ "==": [{ var: "x.n" }, "0"] })).toEqual({ "==": [{ var: "x.n" }, 0] }); }); }); describe("normalizeRuleset", () => { it("drops read-only timestamps and the PUT envelope, and is idempotent", () => { - const withEnvelope = { dynamicGroupRuleSet: { description: "x", dynamicGroupUpdateStarted: "t", process: {}, query: {} } }; + const withEnvelope = { + dynamicGroupRuleSet: { description: "x", dynamicGroupUpdateStarted: "t", process: {}, query: {} }, + }; const once = normalizeRuleset(withEnvelope); expect(once).not.toHaveProperty("dynamicGroupUpdateStarted"); expect(once).not.toHaveProperty("dynamicGroupRuleSet"); @@ -55,16 +69,25 @@ describe("normalizeRuleset", () => { }); it("coerces filter operands but leaves numeric-looking RuleSet-level fields as strings", () => { - const r = { description: "2024", shorty: "007", personIdFieldName: "person.id", - query: { "==": [{ var: "ctgroup.id" }, "112"] }, process: {} }; + const r = { + description: "2024", + shorty: "007", + personIdFieldName: "person.id", + query: { "==": [{ var: "ctgroup.id" }, "112"] }, + process: {}, + }; const out = normalizeRuleset(r); expect(out.description).toBe("2024"); // stays a string, not 2024 - expect(out.shorty).toBe("007"); // stays a string, not 7 + expect(out.shorty).toBe("007"); // stays a string, not 7 expect(out.query).toEqual({ "==": [{ var: "ctgroup.id" }, 112] }); // filter operand coerced }); it("round-trips a leading-zero query leaf byte-identical (no retype on write-back)", () => { - const authored = { description: "Zip filter", query: { "==": [{ var: "person.zip" }, "01067"] }, process: {} }; + const authored = { + description: "Zip filter", + query: { "==": [{ var: "person.zip" }, "01067"] }, + process: {}, + }; const once = normalizeRuleset(authored); // The zip leaf survives normalization untouched — apply PUTs `to.ruleset`, so any retype here // would be written back to CT and silently break the JSONLogic string comparison. @@ -76,8 +99,8 @@ describe("normalizeRuleset", () => { for (const name of ["ruleset-683", "ruleset-2022", "ruleset-1092"]) { const raw = JSON.parse(readFileSync(`tests/fixtures/dynamic/${name}.get.json`, "utf8")); // array shape const once = normalizeRuleset(raw); - expect(normalizeRuleset(once)).toEqual(once); // idempotent - expect(JSON.stringify(once)).not.toContain("dterm"); // cosmetic labels stripped + expect(normalizeRuleset(once)).toEqual(once); // idempotent + expect(JSON.stringify(once)).not.toContain("dterm"); // cosmetic labels stripped } }); }); diff --git a/tests/envs.test.ts b/tests/envs.test.ts index 4a49c60..1562313 100644 --- a/tests/envs.test.ts +++ b/tests/envs.test.ts @@ -2,12 +2,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - DEFAULT_ENVS_PATH, - resolveEnvsPath, - defaultEnvStatePath, - loadEnvProfile, -} from "../src/env/envs.js"; +import { DEFAULT_ENVS_PATH, resolveEnvsPath, defaultEnvStatePath, loadEnvProfile } from "../src/env/envs.js"; const envsPath = join(tmpdir(), `ct-cli-envs-${process.pid}.json`); @@ -67,16 +62,12 @@ describe("loadEnvProfile", () => { }); it("throws a friendly error when the envs file is missing", async () => { - await expect(loadEnvProfile("dev", envsPath)).rejects.toThrow( - /Environment profile file not found/, - ); + await expect(loadEnvProfile("dev", envsPath)).rejects.toThrow(/Environment profile file not found/); }); it("names the known environments when the requested one is absent", async () => { await writeEnvs({ environments: { dev: { host: "https://d.church.tools" } } }); - await expect(loadEnvProfile("prod", envsPath)).rejects.toThrow( - /Unknown environment "prod".*dev/s, - ); + await expect(loadEnvProfile("prod", envsPath)).rejects.toThrow(/Unknown environment "prod".*dev/s); }); it("rejects a profile missing a host", async () => { diff --git a/tests/execute.test.ts b/tests/execute.test.ts index dd8d9a3..8c5099e 100644 --- a/tests/execute.test.ts +++ b/tests/execute.test.ts @@ -81,7 +81,13 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed).toBeUndefined(); // POST body = declared fields ∪ deterministic create-defaults (declared values win). expect(calls[0]).toEqual({ @@ -287,12 +293,23 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.updated).toEqual(["team"]); // CT reads campus at information.campusId but accepts a top-level campusId on PATCH — mirroring // how groupTypeId/groupStatusId are written. PATCH carries only the changed field. expect(calls[0]).toEqual({ method: "PATCH", path: "/groups/9", body: { campusId: 4 } }); - expect(state.resources.team!.fields).toEqual({ name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 }); + expect(state.resources.team!.fields).toEqual({ + name: "Team", + groupTypeId: 2, + groupStatusId: 1, + campusId: 4, + }); }); it("does NOT revert a field that drifted in CT when a sibling field is updated (#27)", async () => { @@ -399,7 +416,13 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed).toBeUndefined(); expect(result.created).toEqual(["parent", "child"]); // The edge PUT resolves the parent's freshly-assigned id (1) against the child's (2). @@ -443,9 +466,7 @@ describe("executePlan", () => { // A note-less no-op whose config now sets preventDestroy — the flag alone is never a diffed field, // so this is the only chance to persist it to state. const plan: Plan = { - items: [ - { type: "campus", key: "mainz", id: 0, action: "no-op", changes: [], preventDestroy: true }, - ], + items: [{ type: "campus", key: "mainz", id: 0, action: "no-op", changes: [], preventDestroy: true }], }; await executePlan(plan, { client, state, statePath: "s.json", save, now: fixedNow }); expect(state.resources.mainz!.preventDestroy).toBe(true); @@ -516,7 +537,9 @@ describe("executePlan", () => { const state = emptyState("h"); const client = { request: async (): Promise => { - throw new CtApiError("POST /group/grouptypes failed", 403, { message: "no permission to create group types" }); + throw new CtApiError("POST /group/grouptypes failed", 403, { + message: "no permission to create group types", + }); }, }; const plan: Plan = { @@ -561,7 +584,13 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed).toBeUndefined(); expect(calls[0]).toEqual({ method: "POST", @@ -649,7 +678,13 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed?.key).toBe("kids_2026_b"); // The shared formatter's output is preserved verbatim (HTTP status + body)... expect(result.failed?.message).toContain("HTTP 400"); @@ -671,10 +706,22 @@ describe("executePlan", () => { }; const plan: Plan = { items: [ - { type: "group", key: "kids_2026_b", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "K" }] }, + { + type: "group", + key: "kids_2026_b", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "K" }], + }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed?.message).toContain("allowDuplicateName: true"); }); @@ -700,7 +747,13 @@ describe("executePlan", () => { }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed?.message).not.toContain("Guidance:"); }); @@ -713,10 +766,22 @@ describe("executePlan", () => { }; const plan: Plan = { items: [ - { type: "group", key: "kids", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "" }] }, + { + type: "group", + key: "kids", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "" }], + }, ], }; - const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); expect(result.failed?.message).not.toContain("Guidance:"); }); }); diff --git a/tests/fixtures/dynamic/README.md b/tests/fixtures/dynamic/README.md index 70c1603..d4d689e 100644 --- a/tests/fixtures/dynamic/README.md +++ b/tests/fixtures/dynamic/README.md @@ -38,7 +38,7 @@ against this production login.** write-back re-sends exactly what CT returned, so it is drift-free by construction. 2. **`CT_LIVE=1 CT_LIVE_WRITE=1 CT_LIVE_WRITE_HOST= - CT_DYNAMIC_FIXTURE_GID=`** — the **#36 pin**: +CT_DYNAMIC_FIXTURE_GID=`** — the **#36 pin**: PUTs a ruleset this test authors itself (custom `description`, `shorty`, `importance`, …, never copied from a GET) to the designated group, GETs it back, and asserts normalized deep-equality field by field. It also builds diff --git a/tests/fixtures/dynamic/portablize-sintegrationmeeting.json b/tests/fixtures/dynamic/portablize-sintegrationmeeting.json index cc454b9..da36e36 100644 --- a/tests/fixtures/dynamic/portablize-sintegrationmeeting.json +++ b/tests/fixtures/dynamic/portablize-sintegrationmeeting.json @@ -6,9 +6,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -32,9 +30,7 @@ "dterm": [ { "title": "nur LEITER", - "stereotype": [ - "groupmembership" - ] + "stereotype": ["groupmembership"] }, { "and": [ @@ -45,11 +41,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1, - 2, - 4 - ] + [1, 2, 4] ] }, { @@ -57,15 +49,7 @@ { "var": "ctgroup.id" }, - [ - "148", - "1228", - "32", - "1237", - "1243", - "27", - "119" - ] + ["148", "1228", "32", "1237", "1243", "27", "119"] ] } ] @@ -77,14 +61,7 @@ { "var": "role.id" }, - [ - "16", - "84", - "85", - "17", - "90", - "91" - ] + ["16", "84", "85", "17", "90", "91"] ] } ] @@ -97,9 +74,7 @@ "dterm": [ { "title": "ALLE", - "stereotype": [ - "groupmembership" - ] + "stereotype": ["groupmembership"] }, { "and": [ @@ -110,11 +85,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1, - 2, - 4 - ] + [1, 2, 4] ] }, { @@ -122,9 +93,7 @@ { "var": "ctgroup.id" }, - [ - "1974" - ] + ["1974"] ] } ] @@ -136,11 +105,7 @@ { "var": "role.id" }, - [ - "15", - "16", - "17" - ] + ["15", "16", "17"] ] } ] @@ -154,11 +119,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { @@ -178,4 +139,4 @@ }, "groupAndQueryResult": {} } -} \ No newline at end of file +} diff --git a/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json b/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json index 2aed610..f5bb6d4 100644 --- a/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json +++ b/tests/fixtures/dynamic/portablize-skidscheckinopsmz.json @@ -6,9 +6,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -28,9 +26,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -42,9 +38,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -52,10 +46,7 @@ { "var": "ctgroup.id" }, - [ - "112", - "8" - ] + ["112", "8"] ] } ] @@ -67,12 +58,7 @@ { "var": "role.id" }, - [ - "84", - "85", - "17", - "16" - ] + ["84", "85", "17", "16"] ] } ] @@ -84,9 +70,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -98,9 +82,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -108,9 +90,7 @@ { "var": "ctgroup.id" }, - [ - "1246" - ] + ["1246"] ] } ] @@ -122,10 +102,7 @@ { "var": "role.id" }, - [ - "16", - "17" - ] + ["16", "17"] ] } ] @@ -139,11 +116,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { @@ -164,4 +137,4 @@ }, "groupAndQueryResult": {} } -} \ No newline at end of file +} diff --git a/tests/fixtures/dynamic/ruleset-1092.get.json b/tests/fixtures/dynamic/ruleset-1092.get.json index 0b02638..82ab450 100644 --- a/tests/fixtures/dynamic/ruleset-1092.get.json +++ b/tests/fixtures/dynamic/ruleset-1092.get.json @@ -7,9 +7,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -29,9 +27,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -43,9 +39,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -53,10 +47,7 @@ { "var": "ctgroup.id" }, - [ - "112", - "8" - ] + ["112", "8"] ] } ] @@ -68,12 +59,7 @@ { "var": "role.id" }, - [ - "84", - "85", - "17", - "16" - ] + ["84", "85", "17", "16"] ] } ] @@ -85,9 +71,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -99,9 +83,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -109,9 +91,7 @@ { "var": "ctgroup.id" }, - [ - "1246" - ] + ["1246"] ] } ] @@ -123,10 +103,7 @@ { "var": "role.id" }, - [ - "16", - "17" - ] + ["16", "17"] ] } ] @@ -140,11 +117,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { diff --git a/tests/fixtures/dynamic/ruleset-2022.get.json b/tests/fixtures/dynamic/ruleset-2022.get.json index e79e157..e64fb14 100644 --- a/tests/fixtures/dynamic/ruleset-2022.get.json +++ b/tests/fixtures/dynamic/ruleset-2022.get.json @@ -7,9 +7,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -42,9 +40,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -56,9 +52,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -87,9 +81,7 @@ { "var": "ctgroup.id" }, - [ - "1869" - ] + ["1869"] ] } ] @@ -115,12 +107,7 @@ { "var": "role.id" }, - [ - "8", - "9", - "10", - "92" - ] + ["8", "9", "10", "92"] ] }, { @@ -140,11 +127,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { diff --git a/tests/fixtures/dynamic/ruleset-683.get.json b/tests/fixtures/dynamic/ruleset-683.get.json index 1414982..62902a0 100644 --- a/tests/fixtures/dynamic/ruleset-683.get.json +++ b/tests/fixtures/dynamic/ruleset-683.get.json @@ -7,9 +7,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -20,9 +18,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -34,9 +30,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -44,13 +38,7 @@ { "var": "ctgroup.groupTypeId" }, - [ - 2, - 12, - 16, - 19, - 1 - ] + [2, 12, 16, 19, 1] ] }, { @@ -58,17 +46,7 @@ { "var": "ctgroup.campusId" }, - [ - 0, - 9, - 17, - 18, - 23, - 26, - 29, - 32, - 33 - ] + [0, 9, 17, 18, 23, 26, 29, 32, 33] ] } ] @@ -115,9 +93,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -127,9 +103,7 @@ { "var": "ctgroup.id" }, - [ - "1993" - ] + ["1993"] ] }, { @@ -170,11 +144,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { diff --git a/tests/fixtures/dynamic/ruleset.get.json b/tests/fixtures/dynamic/ruleset.get.json index 1414982..62902a0 100644 --- a/tests/fixtures/dynamic/ruleset.get.json +++ b/tests/fixtures/dynamic/ruleset.get.json @@ -7,9 +7,7 @@ "query": { "method": "ChurchQuery", "params": { - "groupBy": [ - "person.id" - ], + "groupBy": ["person.id"], "filter": { "and": [ { @@ -20,9 +18,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -34,9 +30,7 @@ { "var": "ctgroup.groupStatusId" }, - [ - 1 - ] + [1] ] }, { @@ -44,13 +38,7 @@ { "var": "ctgroup.groupTypeId" }, - [ - 2, - 12, - 16, - 19, - 1 - ] + [2, 12, 16, 19, 1] ] }, { @@ -58,17 +46,7 @@ { "var": "ctgroup.campusId" }, - [ - 0, - 9, - 17, - 18, - 23, - 26, - 29, - 32, - 33 - ] + [0, 9, 17, 18, 23, 26, 29, 32, 33] ] } ] @@ -115,9 +93,7 @@ { "dterm": [ { - "stereotype": [ - "groupmembership" - ], + "stereotype": ["groupmembership"], "title": "Mitgliedschaft in einer Gruppe" }, { @@ -127,9 +103,7 @@ { "var": "ctgroup.id" }, - [ - "1993" - ] + ["1993"] ] }, { @@ -170,11 +144,7 @@ ] }, "primaryEntityAlias": "person", - "responseFields": [ - "person.id", - "person.firstName", - "person.lastName" - ] + "responseFields": ["person.id", "person.firstName", "person.lastName"] } }, "process": { diff --git a/tests/get-command.test.ts b/tests/get-command.test.ts index 7ad074c..0896ded 100644 --- a/tests/get-command.test.ts +++ b/tests/get-command.test.ts @@ -87,7 +87,12 @@ describe("ct get (#50)", () => { getAllMock.mockResolvedValue({ data: [ { id: 5, name: "first_contact", securityLevel: 1, fieldCategory: { internCode: "f_person" } }, - { id: 9, name: "bezeichnung", securityLevel: 2, fieldCategory: { internCode: "f_group", table: "cdb_gruppe" } }, + { + id: 9, + name: "bezeichnung", + securityLevel: 2, + fieldCategory: { internCode: "f_group", table: "cdb_gruppe" }, + }, ], meta: { pagination: { total: 2, current: 1, lastPage: 1, limit: 100 } }, }); @@ -204,7 +209,10 @@ describe("ct get raw pagination (#100)", () => { }); it("honours a caller's own page=/limit= verbatim rather than appending a conflicting pair", async () => { - getRawMock.mockResolvedValue({ data: [{ id: 1 }], meta: { pagination: { total: 1, current: 1, lastPage: 1 } } }); + getRawMock.mockResolvedValue({ + data: [{ id: 1 }], + meta: { pagination: { total: 1, current: 1, lastPage: 1 } }, + }); const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); await runGet(["raw", "/groups?limit=100&page=2"]); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index d316ee7..4c16bda 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -7,9 +7,7 @@ import type { DesiredResource } from "../src/engine/types.js"; /** A tiny in-memory ChurchTools: a campuses store supporting GET/POST/PUT; hierarchy is empty. */ function fakeCt() { - const campuses = new Map>([ - [0, { id: 0, name: "Mainz", shorty: "MZ" }], - ]); + const campuses = new Map>([[0, { id: 0, name: "Mainz", shorty: "MZ" }]]); let nextId = 1; return { get: async (path: string): Promise => { diff --git a/tests/permission-adopt.test.ts b/tests/permission-adopt.test.ts index 507fec6..d404458 100644 --- a/tests/permission-adopt.test.ts +++ b/tests/permission-adopt.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import { emitAdoptedGrants } from "../src/permissions/adopt.js"; -import { diffGrants, normalizeActual, type DomainType, type RawPermission } from "../src/permissions/grants.js"; +import { + diffGrants, + normalizeActual, + type DomainType, + type RawPermission, +} from "../src/permissions/grants.js"; import { desiredTuples } from "../src/permissions/plan.js"; import { resolveScopeRefs } from "../src/permissions/scope.js"; import { Resolver } from "../src/resolve/resolver.js"; @@ -62,7 +67,12 @@ describe("emitAdoptedGrants", () => { const rows: RawPermission[] = [ { authId: 1, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: emptyState(), + }); expect(block).toContain("ct.groupTypeRole({"); expect(block).toContain("id: 42,"); @@ -72,7 +82,9 @@ describe("emitAdoptedGrants", () => { }); it("group_role emits ct.groupRole", () => { - const rows: RawPermission[] = [{ authId: 1, dataId: null, type: "grant", domainId: 7, meta: { modifiedPid: 5 } }]; + const rows: RawPermission[] = [ + { authId: 1, dataId: null, type: "grant", domainId: 7, meta: { modifiedPid: 5 } }, + ]; const block = emitAdoptedGrants({ domainType: "group_role", domainId: 7, rows, state: emptyState() }); expect(block).toContain("ct.groupRole({"); }); @@ -81,7 +93,12 @@ describe("emitAdoptedGrants", () => { const rows: RawPermission[] = [ { authId: 1104, dataId: 99, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: stateWithKids() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: stateWithKids(), + }); expect(block).toContain('{ right: "churchgroup:view group", scope: ["kids"] }'); expect(block).not.toContain("WARNING"); @@ -89,7 +106,14 @@ describe("emitAdoptedGrants", () => { it("collapses a multi-scope grant (one CT row per dataId) into one entry", () => { const state = stateWithKids(); - state.resources.youth = { type: "group", id: 100, key: "youth", fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources.youth = { + type: "group", + id: 100, + key: "youth", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; const rows: RawPermission[] = [ { authId: 1104, dataId: 99, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, { authId: 1104, dataId: 100, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, @@ -102,12 +126,17 @@ describe("emitAdoptedGrants", () => { const rows: RawPermission[] = [ { authId: 1104, dataId: 777, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: emptyState(), + }); expect(block).toContain("WARNING: scope target group #777 is not managed"); expect(block).toContain("ct adopt group 777"); // the grant is a commented placeholder — never an active line with an invalid/guessed key - expect(block).toContain("// { right: \"churchgroup:view group\", scope:"); + expect(block).toContain('// { right: "churchgroup:view group", scope:'); }); it("excludes baseline + inherited rows and notes preserved revoke/deny rows", () => { @@ -117,7 +146,12 @@ describe("emitAdoptedGrants", () => { { authId: 3, dataId: null, type: "grant", domainId: 42, isInherited: true }, // inherited → excluded { authId: 1104, dataId: 99, type: "revoke", domainId: 42, meta: { modifiedPid: 5 } }, // deny → preserved, noted ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: stateWithKids() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: stateWithKids(), + }); expect(block).toContain('"churchcore:administer settings"'); // authId 1 kept expect(block).not.toContain('scope: ["kids"]'); // the revoke row is NOT emitted as a grant @@ -129,7 +163,12 @@ describe("emitAdoptedGrants", () => { const rows: RawPermission[] = [ { authId: 999999, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: emptyState(), + }); expect(block).toContain("WARNING: authId 999999 has no catalog entry"); expect(parseEmittedGrants(block)).toEqual([]); // comment only, no active grant line @@ -149,7 +188,12 @@ describe("emitAdoptedGrants", () => { const rows: RawPermission[] = [ { authId: 10122, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: emptyState(), + }); expect(block).not.toContain("NOTE:"); expect(parseEmittedGrants(block)).toEqual(["churchdb:+edit group infos"]); @@ -168,7 +212,12 @@ describe("emitAdoptedGrants", () => { const rows: RawPermission[] = [ { authId: 1104, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: emptyState(), + }); expect(block).toContain('WARNING: "churchgroup:view group" is granted GLOBALLY here'); expect(parseEmittedGrants(block)).toEqual([]); @@ -178,7 +227,12 @@ describe("emitAdoptedGrants", () => { const rows: RawPermission[] = [ { authId: 1, dataId: 55, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: emptyState(), + }); expect(block).toContain('WARNING: "churchcore:administer settings" is unscoped per the catalog'); expect(parseEmittedGrants(block)).toEqual([]); @@ -214,7 +268,12 @@ describe("emitAdoptedGrants", () => { { authId: 113, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, { authId: 113, dataId: 2, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 9, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 9, + rows, + state: emptyState(), + }); expect(block).toContain('{ right: "churchdb:view comments", scope: [1, 2] }'); expect(block).not.toContain("ct adopt group"); @@ -233,7 +292,12 @@ describe("emitAdoptedGrants", () => { { authId: 131, dataId: 5, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, { authId: 132, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 9, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 9, + rows, + state: emptyState(), + }); const grants = parseEmittedGrants(block); expect(block).not.toContain("WARNING"); @@ -246,7 +310,12 @@ describe("emitAdoptedGrants", () => { // Full round-trip: pasting this block into config and diffing against the SAME live rows must // be a no-op — no toPut, and critically no toDelete (a partial block must never revoke a live // grant it could not express). - const desired = grants.flatMap((g) => desiredTuples({ key: "adopted", domainType: "group_type_role", domainId: 9, grants: [g] }, emptyState())); + const desired = grants.flatMap((g) => + desiredTuples( + { key: "adopted", domainType: "group_type_role", domainId: 9, grants: [g] }, + emptyState(), + ), + ); const actual = normalizeActual(rows); const diff = diffGrants(desired, actual); expect(diff.toPut).toEqual([]); @@ -265,14 +334,14 @@ describe("emitAdoptedGrants", () => { const rows: RawPermission[] = [ // writable, user-authored grants (unscoped + group-scoped) — emitted active { authId: 1113, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchgroup:administer groups - { authId: 1104, dataId: 99, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchgroup:view group scoped to managed "kids" + { authId: 1104, dataId: 99, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchgroup:view group scoped to managed "kids" // admin-authored MEMBER rights (authId >= 10000) — NOW managed: unscoped + security-level scopes { authId: 10107, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+add person (unscoped) - { authId: 10101, dataId: 2, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+see persons scope [2] - { authId: 10133, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+edit group member fields scope [1] + { authId: 10101, dataId: 2, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+see persons scope [2] + { authId: 10133, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+edit group member fields scope [1] // EXCLUDED (authId >= 10000 too): system baseline + a truly-inherited row → never revoked { authId: 10122, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: -1 } }, // system baseline - { authId: 10111, dataId: null, type: "grant", domainId: 9, isInherited: true }, // inherited + { authId: 10111, dataId: null, type: "grant", domainId: 9, isInherited: true }, // inherited ]; const state = stateWithKids(); const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 9, rows, state }); @@ -283,7 +352,9 @@ describe("emitAdoptedGrants", () => { // Paste-and-plan: diff the emitted declaration against the FULL live row set. normalizeActual // drops the system-baseline + inherited rows, so they never appear as revokes. - const desired = grants.flatMap((g) => desiredTuples({ key: "adopted", domainType: "group_type_role", domainId: 9, grants: [g] }, state)); + const desired = grants.flatMap((g) => + desiredTuples({ key: "adopted", domainType: "group_type_role", domainId: 9, grants: [g] }, state), + ); const actual = normalizeActual(rows); const diff = diffGrants(desired, actual); expect(diff.toPut).toEqual([]); @@ -296,7 +367,12 @@ describe("emitAdoptedGrants", () => { const rows: RawPermission[] = [ { authId: 1104, dataId: 777, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; - const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + const block = emitAdoptedGrants({ + domainType: "group_type_role", + domainId: 42, + rows, + state: emptyState(), + }); expect(block).toContain("ct adopt group 777"); }); @@ -304,7 +380,14 @@ describe("emitAdoptedGrants", () => { // churchdb:view station (authId 124) scopes by cdb_station. Campus ids are host-specific, so an // adopted numeric literal is a misgrant when the block is replayed on the other instance. const state = stateWithKids(); - state.resources.koblenz = { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources.koblenz = { + type: "campus", + id: 23, + key: "koblenz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; const rows: RawPermission[] = [ { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; @@ -331,7 +414,14 @@ describe("emitAdoptedGrants", () => { it("mixes managed (logical) and unmanaged (numeric) campus scopes in one grant", () => { const state = emptyState(); - state.resources.koblenz = { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources.koblenz = { + type: "campus", + id: 23, + key: "koblenz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; const rows: RawPermission[] = [ { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, { authId: 124, dataId: 99, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, @@ -379,7 +469,14 @@ describe("emitAdoptedGrants", () => { // A managed campus, so the fixture also covers the #98 typed-ref emission form (`{ campus: … }`) // — without it the round-trip property was only ever exercised on group/numeric scopes, and the // one shape that needs a pre-resolved ScopeRefMap went unchecked. - state.resources.koblenz = { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources.koblenz = { + type: "campus", + id: 23, + key: "koblenz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; const rows: RawPermission[] = [ { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // cdb_station, managed → { campus: … } { authId: 124, dataId: 777, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // cdb_station, unmanaged → numeric diff --git a/tests/permission-apply.test.ts b/tests/permission-apply.test.ts index 5021ead..063dac1 100644 --- a/tests/permission-apply.test.ts +++ b/tests/permission-apply.test.ts @@ -5,18 +5,39 @@ import { CtApiError } from "../src/api/ctClient.js"; describe("applyPermissionPlan", () => { it("PUTs each grant and DELETEs each removed tuple with the array dataId body", async () => { const request = vi.fn(async () => ({})); - const res = await applyPermissionPlan([{ - key: "t", domainType: "group_type_role", domainId: 8, - diff: { - toPut: [{ authId: 1104, dataId: [42], type: "grant" }, { authId: 1101, dataId: [], type: "grant" }], - toDelete: [{ authId: 2000, dataId: [], type: "grant" }], - preserved: [], preservedUnknown: [], - }, - }], { request } as never); + const res = await applyPermissionPlan( + [ + { + key: "t", + domainType: "group_type_role", + domainId: 8, + diff: { + toPut: [ + { authId: 1104, dataId: [42], type: "grant" }, + { authId: 1101, dataId: [], type: "grant" }, + ], + toDelete: [{ authId: 2000, dataId: [], type: "grant" }], + preserved: [], + preservedUnknown: [], + }, + }, + ], + { request } as never, + ); expect(res).toEqual({ granted: 2, deleted: 1, failed: [] }); - expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { authId: 1104, dataId: [42], type: "grant" }); - expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { authId: 1101, type: "grant" }); // no dataId when unscoped - expect(request).toHaveBeenCalledWith("DELETE", "/permissions/group_type_role/8", { authId: 2000, type: "grant" }); + expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { + authId: 1104, + dataId: [42], + type: "grant", + }); + expect(request).toHaveBeenCalledWith("PUT", "/permissions/group_type_role/8", { + authId: 1101, + type: "grant", + }); // no dataId when unscoped + expect(request).toHaveBeenCalledWith("DELETE", "/permissions/group_type_role/8", { + authId: 2000, + type: "grant", + }); }); it("collects a failed write instead of aborting the batch, and keeps writing the rest (#35 items 3+14)", async () => { @@ -26,17 +47,25 @@ describe("applyPermissionPlan", () => { if (b.authId === 1104) throw new Error("boom"); return {}; }); - const res = await applyPermissionPlan([{ - key: "t", domainType: "group_type_role", domainId: 8, - diff: { - toPut: [ - { authId: 1104, dataId: [42], type: "grant" }, - { authId: 1101, dataId: [], type: "grant" }, - ], - toDelete: [{ authId: 2000, dataId: [], type: "grant" }], - preserved: [], preservedUnknown: [], - }, - }], { request } as never); + const res = await applyPermissionPlan( + [ + { + key: "t", + domainType: "group_type_role", + domainId: 8, + diff: { + toPut: [ + { authId: 1104, dataId: [42], type: "grant" }, + { authId: 1101, dataId: [], type: "grant" }, + ], + toDelete: [{ authId: 2000, dataId: [], type: "grant" }], + preserved: [], + preservedUnknown: [], + }, + }, + ], + { request } as never, + ); expect(res.granted).toBe(1); // only the 1101 PUT succeeded expect(res.deleted).toBe(1); expect(res.failed).toEqual([ @@ -50,14 +79,22 @@ describe("applyPermissionPlan", () => { const request = vi.fn(async () => { throw new CtApiError("PUT /permissions/group_type_role/8 failed", 403, { message: "no permission" }); }); - const res = await applyPermissionPlan([{ - key: "t", domainType: "group_type_role", domainId: 8, - diff: { - toPut: [{ authId: 1104, dataId: [42], type: "grant" }], - toDelete: [], - preserved: [], preservedUnknown: [], - }, - }], { request } as never); + const res = await applyPermissionPlan( + [ + { + key: "t", + domainType: "group_type_role", + domainId: 8, + diff: { + toPut: [{ authId: 1104, dataId: [42], type: "grant" }], + toDelete: [], + preserved: [], + preservedUnknown: [], + }, + }, + ], + { request } as never, + ); expect(res.failed).toHaveLength(1); expect(res.failed[0]?.message).toContain("HTTP 403"); expect(res.failed[0]?.message).toContain("no permission"); diff --git a/tests/permission-bootstrap.test.ts b/tests/permission-bootstrap.test.ts index 480c71a..3dd2c1a 100644 --- a/tests/permission-bootstrap.test.ts +++ b/tests/permission-bootstrap.test.ts @@ -41,7 +41,15 @@ function mockClient(newId: number) { } const createKidsPlan: Plan = { - items: [{ type: "group", key: "kids", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Kids" }] }], + items: [ + { + type: "group", + key: "kids", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Kids" }], + }, + ], }; describe("scope bootstrap: declare group + grant scoped to it in one config (#29)", () => { @@ -51,7 +59,9 @@ describe("scope bootstrap: declare group + grant scoped to it in one config (#29 const { items, fetchErrors } = await buildPermissionPlan(client, state, [scopedPerm], desiredKidsGroup); expect(fetchErrors).toEqual([]); // The scoped grant survives planning and lands in toPut as a pending tuple. - expect(items[0]?.diff.toPut).toEqual([{ authId: 1104, dataId: [], type: "grant", scopeKey: "kids", scopeType: "group", pending: true }]); + expect(items[0]?.diff.toPut).toEqual([ + { authId: 1104, dataId: [], type: "grant", scopeKey: "kids", scopeType: "group", pending: true }, + ]); // Read-only `ct plan` renders instead of aborting, and labels the pending scope. expect(renderPermissionPlan(items)).toContain("kids (created this apply)"); }); @@ -75,12 +85,25 @@ describe("stale id after recreate (#33 item 3)", () => { it("re-resolves the scope dataId against post-execute state — grant PUT carries the NEW id", async () => { const { client, calls } = mockClient(777); // Pre-apply state has kids under a stale id 100 (it vanished from CT and will be recreated). - const state: State = { version: 1, host: HOST, resources: { - kids: { type: "group", id: 100, key: "kids", fields: { name: "Kids" }, adoptedAt: "t", updatedAt: "t" }, - }}; + const state: State = { + version: 1, + host: HOST, + resources: { + kids: { + type: "group", + id: 100, + key: "kids", + fields: { name: "Kids" }, + adoptedAt: "t", + updatedAt: "t", + }, + }, + }; const { items } = await buildPermissionPlan(client, state, [scopedPerm], desiredKidsGroup); // At plan time the tuple resolves to the OLD id 100… - expect(items[0]?.diff.toPut).toEqual([{ authId: 1104, dataId: [100], type: "grant", scopeKey: "kids", scopeType: "group" }]); + expect(items[0]?.diff.toPut).toEqual([ + { authId: 1104, dataId: [100], type: "grant", scopeKey: "kids", scopeType: "group" }, + ]); // …then a recreate mints id 777 in state. await executePlan(createKidsPlan, { client, state, statePath: "unused", save: async () => {} }); @@ -94,6 +117,8 @@ describe("stale id after recreate (#33 item 3)", () => { describe("truly-unknown scope key still hard-fails (#29 acceptance)", () => { it("throws the clear error when a key is neither in state nor declared", () => { - expect(() => desiredTuples(scopedPerm, emptyState(HOST), new Set())).toThrow(/scope key "kids" does not resolve/i); + expect(() => desiredTuples(scopedPerm, emptyState(HOST), new Set())).toThrow( + /scope key "kids" does not resolve/i, + ); }); }); diff --git a/tests/permission-catalog-store.test.ts b/tests/permission-catalog-store.test.ts index 5bd035c..39bceb2 100644 --- a/tests/permission-catalog-store.test.ts +++ b/tests/permission-catalog-store.test.ts @@ -45,8 +45,12 @@ function tempCatalogDir(contents?: unknown): string { describe("hostCatalogPath", () => { it("keeps one file per instance so dev and prod captures coexist", () => { - expect(hostCatalogPath("https://dev.church.tools", ".ct")).toBe(".ct/permission-catalog.dev.church.tools.json"); - expect(hostCatalogPath("https://prod.church.tools/", ".ct")).toBe(".ct/permission-catalog.prod.church.tools.json"); + expect(hostCatalogPath("https://dev.church.tools", ".ct")).toBe( + ".ct/permission-catalog.dev.church.tools.json", + ); + expect(hostCatalogPath("https://prod.church.tools/", ".ct")).toBe( + ".ct/permission-catalog.prod.church.tools.json", + ); }); }); @@ -60,7 +64,12 @@ describe("loadHostCatalog", () => { it("replaces every derived index, not just the name map", async () => { const dir = tempCatalogDir({ - $meta: { capturedFrom: "mychurch.church.tools", ctVersion: "9.9.9", capturedAt: "2026-08-13", rightCount: 1 }, + $meta: { + capturedFrom: "mychurch.church.tools", + ctVersion: "9.9.9", + capturedAt: "2026-08-13", + rightCount: 1, + }, "newmodule:new right": { authId: 4242, scopeField: "cdb_zone", revocable: false, desc: "New" }, }); @@ -101,7 +110,12 @@ describe("capturePermissionCatalog", () => { data: { auth_table: { churchdb: { - "view station": { id: 124, datenfeld: "cdb_station", bezeichnung: "Standort sehen", isRevocable: 1 }, + "view station": { + id: 124, + datenfeld: "cdb_station", + bezeichnung: "Standort sehen", + isRevocable: 1, + }, "view alldata": { id: 102, datenfeld: "", bezeichnung: "Alle sehen", isRevocable: false }, }, }, diff --git a/tests/permission-catalog.test.ts b/tests/permission-catalog.test.ts index 15a54a4..b79e669 100644 --- a/tests/permission-catalog.test.ts +++ b/tests/permission-catalog.test.ts @@ -13,7 +13,9 @@ describe("permission catalog", () => { expect(e.scopeField).toBe("cdb_gruppe"); }); it("throws a helpful error for an unknown right", () => { - expect(() => resolveAuthId("churchgroup:no such right")).toThrow(/unknown permission "churchgroup:no such right"/i); + expect(() => resolveAuthId("churchgroup:no such right")).toThrow( + /unknown permission "churchgroup:no such right"/i, + ); }); it("exposes the whole catalog (187 rights)", () => { expect(Object.keys(CATALOG).length).toBeGreaterThanOrEqual(180); diff --git a/tests/permission-grants.test.ts b/tests/permission-grants.test.ts index b21774d..a6281f5 100644 --- a/tests/permission-grants.test.ts +++ b/tests/permission-grants.test.ts @@ -7,7 +7,7 @@ describe("normalizeActual", () => { { authId: 1104, dataId: 3, type: "grant" as const, domainId: 42, meta: { modifiedPid: 1 } }, { authId: 1101, dataId: null, type: "grant" as const, domainId: 42, meta: { modifiedPid: 1 } }, { authId: 9999, dataId: 1, type: "grant" as const, domainId: 42, meta: { modifiedPid: -1 } }, // system baseline → excluded - { authId: 8888, dataId: 1, type: "grant" as const, domainId: 42, isInherited: true }, // inherited → excluded + { authId: 8888, dataId: 1, type: "grant" as const, domainId: 42, isInherited: true }, // inherited → excluded ]; expect(normalizeActual(rows)).toEqual([ { authId: 1104, dataId: [3], type: "grant" }, @@ -20,11 +20,11 @@ describe("diffGrants", () => { it("adds missing, deletes extra, no-ops identical (order-independent dataId)", () => { const desired = [ { authId: 1104, dataId: [7, 3], type: "grant" as const }, // present but reordered - { authId: 1101, dataId: [], type: "grant" as const }, // new + { authId: 1101, dataId: [], type: "grant" as const }, // new ]; const actual = [ { authId: 1104, dataId: [3, 7], type: "grant" as const }, // same tuple, different order - { authId: 2000, dataId: [], type: "grant" as const }, // extra → delete + { authId: 2000, dataId: [], type: "grant" as const }, // extra → delete ]; const d = diffGrants(desired, actual); expect(d.toPut.map(tupleKey)).toEqual([tupleKey({ authId: 1101, dataId: [], type: "grant" })]); @@ -37,8 +37,8 @@ describe("diffGrants", () => { // It must NOT land in toDelete (that would silently remove an admin's explicit deny). const desired = [{ authId: 1104, dataId: [3], type: "grant" as const }]; const actual = [ - { authId: 1104, dataId: [3], type: "grant" as const }, // matched → no-op - { authId: 1105, dataId: [7], type: "revoke" as const }, // pre-existing deny → preserved, never deleted + { authId: 1104, dataId: [3], type: "grant" as const }, // matched → no-op + { authId: 1105, dataId: [7], type: "revoke" as const }, // pre-existing deny → preserved, never deleted ]; const d = diffGrants(desired, actual); expect(d.toPut).toEqual([]); diff --git a/tests/permission-pending-domain.test.ts b/tests/permission-pending-domain.test.ts index 7210498..238fc83 100644 --- a/tests/permission-pending-domain.test.ts +++ b/tests/permission-pending-domain.test.ts @@ -34,7 +34,15 @@ const strukturPerm: DesiredPermission = { grants: ["churchgroup:administer groups"], }; const createStrukturPlan: Plan = { - items: [{ type: "group-type", key: "struktur", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Struktur" }] }], + items: [ + { + type: "group-type", + key: "struktur", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Struktur" }], + }, + ], }; /** A mock client: POST /group/grouptypes mints STRUKTUR_TYPE_ID; GETs return whatever `perms` maps. */ @@ -52,7 +60,12 @@ function mockClient(perms: Record = {}) { describe("pending domain: declare group type + grant by reference in one config (#69/#23)", () => { it("plans from EMPTY state without aborting — a pending grant block, not the hard error", async () => { const { client, get } = mockClient(); - const { items, fetchErrors } = await buildPermissionPlan(client, emptyState(HOST), [strukturPerm], strukturType); + const { items, fetchErrors } = await buildPermissionPlan( + client, + emptyState(HOST), + [strukturPerm], + strukturType, + ); expect(fetchErrors).toEqual([]); // The domain is pending: no numeric id yet, the Ref is carried for apply-time re-resolution. @@ -87,13 +100,22 @@ describe("pending domain: declare group type + grant by reference in one config // …then permission reconciliation runs against POST-execute state and writes to the fresh domain. const res = await applyPermissionPlan(items, client, state); expect(res.granted).toBe(1); - const put = calls.find((c) => c.method === "PUT" && c.path === `/permissions/group_type_role/${STRUKTUR_TYPE_ID}`); + const put = calls.find( + (c) => c.method === "PUT" && c.path === `/permissions/group_type_role/${STRUKTUR_TYPE_ID}`, + ); expect(put?.body).toEqual({ authId: 1113, type: "grant" }); // fresh domain id in the path, not a placeholder // Second plan (type now in state, grant now live) converges to a no-op — domain is concrete. const { client: c2 } = mockClient({ "/permissions/group_type_role": [ - { domainType: "group_type_role", domainId: STRUKTUR_TYPE_ID, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, + { + domainType: "group_type_role", + domainId: STRUKTUR_TYPE_ID, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, ], }); const { items: items2, fetchErrors } = await buildPermissionPlan(c2, state, [strukturPerm], strukturType); @@ -108,12 +130,30 @@ describe("pending domain: declare group type + grant by reference in one config describe("pending domain: prod-like scenario (type already in state) is unchanged (#69)", () => { it("resolves to the concrete domain id and reconciles idempotently — no pending path", async () => { - const state: State = { version: 1, host: HOST, resources: { - struktur: { type: "group-type", id: STRUKTUR_TYPE_ID, key: "struktur", fields: { name: "Struktur" }, adoptedAt: "t", updatedAt: "t" }, - }}; + const state: State = { + version: 1, + host: HOST, + resources: { + struktur: { + type: "group-type", + id: STRUKTUR_TYPE_ID, + key: "struktur", + fields: { name: "Struktur" }, + adoptedAt: "t", + updatedAt: "t", + }, + }, + }; const { client } = mockClient({ "/permissions/group_type_role": [ - { domainType: "group_type_role", domainId: STRUKTUR_TYPE_ID, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, + { + domainType: "group_type_role", + domainId: STRUKTUR_TYPE_ID, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, ], }); const { items, fetchErrors } = await buildPermissionPlan(client, state, [strukturPerm], strukturType); @@ -132,9 +172,14 @@ describe("group_role symmetry: a same-run group is NOT made pending — it stays // materially harder case than group_type_role (whose domainId is the group type's OWN id, present // in post-execute state). So group_role deliberately does NOT go pending: it fails fast at // resolve time with its own actionable message, unchanged by this fix. - const declaredGroup: DesiredResource[] = [{ type: "group", key: "kids_area", fields: { name: "Kids" }, dependsOn: [] }]; + const declaredGroup: DesiredResource[] = [ + { type: "group", key: "kids_area", fields: { name: "Kids" }, dependsOn: [] }, + ]; const grPerm: DesiredPermission = { - key: "kids_lead", domainType: "group_role", domainId: ref.groupRole("kids_area", "Leiter"), grants: [], + key: "kids_lead", + domainType: "group_role", + domainId: ref.groupRole("kids_area", "Leiter"), + grants: [], }; const { client } = mockClient(); await expect(buildPermissionPlan(client, emptyState(HOST), [grPerm], declaredGroup)).rejects.toThrow( diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index d628274..3a89c05 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -4,18 +4,29 @@ import { CATALOG_META } from "../src/permissions/catalog.js"; import { ref } from "../src/resolve/refs.js"; import type { State } from "../src/state/state.js"; -const state: State = { version: 1, 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" }, -}}; +const state: State = { + version: 1, + 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" }, + }, +}; describe("desiredTuples", () => { it("resolves names and scope to tuples", () => { const tuples = desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - "churchgroup:administer groups", // authId 1113, unscoped (no scopeField) → global - { right: "churchgroup:view group", scope: ["kids_area"] }, // authId 1104, scoped, dataId [42] - ]}, state); + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [ + "churchgroup:administer groups", // authId 1113, unscoped (no scopeField) → global + { right: "churchgroup:view group", scope: ["kids_area"] }, // authId 1104, scoped, dataId [42] + ], + }, + state, + ); expect(tuples).toEqual([ { authId: 1113, dataId: [], type: "grant" }, { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area", scopeType: "group" }, // scoped tuples retain their symbolic key for re-resolution @@ -25,22 +36,34 @@ describe("desiredTuples", () => { it("rejects a bare-string scoped right — it would silently grant globally", () => { // churchgroup:view group carries scopeField "cdb_gruppe" (it IS scoped). Declared as a bare // string it would emit dataId: [] — a global grant. It must be declared as { right, scope }. - expect(() => desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:view group"] }, state), + expect(() => + desiredTuples( + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:view group"] }, + state, + ), ).toThrow(/is a scoped right.*must be declared as \{ right/is); }); it("accepts an admin-authored authId >= 10000 member right on group_type_role (#65)", () => { // The old authId>=10000 rejection is gone — admin-authored member rights CT lets you write are // now declarable. "churchdb:+add person" (authId 10107) is unscoped, so a bare string is valid. - expect(desiredTuples({ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchdb:+add person"] }, state)) - .toEqual([{ authId: 10107, dataId: [], type: "grant" }]); + expect( + desiredTuples( + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchdb:+add person"] }, + state, + ), + ).toEqual([{ authId: 10107, dataId: [], type: "grant" }]); }); it("fans out a multi-element scope into one single-dataId tuple per dataId (idempotency: ChurchTools reads scoped grants back one row per dataId)", () => { const tuples = desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - { right: "churchgroup:view group", scope: ["kids_area", "other"] }, - ]}, state); + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [{ right: "churchgroup:view group", scope: ["kids_area", "other"] }], + }, + state, + ); // resolveScope sorts resolved dataIds ascending (7 < 42), independent of scope-key order. expect(tuples).toEqual([ { authId: 1104, dataId: [7], type: "grant", scopeKey: "other", scopeType: "group" }, @@ -51,10 +74,16 @@ describe("desiredTuples", () => { it("rejects a scoped grant on a right with no scopeField", () => { // churchcore:administer settings has scopeField: null in the catalog — not a scoped right. - expect(() => desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - { right: "churchcore:administer settings", scope: ["kids_area"] }, - ]}, state), + expect(() => + desiredTuples( + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [{ right: "churchcore:administer settings", scope: ["kids_area"] }], + }, + state, + ), ).toThrow(/not a scoped right/); }); @@ -64,9 +93,14 @@ describe("desiredTuples", () => { // declare it. Numeric entries fan out just like logical keys, and MUST NOT retain a scopeKey — // there is no state resource to re-resolve at apply time. const tuples = desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - { right: "churchdb:view comments", scope: [1, 2] }, - ]}, state); + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [{ right: "churchdb:view comments", scope: [1, 2] }], + }, + state, + ); expect(tuples).toEqual([ { authId: 113, dataId: [1], type: "grant" }, { authId: 113, dataId: [2], type: "grant" }, @@ -76,9 +110,14 @@ describe("desiredTuples", () => { it("mixes a numeric scope entry with a logical group key in the same declaration", () => { const tuples = desiredTuples( - { key: "t", domainType: "group_type_role", domainId: 8, grants: [ - { right: "churchgroup:view group", scope: ["kids_area", 3] }, - ]}, state); + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [{ right: "churchgroup:view group", scope: ["kids_area", 3] }], + }, + state, + ); expect(tuples).toEqual([ { authId: 1104, dataId: [3], type: "grant" }, { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area", scopeType: "group" }, @@ -88,58 +127,126 @@ describe("desiredTuples", () => { describe("buildPermissionPlan", () => { it("diffs desired vs actual (bulk fetch filtered to managed domainIds)", async () => { - const client = { get: vi.fn(async () => [ - { domainType: "group_type_role", domainId: 8, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, - { domainType: "group_type_role", domainId: 99, authId: 1, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, // unmanaged domainId → ignored - ]) }; - const { items, fetchErrors } = await buildPermissionPlan(client as never, state, - [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }]); + const client = { + get: vi.fn(async () => [ + { + domainType: "group_type_role", + domainId: 8, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, + { + domainType: "group_type_role", + domainId: 99, + authId: 1, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, // unmanaged domainId → ignored + ]), + }; + const { items, fetchErrors } = await buildPermissionPlan(client as never, state, [ + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }, + ]); expect(fetchErrors).toEqual([]); - expect(items[0]?.diff.toPut).toEqual([]); // 1113 unscoped already present - expect(items[0]?.diff.toDelete).toEqual([]); // domainId 99 is unmanaged → invisible + expect(items[0]?.diff.toPut).toEqual([]); // 1113 unscoped already present + expect(items[0]?.diff.toDelete).toEqual([]); // domainId 99 is unmanaged → invisible }); it("multi-scope grant is idempotent against ChurchTools's one-row-per-dataId read shape (no churn)", async () => { - const client = { get: vi.fn(async () => [ - { domainType: "group_type_role", domainId: 8, authId: 1104, dataId: 42, type: "grant", meta: { modifiedPid: 1 } }, - { domainType: "group_type_role", domainId: 8, authId: 1104, dataId: 7, type: "grant", meta: { modifiedPid: 1 } }, - ]) }; - const { items, fetchErrors } = await buildPermissionPlan(client as never, state, - [{ key: "t", domainType: "group_type_role", domainId: 8, grants: [ - { right: "churchgroup:view group", scope: ["kids_area", "other"] }, - ]}]); + const client = { + get: vi.fn(async () => [ + { + domainType: "group_type_role", + domainId: 8, + authId: 1104, + dataId: 42, + type: "grant", + meta: { modifiedPid: 1 }, + }, + { + domainType: "group_type_role", + domainId: 8, + authId: 1104, + dataId: 7, + type: "grant", + meta: { modifiedPid: 1 }, + }, + ]), + }; + const { items, fetchErrors } = await buildPermissionPlan(client as never, state, [ + { + key: "t", + domainType: "group_type_role", + domainId: 8, + grants: [{ right: "churchgroup:view group", scope: ["kids_area", "other"] }], + }, + ]); expect(fetchErrors).toEqual([]); expect(items[0]?.diff.toPut).toEqual([]); expect(items[0]?.diff.toDelete).toEqual([]); }); it("resolves a group_role domain by (group, role) reference and reconciles idempotently (#25)", async () => { - const client = { get: vi.fn(async (path: string) => { - if (path === "/groups/42/roles") return [{ id: 2882, name: "Leiter" }]; - if (path === "/permissions/group_role") return [ - { domainType: "group_role", domainId: 2882, authId: 1104, dataId: 42, type: "grant", meta: { modifiedPid: 1 } }, - ]; - throw new Error(`unexpected path ${path}`); - }) }; + const client = { + get: vi.fn(async (path: string) => { + if (path === "/groups/42/roles") return [{ id: 2882, name: "Leiter" }]; + if (path === "/permissions/group_role") + return [ + { + domainType: "group_role", + domainId: 2882, + authId: 1104, + dataId: 42, + type: "grant", + meta: { modifiedPid: 1 }, + }, + ]; + throw new Error(`unexpected path ${path}`); + }), + }; // Declared with ZERO numeric ids: right name + group key + role name only. const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, [ - { key: "kids_lead", domainType: "group_role", domainId: ref.groupRole("kids_area", "Leiter"), - grants: [{ right: "churchgroup:view group", scope: ["kids_area"] }] }, + { + key: "kids_lead", + domainType: "group_role", + domainId: ref.groupRole("kids_area", "Leiter"), + grants: [{ right: "churchgroup:view group", scope: ["kids_area"] }], + }, ]); expect(fetchErrors).toEqual([]); expect(warnings).toEqual([]); - expect(items[0]?.domainId).toBe(2882); // resolved from the (group, role) pair - expect(items[0]?.diff.toPut).toEqual([]); // adopted live row already matches → no-op + expect(items[0]?.domainId).toBe(2882); // resolved from the (group, role) pair + expect(items[0]?.diff.toPut).toEqual([]); // adopted live row already matches → no-op expect(items[0]?.diff.toDelete).toEqual([]); }); it("warns and never revokes a live grant whose authId is unknown to the catalog (#25)", async () => { - const client = { get: vi.fn(async () => [ - { domainType: "group_type_role", domainId: 8, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, // known + desired - { domainType: "group_type_role", domainId: 8, authId: 987654, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, // unknown authId - ]) }; - const { items, warnings } = await buildPermissionPlan(client as never, state, - [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }]); + const client = { + get: vi.fn(async () => [ + { + domainType: "group_type_role", + domainId: 8, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, // known + desired + { + domainType: "group_type_role", + domainId: 8, + authId: 987654, + dataId: null, + type: "grant", + meta: { modifiedPid: 1 }, + }, // unknown authId + ]), + }; + const { items, warnings } = await buildPermissionPlan(client as never, state, [ + { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }, + ]); expect(items[0]?.diff.toDelete).toEqual([]); // the unnameable grant is NOT proposed for revocation expect(items[0]?.diff.toPut).toEqual([]); expect(warnings.some((w) => w.includes("987654") && w.includes("group_type_role #8"))).toBe(true); @@ -151,17 +258,53 @@ describe("buildPermissionPlan", () => { // self-re-adding system baseline (modifiedPid === -1) and truly-inherited rows are excluded by // normalizeActual and NEVER revoked (the #65 bug was "0 to grant, 24 to remove"). The boundary is // inheritance + system-baseline, NOT the authId — so the excluded rows below carry authId >= 10000 too. - const client = { get: vi.fn(async () => [ - // declared writable grant — matches, no diff - { domainType: "group_type_role", domainId: 9, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, - // admin-authored member right (authId >= 10000, pid 5) that is UNDECLARED → must be revoked - { domainType: "group_type_role", domainId: 9, authId: 10107, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, - // system baseline + inherited (authId >= 10000) → excluded, never revoked - { domainType: "group_type_role", domainId: 9, authId: 10122, dataId: null, type: "grant", meta: { modifiedPid: -1 } }, - { domainType: "group_type_role", domainId: 9, authId: 10111, dataId: null, type: "grant", isInherited: true }, - ]) }; - const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, - [{ key: "struktur", domainType: "group_type_role", domainId: 9, grants: ["churchgroup:administer groups"] }]); + const client = { + get: vi.fn(async () => [ + // declared writable grant — matches, no diff + { + domainType: "group_type_role", + domainId: 9, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 5 }, + }, + // admin-authored member right (authId >= 10000, pid 5) that is UNDECLARED → must be revoked + { + domainType: "group_type_role", + domainId: 9, + authId: 10107, + dataId: null, + type: "grant", + meta: { modifiedPid: 5 }, + }, + // system baseline + inherited (authId >= 10000) → excluded, never revoked + { + domainType: "group_type_role", + domainId: 9, + authId: 10122, + dataId: null, + type: "grant", + meta: { modifiedPid: -1 }, + }, + { + domainType: "group_type_role", + domainId: 9, + authId: 10111, + dataId: null, + type: "grant", + isInherited: true, + }, + ]), + }; + const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, [ + { + key: "struktur", + domainType: "group_type_role", + domainId: 9, + grants: ["churchgroup:administer groups"], + }, + ]); expect(fetchErrors).toEqual([]); expect(items[0]?.diff.toPut).toEqual([]); // the declared writable grant already matches // the undeclared admin-authored member right IS revoked; the baseline + inherited rows are NOT @@ -173,15 +316,30 @@ describe("buildPermissionPlan", () => { it("reconciles admin-authored authId >= 10000 rights on group_role too (no authId cutoff, #65)", async () => { // No authId cutoff on either domain: on group_role the churchdb:+… rights ARE writable/declarable, // so a live admin-authored one that is undeclared must still be revoked (no blanket exclusion). - const client = { get: vi.fn(async (path: string) => { - if (path === "/groups/42/roles") return [{ id: 2882, name: "Leiter" }]; - if (path === "/permissions/group_role") return [ - { domainType: "group_role", domainId: 2882, authId: 10122, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, - ]; - throw new Error(`unexpected path ${path}`); - }) }; + const client = { + get: vi.fn(async (path: string) => { + if (path === "/groups/42/roles") return [{ id: 2882, name: "Leiter" }]; + if (path === "/permissions/group_role") + return [ + { + domainType: "group_role", + domainId: 2882, + authId: 10122, + dataId: null, + type: "grant", + meta: { modifiedPid: 5 }, + }, + ]; + throw new Error(`unexpected path ${path}`); + }), + }; const { items } = await buildPermissionPlan(client as never, state, [ - { key: "kids_lead", domainType: "group_role", domainId: ref.groupRole("kids_area", "Leiter"), grants: [] }, + { + key: "kids_lead", + domainType: "group_role", + domainId: ref.groupRole("kids_area", "Leiter"), + grants: [], + }, ]); expect(items[0]?.diff.toDelete).toEqual([{ authId: 10122, dataId: [], type: "grant" }]); }); @@ -189,29 +347,72 @@ describe("buildPermissionPlan", () => { it("still revokes a REAL user-authored grant (authId < 10000) that is undeclared (#65 guard)", async () => { // Regression guard: the inherited-rights exclusion must NOT swallow ordinary undeclared grants — // those are exactly the drift a plan is meant to surface as a revoke. - const client = { get: vi.fn(async () => [ - { domainType: "group_type_role", domainId: 9, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, // declared - { domainType: "group_type_role", domainId: 9, authId: 1104, dataId: 42, type: "grant", meta: { modifiedPid: 5 } }, // undeclared user grant - { domainType: "group_type_role", domainId: 9, authId: 10101, dataId: null, type: "grant", isInherited: true }, // inherited → excluded - ]) }; - const { items } = await buildPermissionPlan(client as never, state, - [{ key: "struktur", domainType: "group_type_role", domainId: 9, grants: ["churchgroup:administer groups"] }]); + const client = { + get: vi.fn(async () => [ + { + domainType: "group_type_role", + domainId: 9, + authId: 1113, + dataId: null, + type: "grant", + meta: { modifiedPid: 5 }, + }, // declared + { + domainType: "group_type_role", + domainId: 9, + authId: 1104, + dataId: 42, + type: "grant", + meta: { modifiedPid: 5 }, + }, // undeclared user grant + { + domainType: "group_type_role", + domainId: 9, + authId: 10101, + dataId: null, + type: "grant", + isInherited: true, + }, // inherited → excluded + ]), + }; + const { items } = await buildPermissionPlan(client as never, state, [ + { + key: "struktur", + domainType: "group_type_role", + domainId: 9, + grants: ["churchgroup:administer groups"], + }, + ]); expect(items[0]?.diff.toDelete).toEqual([{ authId: 1104, dataId: [42], type: "grant" }]); }); it("warns when the instance CT version differs from the catalog's recorded version (#25)", async () => { const client = { get: vi.fn(async () => []) }; - const { warnings } = await buildPermissionPlan(client as never, state, + const { warnings } = await buildPermissionPlan( + client as never, + state, [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }], - [], undefined, "9.99.0"); - expect(warnings.some((w) => /catalog was captured from ChurchTools .* but this instance\s+runs 9\.99\.0/is.test(w))).toBe(true); + [], + undefined, + "9.99.0", + ); + expect( + warnings.some((w) => + /catalog was captured from ChurchTools .* but this instance\s+runs 9\.99\.0/is.test(w), + ), + ).toBe(true); }); it("does NOT warn about staleness when the instance version matches the catalog version (#25)", async () => { const client = { get: vi.fn(async () => []) }; - const { warnings } = await buildPermissionPlan(client as never, state, + const { warnings } = await buildPermissionPlan( + client as never, + state, [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }], - [], undefined, CATALOG_META!.ctVersion); + [], + undefined, + CATALOG_META!.ctVersion, + ); expect(warnings).toEqual([]); }); // The PERSON-status domain end to end (#90): a `personStatus` ref resolves against /statuses, the @@ -219,33 +420,57 @@ describe("buildPermissionPlan", () => { // a clean no-op — which is the whole point (an instance-wide grant that churned every plan would // rewrite everyone's rights on every apply). 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 === "/permissions/status") return [ - { domainType: "status", domainId: 6, authId: 18, dataId: -1, type: "grant", meta: { modifiedPid: 1 } }, - ]; - throw new Error(`unexpected path ${path}`); - }) }; + 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 === "/permissions/status") + return [ + { + domainType: "status", + domainId: 6, + authId: 18, + dataId: -1, + type: "grant", + meta: { modifiedPid: 1 }, + }, + ]; + throw new Error(`unexpected path ${path}`); + }), + }; const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, [ - { key: "core_login", domainType: "status", domainId: ref.personStatus("5 - Core"), - grants: [{ right: "churchcore:login to external system", scope: [-1] }] }, + { + key: "core_login", + domainType: "status", + domainId: ref.personStatus("5 - 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); // resolved from the /statuses catalog expect(items[0]?.diff.toPut).toEqual([]); // live -1 row matches the declaration expect(items[0]?.diff.toDelete).toEqual([]); }); 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 === "/permissions/status") return []; - throw new Error(`unexpected path ${path}`); - }) }; + const client = { + get: vi.fn(async (path: string) => { + if (path === "/statuses") return [{ id: 4, name: "3 - Group Active" }]; + if (path === "/permissions/status") return []; + throw new Error(`unexpected path ${path}`); + }), + }; const { items } = await buildPermissionPlan(client as never, state, [ - { key: "group_active_login", domainType: "status", domainId: ref.personStatus("3 - Group Active"), - grants: [{ right: "churchcore:login to external system", scope: [-1] }] }, + { + key: "group_active_login", + domainType: "status", + domainId: ref.personStatus("3 - Group Active"), + grants: [{ right: "churchcore:login to external system", scope: [-1] }], + }, ]); expect(items[0]?.domainId).toBe(4); expect(items[0]?.diff.toPut).toEqual([{ authId: 18, dataId: [-1], type: "grant" }]); diff --git a/tests/permission-scope-refs.test.ts b/tests/permission-scope-refs.test.ts index d69c07b..d75e642 100644 --- a/tests/permission-scope-refs.test.ts +++ b/tests/permission-scope-refs.test.ts @@ -38,7 +38,14 @@ function stateWithKoblenz(id: number): State { version: 1, host: HOST, resources: { - koblenz: { type: "campus", id, key: "koblenz", fields: { name: "Koblenz" }, adoptedAt: "t", updatedAt: "t" }, + koblenz: { + type: "campus", + id, + key: "koblenz", + fields: { name: "Koblenz" }, + adoptedAt: "t", + updatedAt: "t", + }, }, }; } @@ -55,11 +62,21 @@ function mockClient(campuses: { id: number; name: string }[] = [], newId = 555) return { client: { get, request } as unknown as CtClient, calls, get }; } -async function tuplesFor(perm: DesiredPermission, state: State, desired: DesiredResource[] = [], client?: CtClient) { +async function tuplesFor( + perm: DesiredPermission, + state: State, + desired: DesiredResource[] = [], + client?: CtClient, +) { const c = client ?? mockClient().client; const resolver = new Resolver({ client: c, state, desired }); const refs = await resolveScopeRefs([perm], resolver, state); - return desiredTuples(perm, state, new Set(desired.filter((r) => r.type === "group").map((r) => r.key)), refs); + return desiredTuples( + perm, + state, + new Set(desired.filter((r) => r.type === "group").map((r) => r.key)), + refs, + ); } describe("campus-scoped grants are portable across hosts (#98)", () => { @@ -68,14 +85,27 @@ describe("campus-scoped grants are portable across hosts (#98)", () => { const dev = await tuplesFor(campusScoped, stateWithKoblenz(6)); const prod = await tuplesFor(campusScoped, stateWithKoblenz(23)); - expect(dev).toEqual([{ authId: 124, dataId: [6], type: "grant", scopeKey: "koblenz", scopeType: "campus" }]); - expect(prod).toEqual([{ authId: 124, dataId: [23], type: "grant", scopeKey: "koblenz", scopeType: "campus" }]); + expect(dev).toEqual([ + { authId: 124, dataId: [6], type: "grant", scopeKey: "koblenz", scopeType: "campus" }, + ]); + expect(prod).toEqual([ + { authId: 124, dataId: [23], type: "grant", scopeKey: "koblenz", scopeType: "campus" }, + ]); }); it("plans to a clean no-op against each host's live rows", async () => { for (const id of [6, 23]) { const state = stateWithKoblenz(id); - const live = [{ domainType: "group_role", domainId: 900, authId: 124, dataId: id, type: "grant", meta: { modifiedPid: 5 } }]; + const live = [ + { + domainType: "group_role", + domainId: 900, + authId: 124, + dataId: id, + type: "grant", + meta: { modifiedPid: 5 }, + }, + ]; const client = { get: vi.fn(async (path: string) => (path === "/campuses" ? [] : live)) }; const { items, fetchErrors } = await buildPermissionPlan(client as never, state, [campusScoped]); expect(fetchErrors).toEqual([]); @@ -93,7 +123,9 @@ describe("campus-scoped grants are portable across hosts (#98)", () => { }); it("a campus created in the same run is pending at plan time and gets its real id at apply time", async () => { - const desired: DesiredResource[] = [{ type: "campus", key: "koblenz", fields: { name: "Koblenz", shorty: "KO" }, dependsOn: [] }]; + const desired: DesiredResource[] = [ + { type: "campus", key: "koblenz", fields: { name: "Koblenz", shorty: "KO" }, dependsOn: [] }, + ]; const { client, calls } = mockClient([], 555); const state = emptyState(HOST); const { items } = await buildPermissionPlan(client, state, [campusScoped], desired); @@ -102,7 +134,15 @@ describe("campus-scoped grants are portable across hosts (#98)", () => { ]); const createCampus: Plan = { - items: [{ type: "campus", key: "koblenz", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Koblenz" }] }], + items: [ + { + type: "campus", + key: "koblenz", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Koblenz" }], + }, + ], }; await executePlan(createCampus, { client, state, statePath: "unused", save: async () => {} }); expect(state.resources.koblenz?.id).toBe(555); @@ -119,7 +159,9 @@ describe("scope-dimension validation (#98)", () => { it("rejects a ref whose dimension does not match the right's scopeField, naming both", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, // churchdb:view station scopes by cdb_station (campus) — a group-type ref there is a config bug. grants: [{ right: VIEW_STATION, scope: [ref.groupType("struktur")] }], }; @@ -130,7 +172,9 @@ describe("scope-dimension validation (#98)", () => { it("rejects a ref on a dimension with no logical form at all, naming the ref and the dimension", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, // churchdb:security level person scopes by cc_securitylevel — security levels are not resources. grants: [{ right: "churchdb:security level person", scope: [ref.campus("koblenz")] }], }; @@ -141,7 +185,9 @@ describe("scope-dimension validation (#98)", () => { it("rejects a logical ref on a dimension that has no logical form, pointing at the numeric hatch", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, // ccm_data_category: its values are not resources at all — numbers only, by nature. grants: [{ right: "jpmFlowManagerPreview:view custom category", scope: [ref.campus("x")] }], }; @@ -152,7 +198,9 @@ describe("scope-dimension validation (#98)", () => { it("rejects a logical ref on an unscoped right", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: "churchcore:administer settings", scope: [ref.campus("koblenz")] }], }; await expect(resolveScopeRefs([perm], resolverFor(emptyState(HOST)), emptyState(HOST))).rejects.toThrow( @@ -164,16 +212,22 @@ describe("scope-dimension validation (#98)", () => { // Before #98 this was the trap: "koblenz" was looked up among managed GROUPS regardless of the // right's dimension, so it either errored confusingly or matched an unrelated same-keyed group. const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: VIEW_STATION, scope: ["koblenz"] }], }; const state = stateWithKoblenz(6); - await expect(tuplesFor(perm, state)).rejects.toThrow(/bare string.*scopes by "cdb_station".*\{ campus: "koblenz" \}/s); + await expect(tuplesFor(perm, state)).rejects.toThrow( + /bare string.*scopes by "cdb_station".*\{ campus: "koblenz" \}/s, + ); }); it("a ref that cannot resolve is a hard error, never a dropped or guessed dataId", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: VIEW_STATION, scope: [ref.campus("nowhere")] }], }; await expect(tuplesFor(perm, emptyState(HOST))).rejects.toThrow(/Cannot resolve campus:nowhere/); @@ -181,18 +235,26 @@ describe("scope-dimension validation (#98)", () => { it("still accepts a numeric dataId on any dimension (the #49 escape hatch is untouched)", async () => { const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: "churchdb:view alldata", scope: [7] }], }; expect(await tuplesFor(perm, emptyState(HOST))).toEqual([{ authId: 102, dataId: [7], type: "grant" }]); }); it("resolves a group-type scope ref against the managed group type", async () => { - const state: State = { version: 1, host: HOST, resources: { - struktur: { type: "group-type", id: 12, key: "struktur", fields: {}, adoptedAt: "t", updatedAt: "t" }, - }}; + const state: State = { + version: 1, + host: HOST, + resources: { + struktur: { type: "group-type", id: 12, key: "struktur", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + }; const perm: DesiredPermission = { - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: "churchgroup:view groups of grouptype", scope: [ref.groupType("struktur")] }], }; expect(await tuplesFor(perm, state)).toEqual([ @@ -204,11 +266,18 @@ describe("scope-dimension validation (#98)", () => { describe("department scopes are a READ-ONLY ref catalog (cdb_bereich, #98)", () => { // `/departments` is GET-only — live-probed 2026-08-13 against the instance's own OpenAPI spec // (eqrm prod, CT 3.135.2). So a department resolves by NAME on every host but is never declarable. - const departments = [{ id: 7, name: "Equippers Koblenz" }, { id: 1, name: "Equippers Rhein-Main" }]; - const client = { get: vi.fn(async (path: string) => (path === "/departments" ? departments : [])) } as unknown as CtClient; + const departments = [ + { id: 7, name: "Equippers Koblenz" }, + { id: 1, name: "Equippers Rhein-Main" }, + ]; + const client = { + get: vi.fn(async (path: string) => (path === "/departments" ? departments : [])), + } as unknown as CtClient; const perm = (key: string): DesiredPermission => ({ - key: "p", domainType: "group_role", domainId: 1, + key: "p", + domainType: "group_role", + domainId: 1, grants: [{ right: "churchdb:view alldata", scope: [{ department: key }] }], }); @@ -227,9 +296,20 @@ describe("department scopes are a READ-ONLY ref catalog (cdb_bereich, #98)", () }); 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" }, - }}; + const state: State = { + version: 1, + host: HOST, + resources: { + equippers_koblenz: { + type: "group", + id: 999, + key: "equippers_koblenz", + 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" }, @@ -238,7 +318,7 @@ describe("department scopes are a READ-ONLY ref catalog (cdb_bereich, #98)", () }); describe("scope object sugar", () => { - it("compiles { campus: \"koblenz\" } to the same Ref as ref.campus(...)", () => { + it('compiles { campus: "koblenz" } to the same Ref as ref.campus(...)', () => { const { ct, permissions } = createContext(); ct.groupRole({ key: "p", id: 900, grants: [{ right: VIEW_STATION, scope: [{ campus: "koblenz" }] }] }); expect(permissions[0]?.grants).toEqual([{ right: VIEW_STATION, scope: [ref.campus("koblenz")] }]); @@ -247,10 +327,18 @@ describe("scope object sugar", () => { it("rejects a scope object naming zero or several dimensions", () => { const { ct } = createContext(); expect(() => - ct.groupRole({ key: "a", id: 1, grants: [{ right: VIEW_STATION, scope: [{ campus: "a", groupType: "b" } as never] }] }), + ct.groupRole({ + key: "a", + id: 1, + grants: [{ right: VIEW_STATION, scope: [{ campus: "a", groupType: "b" } as never] }], + }), ).toThrow(/exactly one dimension/); expect(() => - ct.groupRole({ key: "b", id: 2, grants: [{ right: VIEW_STATION, scope: [{ bereich: "a" } as never] }] }), + ct.groupRole({ + key: "b", + id: 2, + grants: [{ right: VIEW_STATION, scope: [{ bereich: "a" } as never] }], + }), ).toThrow(/unknown scope dimension "bereich"/); }); @@ -264,18 +352,40 @@ describe("scope object sugar", () => { describe("re-resolution is type-aware", () => { it("re-resolves a campus scope against the campus in state, not a same-keyed group", () => { - const state: State = { version: 1, host: HOST, resources: { - koblenz: { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, - }}; - const t = { authId: 124, dataId: [], type: "grant" as const, scopeKey: "koblenz", scopeType: "campus", pending: true }; + const state: State = { + version: 1, + host: HOST, + resources: { + koblenz: { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + }; + const t = { + authId: 124, + dataId: [], + type: "grant" as const, + scopeKey: "koblenz", + scopeType: "campus", + pending: true, + }; expect(reresolveTuple(t, state)).toEqual({ ...t, dataId: [23], pending: false }); }); it("refuses to write a grant whose scope key now names a different resource type", () => { - const state: State = { version: 1, host: HOST, resources: { - koblenz: { type: "group", id: 5, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, - }}; - const t = { authId: 124, dataId: [], type: "grant" as const, scopeKey: "koblenz", scopeType: "campus", pending: true }; + const state: State = { + version: 1, + host: HOST, + resources: { + koblenz: { type: "group", id: 5, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + }; + const t = { + authId: 124, + dataId: [], + type: "grant" as const, + scopeKey: "koblenz", + scopeType: "campus", + pending: true, + }; expect(() => reresolveTuple(t, state)).toThrow(/did not resolve to a managed campus/); }); }); diff --git a/tests/permission-scope.test.ts b/tests/permission-scope.test.ts index 65b0044..283943c 100644 --- a/tests/permission-scope.test.ts +++ b/tests/permission-scope.test.ts @@ -2,10 +2,14 @@ import { describe, it, expect } from "vitest"; import { resolveScope } from "../src/permissions/scope.js"; import type { State } from "../src/state/state.js"; -const state: State = { version: 1, 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" }, -}}; +const state: State = { + version: 1, + 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" }, + }, +}; describe("resolveScope", () => { it("maps managed group keys to resolutions sorted by id", () => { @@ -15,10 +19,14 @@ describe("resolveScope", () => { ]); }); it("resolves a declared-but-not-yet-created group key to a pending (null id) resolution", () => { - expect(resolveScope(["kids"], state, new Set(["kids"]))).toEqual([{ key: "kids", id: null, type: "group" }]); + expect(resolveScope(["kids"], state, new Set(["kids"]))).toEqual([ + { key: "kids", id: null, type: "group" }, + ]); }); it("orders resolved (in-state, by id) before pending (by key)", () => { - expect(resolveScope(["pending_b", "kids_area", "pending_a"], state, new Set(["pending_a", "pending_b"]))).toEqual([ + expect( + resolveScope(["pending_b", "kids_area", "pending_a"], state, new Set(["pending_a", "pending_b"])), + ).toEqual([ { key: "kids_area", id: 42, type: "group" }, { key: "pending_a", id: null, type: "group" }, { key: "pending_b", id: null, type: "group" }, diff --git a/tests/permission.integration.test.ts b/tests/permission.integration.test.ts index 3615d6c..8fdda14 100644 --- a/tests/permission.integration.test.ts +++ b/tests/permission.integration.test.ts @@ -63,7 +63,12 @@ describe.runIf(live)("permission round-trip (live, read-only)", () => { return { right: name, scope: t.dataId.map((id) => `fixture_group_${id}`) }; }); - const desired: DesiredPermission = { key: "fixture", domainType: "group_type_role", domainId: DOMAIN_ID, grants }; + const desired: DesiredPermission = { + key: "fixture", + domainType: "group_type_role", + domainId: DOMAIN_ID, + grants, + }; // Read-only: buildPermissionPlan only issues GETs. const { items, fetchErrors } = await buildPermissionPlan(client, state, [desired]); diff --git a/tests/person-status-resource.test.ts b/tests/person-status-resource.test.ts index d28b716..d0bfb78 100644 --- a/tests/person-status-resource.test.ts +++ b/tests/person-status-resource.test.ts @@ -43,16 +43,31 @@ describe("the person-status registry entry", () => { // declare no required fields at all). The executor sends the declared bag as a full-replace PUT, // so anything left unmanaged here would be dropped from the body — a 400, or a silent blanking. expect([...knownFields("person-status")].sort()).toEqual([ - "isMember", "isSearchable", "name", "securityLevelId", "shorty", "sortKey", + "isMember", + "isSearchable", + "name", + "securityLevelId", + "shorty", + "sortKey", ]); expect( RESOURCES["person-status"]?.managedFields({ - id: 3, name: "3 - Group Active", shorty: "GA", isMember: true, - isSearchable: false, sortKey: 30, securityLevelId: 1, extra: 1, + id: 3, + name: "3 - Group Active", + shorty: "GA", + isMember: true, + isSearchable: false, + sortKey: 30, + securityLevelId: 1, + extra: 1, }), ).toEqual({ - name: "3 - Group Active", shorty: "GA", isMember: true, - isSearchable: false, sortKey: 30, securityLevelId: 1, + name: "3 - Group Active", + shorty: "GA", + isMember: true, + isSearchable: false, + sortKey: 30, + securityLevelId: 1, }); }); @@ -90,7 +105,9 @@ describe("ct.personStatus in the config DSL", () => { try { const { ct, resources } = createContext(); ct.personStatus({ key: "s", name: "3 - Group Active", nameTranslated: "Group Active" }); - expect(String(spy.mock.calls[0]![0])).toContain('person-status "s": unknown field "nameTranslated" (ignored)'); + expect(String(spy.mock.calls[0]![0])).toContain( + 'person-status "s": unknown field "nameTranslated" (ignored)', + ); expect(resources[0]?.fields).toHaveProperty("nameTranslated", "Group Active"); // still passed through } finally { spy.mockRestore(); @@ -102,9 +119,20 @@ describe("resolving a personStatus reference", () => { const client = { get: vi.fn(async () => [{ id: 3, name: "3 - Group Active" }]) }; it("prefers a MANAGED status in state over the live /statuses catalog", async () => { - const state: State = { version: 1, host: HOST, resources: { - group_active: { type: "person-status", id: 8, key: "group_active", fields: {}, adoptedAt: "t", updatedAt: "t" }, - }}; + const state: State = { + version: 1, + host: HOST, + resources: { + group_active: { + type: "person-status", + id: 8, + key: "group_active", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }, + }, + }; const resolver = new Resolver({ client: client as never, state, desired: [] }); expect(await resolver.resolve(ref.personStatus("group_active"), "site")).toBe(8); }); @@ -153,8 +181,15 @@ describe("declare a status AND grants on it, in one config (the #96 trap)", () = const { items } = await buildPermissionPlan(client, state, [perm], desired); const createStatus: Plan = { - items: [{ type: "person-status", key: "group_active", id: null, action: "create", - changes: [{ field: "name", from: undefined, to: "3 - Group Active" }] }], + items: [ + { + type: "person-status", + key: "group_active", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "3 - Group Active" }], + }, + ], }; await executePlan(createStatus, { client, state, statePath: "unused", save: async () => {} }); expect(state.resources.group_active?.id).toBe(9); diff --git a/tests/plan-detailed-exitcode-command.test.ts b/tests/plan-detailed-exitcode-command.test.ts index cda9753..e51e936 100644 --- a/tests/plan-detailed-exitcode-command.test.ts +++ b/tests/plan-detailed-exitcode-command.test.ts @@ -110,7 +110,12 @@ describe("ct plan --detailed-exitcode", () => { key: "team", domainType: "group_role", domainId: 1, - diff: { toPut: [{ authId: 5, dataId: [], type: "grant" }], toDelete: [], preserved: [], preservedUnknown: [] }, + diff: { + toPut: [{ authId: 5, dataId: [], type: "grant" }], + toDelete: [], + preserved: [], + preservedUnknown: [], + }, }, ]; await runPlan(["--detailed-exitcode"]); diff --git a/tests/plan.test.ts b/tests/plan.test.ts index 2f9f316..f20a114 100644 --- a/tests/plan.test.ts +++ b/tests/plan.test.ts @@ -35,7 +35,9 @@ describe("computePlan", () => { const plan = computePlan([desired("mainz", { name: "Mainz" })], stateOf(), new Map()); expect(plan.items).toHaveLength(1); expect(plan.items[0]).toMatchObject({ action: "create", key: "mainz", id: null }); - expect(plan.items[0]?.changes).toEqual([{ field: "name", from: undefined, to: "Mainz", source: "config" }]); + expect(plan.items[0]?.changes).toEqual([ + { field: "name", from: undefined, to: "Mainz", source: "config" }, + ]); }); it("is a no-op when desired matches actual (id 0 handled)", () => { @@ -79,11 +81,7 @@ describe("computePlan", () => { // A raw array from a programmatic caller (import command, test harness) could carry duplicates — // desiredByKey would collapse them while the plan loop emits both. Reject up front. expect(() => - computePlan( - [desired("dup", { name: "A" }), desired("dup", { name: "B" })], - stateOf(), - new Map(), - ), + computePlan([desired("dup", { name: "A" }), desired("dup", { name: "B" })], stateOf(), new Map()), ).toThrow(/Duplicate desired key "dup"/); }); @@ -94,7 +92,9 @@ describe("computePlan", () => { actualOf({ mainz: { name: "Mainz", shortName: "MZ" } }), ); expect(plan.items[0]).toMatchObject({ action: "update", id: 5 }); - expect(plan.items[0]?.changes).toEqual([{ field: "name", from: "Mainz", to: "Mainz HQ", source: "config" }]); + expect(plan.items[0]?.changes).toEqual([ + { field: "name", from: "Mainz", to: "Mainz HQ", source: "config" }, + ]); }); it("does not flag a mere object-key-order difference as a change", () => { @@ -182,7 +182,7 @@ describe("computePlan", () => { // apart from "both happened, independently". Computed from the SAME three values already // available (last-known state snapshot, desired config, fetched actual) — no new fetch. describe("changes[].source attribution (#24)", () => { - it("tags a plain config change as \"config\" (ChurchTools still matches the last-known snapshot)", () => { + it('tags a plain config change as "config" (ChurchTools still matches the last-known snapshot)', () => { const plan = computePlan( [desired("mainz", { name: "Mainz HQ" })], stateOf(managed("mainz", 5, { name: "Mainz" })), @@ -193,7 +193,7 @@ describe("changes[].source attribution (#24)", () => { ]); }); - it("tags a pure manual edit as \"drift\" (config unchanged, ChurchTools moved)", () => { + it('tags a pure manual edit as "drift" (config unchanged, ChurchTools moved)', () => { const plan = computePlan( [desired("mainz", { name: "Mainz" })], // config still says the last-known value stateOf(managed("mainz", 5, { name: "Mainz" })), @@ -205,7 +205,7 @@ describe("changes[].source attribution (#24)", () => { expect(plan.items[0]?.drift).toEqual([{ field: "name", from: "Mainz", to: "Changed In CT" }]); }); - it("tags \"config+drift\" when both the config AND ChurchTools moved independently", () => { + it('tags "config+drift" when both the config AND ChurchTools moved independently', () => { const plan = computePlan( [desired("mainz", { name: "New Config Name" })], stateOf(managed("mainz", 5, { name: "Old Name" })), @@ -216,14 +216,14 @@ describe("changes[].source attribution (#24)", () => { ]); }); - it("always tags a create's changes as \"config\" (nothing to drift from yet)", () => { + it('always tags a create\'s changes as "config" (nothing to drift from yet)', () => { const plan = computePlan([desired("mainz", { name: "Mainz" })], stateOf(), new Map()); expect(plan.items[0]?.changes).toEqual([ { field: "name", from: undefined, to: "Mainz", source: "config" }, ]); }); - it("always tags a recreate's changes as \"config\"", () => { + it('always tags a recreate\'s changes as "config"', () => { const plan = computePlan( [desired("mainz", { name: "Mainz" })], stateOf(managed("mainz", 5, { name: "Mainz" })), @@ -243,9 +243,7 @@ describe("changes[].source attribution (#24)", () => { stateOf(managedT("group", "team", 9, { name: "Team" })), // no campusId in the snapshot actualOf({ team: { name: "Team", campusId: 4 } }), ); - expect(plan.items[0]?.changes).toEqual([ - { field: "campusId", from: 4, to: 7, source: "config" }, - ]); + expect(plan.items[0]?.changes).toEqual([{ field: "campusId", from: 4, to: 7, source: "config" }]); expect(plan.items[0]?.drift).toBeUndefined(); }); }); diff --git a/tests/query-refs.test.ts b/tests/query-refs.test.ts index f994fdd..11cd719 100644 --- a/tests/query-refs.test.ts +++ b/tests/query-refs.test.ts @@ -61,7 +61,12 @@ describe("portablizeRuleset (#76 Stage 2)", () => { it("rewrites every managed id in a `oneof` id list and keeps operand order", () => { const ruleset = { query: churchQuery(q.oneof("ctgroup.id", [148, 1228])) }; const { ruleset: out } = portablizeRuleset(ruleset, { - idToKeyByKind: { group: new Map([[148, "jugend-mainz"], [1228, "jugend-berlin"]]) }, + idToKeyByKind: { + group: new Map([ + [148, "jugend-mainz"], + [1228, "jugend-berlin"], + ]), + }, }); const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; expect(filter.oneof[0]).toEqual({ var: "ctgroup.id" }); @@ -114,7 +119,10 @@ describe("portablizeRuleset (#76 Stage 2)", () => { [84, { groupTypeId: 12, name: "Leiter" }], [16, { groupTypeId: 2, name: "Leiter" }], // SAME name as 84, different group type ]); - const groupTypeIdToKey = new Map([[12, "local_lead"], [2, "team"]]); + const groupTypeIdToKey = new Map([ + [12, "local_lead"], + [2, "team"], + ]); it("disambiguates two same-named roles by their group type", () => { const ruleset = { query: churchQuery(q.oneof("role.id", [84, 16])) }; @@ -195,7 +203,11 @@ describe("portablizeRuleset (#76 Stage 2)", () => { it("rewrites the integer field to a group-type-role marker via the same role catalog", () => { const ruleset = { query: churchQuery(q.eq("person.isArchived", 0)), - process: { queryResultOnly: { none: { handleMembership: { groupMemberStatus: "active", groupTypeRoleId: 66 } } } }, + process: { + queryResultOnly: { + none: { handleMembership: { groupMemberStatus: "active", groupTypeRoleId: 66 } }, + }, + }, }; const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: {}, @@ -215,7 +227,9 @@ describe("portablizeRuleset (#76 Stage 2)", () => { }); it("leaves the field numeric with a warning when the role is unknown", () => { - const ruleset = { process: { queryResultOnly: { none: { handleMembership: { groupTypeRoleId: 4242 } } } } }; + const ruleset = { + process: { queryResultOnly: { none: { handleMembership: { groupTypeRoleId: 4242 } } } }, + }; const { ruleset: out, warnings } = portablizeRuleset(ruleset, { idToKeyByKind: {}, roleCatalog, @@ -252,10 +266,17 @@ describe("portablizeRuleset (#76 Stage 2)", () => { [17, { groupTypeId: 2, name: "Organisator" }], [66, { groupTypeId: 9, name: "Mitglied" }], // the process.groupTypeRoleId target ]); - const groupTypeIdToKey = new Map([[12, "local_lead"], [2, "team"], [9, "struktur"]]); + const groupTypeIdToKey = new Map([ + [12, "local_lead"], + [2, "team"], + [9, "struktur"], + ]); // Group 1246 is deliberately UNMANAGED (not in the map) — the escape hatch: it stays numeric. const idToKeyByKind: Partial>> = { - group: new Map([[112, "bereich_kids"], [8, "team_kidsdienst"]]), + group: new Map([ + [112, "bereich_kids"], + [8, "team_kidsdienst"], + ]), }; const opts = { idToKeyByKind, roleCatalog, groupTypeIdToKey }; @@ -263,9 +284,12 @@ describe("portablizeRuleset (#76 Stage 2)", () => { const { ruleset: portable } = portablizeRuleset(normalized, opts); const json = JSON.stringify(portable); // A query role marker (84 → local_lead/Leiter) and the process-field marker (66 → struktur/Mitglied). - expect(json).toContain('{"__ctRef":true,"kind":"group-type-role","groupType":"local_lead","role":"Leiter"}'); - const hm = (portable.process as { queryResultOnly: { none: { handleMembership: Record } } }) - .queryResultOnly.none.handleMembership; + expect(json).toContain( + '{"__ctRef":true,"kind":"group-type-role","groupType":"local_lead","role":"Leiter"}', + ); + const hm = ( + portable.process as { queryResultOnly: { none: { handleMembership: Record } } } + ).queryResultOnly.none.handleMembership; expect(hm.groupTypeRoleId).toEqual({ __ctRef: true, kind: "group-type-role", @@ -280,12 +304,20 @@ describe("portablizeRuleset (#76 Stage 2)", () => { // deepMapRefs the resolver uses. Markers sit exactly where the ids were, so the whole ruleset // (query filter AND the out-of-query process.groupTypeRoleId) restores byte-identical. const keyToId = new Map(); - for (const [id, key] of [[112, "bereich_kids"], [8, "team_kidsdienst"]] as const) { + for (const [id, key] of [ + [112, "bereich_kids"], + [8, "team_kidsdienst"], + ] as const) { keyToId.set(refKey({ __ctRef: true, kind: "group", key }), id); } for (const [id, entry] of roleCatalog) { keyToId.set( - refKey({ __ctRef: true, kind: "group-type-role", groupType: groupTypeIdToKey.get(entry.groupTypeId)!, role: entry.name }), + refKey({ + __ctRef: true, + kind: "group-type-role", + groupType: groupTypeIdToKey.get(entry.groupTypeId)!, + role: entry.name, + }), id, ); } @@ -301,11 +333,14 @@ describe("portablizeRuleset (#76 Stage 2)", () => { var: "ctgroup.id", id: 1246, reason: "unmanaged", - detail: "group #1246 is not under management — `ct adopt group 1246` (then re-adopt) makes it portable", + detail: + "group #1246 is not under management — `ct adopt group 1246` (then re-adopt) makes it 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.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); }); @@ -315,7 +350,9 @@ describe("portablizeRuleset (#76 Stage 2)", () => { // Every marker-rewritten id is gone from the report; the numeric leftovers remain. expect(left.some((w) => w.var === "ctgroup.id" && w.id === 1246)).toBe(true); expect(left.some((w) => w.id === 112 || w.id === 8)).toBe(false); // now `{ __ctRef }` markers - expect(formatPortablizeWarnings(left).some((l) => l.startsWith("ctgroup.id: 1246 left numeric"))).toBe(true); + expect(formatPortablizeWarnings(left).some((l) => l.startsWith("ctgroup.id: 1246 left numeric"))).toBe( + true, + ); }); }); }); diff --git a/tests/query.test.ts b/tests/query.test.ts index 3e6aee5..1f85b2b 100644 --- a/tests/query.test.ts +++ b/tests/query.test.ts @@ -5,14 +5,15 @@ import { normalizeRuleset } from "../src/engine/dynamic.js"; describe("typed query builder", () => { it("builds a JSONLogic tree", () => { const tree = q.and(q.eq("ctgroup.campusId", 1), q.eq("person.isArchived", false)); - expect(tree).toEqual({ and: [ - { "==": [{ var: "ctgroup.campusId" }, 1] }, - { "==": [{ var: "person.isArchived" }, false] }, - ] }); + expect(tree).toEqual({ + and: [{ "==": [{ var: "ctgroup.campusId" }, 1] }, { "==": [{ var: "person.isArchived" }, false] }], + }); }); it("oneof and isnull", () => { - expect(q.oneof("ctgroup.groupTypeId", [1, 2])).toEqual({ oneof: [{ var: "ctgroup.groupTypeId" }, [1, 2]] }); + expect(q.oneof("ctgroup.groupTypeId", [1, 2])).toEqual({ + oneof: [{ var: "ctgroup.groupTypeId" }, [1, 2]], + }); expect(q.isnull("person.isArchived")).toEqual({ isnull: [{ var: "person.isArchived" }] }); }); @@ -58,8 +59,13 @@ describe("typed query builder", () => { }); it("a built ruleset normalizes stably (matches read-back normalization)", () => { - const ruleset = { description: "x", importance: 0, personIdFieldName: "id", - process: {}, query: churchQuery(q.eq("ctgroup.campusId", 1)) }; + const ruleset = { + description: "x", + importance: 0, + personIdFieldName: "id", + process: {}, + query: churchQuery(q.eq("ctgroup.campusId", 1)), + }; expect(normalizeRuleset(normalizeRuleset(ruleset))).toEqual(normalizeRuleset(ruleset)); }); diff --git a/tests/refs.test.ts b/tests/refs.test.ts index 81bc5fc..aeef5ec 100644 --- a/tests/refs.test.ts +++ b/tests/refs.test.ts @@ -18,7 +18,12 @@ describe("ref helper", () => { expect(ref.status("active")).toEqual({ __ctRef: true, kind: "group-status", key: "active" }); expect(ref.roleDef("leiter")).toEqual({ __ctRef: true, kind: "role-def", key: "leiter" }); expect(ref.group("g")).toEqual({ __ctRef: true, kind: "group", key: "g" }); - expect(ref.groupRole("g", "Leiter")).toEqual({ __ctRef: true, kind: "group-role", group: "g", role: "Leiter" }); + expect(ref.groupRole("g", "Leiter")).toEqual({ + __ctRef: true, + kind: "group-role", + group: "g", + role: "Leiter", + }); }); it("rejects an empty/non-string key", () => { diff --git a/tests/registry.test.ts b/tests/registry.test.ts index fa0c4ff..4a21e16 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -233,9 +233,9 @@ describe("createDefaults — required-but-unmanaged create fields (#73)", () => }); it("group-role fills the required `shorty` from the declared name (truncated to 10)", () => { - expect( - RESOURCES["group-role"]?.createDefaults?.({ name: "Verantwortlicher", groupTypeId: 2 }), - ).toEqual({ shorty: "Verantwort" }); + expect(RESOURCES["group-role"]?.createDefaults?.({ name: "Verantwortlicher", groupTypeId: 2 })).toEqual({ + shorty: "Verantwort", + }); }); it("pads a 1-char group-type name's namePlural up to CT's 2-char minimum", () => { diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts index e425c9b..16b3659 100644 --- a/tests/resolver.test.ts +++ b/tests/resolver.test.ts @@ -36,7 +36,12 @@ describe("Resolver.resolve", () => { }); 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 client = fakeClient({ + "/campuses": [ + { id: 3, name: "Berlin", shorty: "BE" }, + { id: 5, name: "Mainz" }, + ], + }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); expect(await r.resolve(ref.campus("mainz"), "site")).toBe(5); }); @@ -75,7 +80,12 @@ describe("Resolver.resolve", () => { // /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" }] }); + const client = fakeClient({ + "/group/memberstatus": [ + { id: 1, name: "Active" }, + { id: 2, name: "Candidate" }, + ], + }); 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/, @@ -91,7 +101,7 @@ describe("Resolver.resolve", () => { // SAME message context.ts's eval-time guard uses, not the generic one. const client = fakeClient({}); 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( + 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").', @@ -108,9 +118,14 @@ describe("Resolver.resolve", () => { }); it("throws listing candidates on an ambiguous catalog match", async () => { - const client = fakeClient({ "/campuses": [{ id: 1, name: "Mainz" }, { id: 2, name: "Mainz" }] }); + const client = fakeClient({ + "/campuses": [ + { id: 1, name: "Mainz" }, + { id: 2, name: "Mainz" }, + ], + }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); - await expect(r.resolve(ref.campus("mainz"), "group \"g\"")).rejects.toThrow( + await expect(r.resolve(ref.campus("mainz"), 'group "g"')).rejects.toThrow( /Ambiguous campus:mainz referenced at group "g" on hostA: 2 live campuss match/, ); }); @@ -118,7 +133,7 @@ describe("Resolver.resolve", () => { 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( + await expect(r.resolve(ref.campus("mainz"), 'group "g".campusId')).rejects.toThrow( /Cannot resolve campus:mainz referenced at group "g".campusId on hostB/, ); }); @@ -128,12 +143,15 @@ describe("Resolver.resolve", () => { kids: { type: "group", id: 42, key: "kids", fields: {}, adoptedAt: "t", updatedAt: "t" }, }); const client = fakeClient({ - "/groups/42/roles": [{ id: 2882, name: "Leiter" }, { id: 2883, name: "Mitglied" }], + "/groups/42/roles": [ + { id: 2882, name: "Leiter" }, + { id: 2883, name: "Mitglied" }, + ], }); const r = new Resolver({ client, state, desired: NO_DESIRED }); // slug("Leiter") === "leiter", so either the slug key or the exact name resolves. - expect(await r.resolve(ref.groupRole("kids", "leiter"), "perm \"p\"")).toBe(2882); - expect(await r.resolve(ref.groupRole("kids", "Mitglied"), "perm \"p\"")).toBe(2883); + expect(await r.resolve(ref.groupRole("kids", "leiter"), 'perm "p"')).toBe(2882); + expect(await r.resolve(ref.groupRole("kids", "Mitglied"), 'perm "p"')).toBe(2883); expect(client.calls["/groups/42/roles"]).toBe(1); // fetched once, cached across both refs }); @@ -143,7 +161,7 @@ describe("Resolver.resolve", () => { }); const client = fakeClient({ "/groups/42/roles": [{ id: 2882, name: "Leiter" }] }); const r = new Resolver({ client, state, desired: NO_DESIRED, host: "hostA" }); - await expect(r.resolve(ref.groupRole("kids", "Ghost"), "perm \"p\"")).rejects.toThrow( + await expect(r.resolve(ref.groupRole("kids", "Ghost"), 'perm "p"')).rejects.toThrow( /group #42 has no role named "Ghost".*available: "Leiter".*pass a numeric id/is, ); }); @@ -151,7 +169,7 @@ describe("Resolver.resolve", () => { it("errors when a group_role names a group that isn't managed", async () => { const client = fakeClient({}); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - await expect(r.resolve(ref.groupRole("ghost", "Leiter"), "perm \"p\"")).rejects.toThrow( + await expect(r.resolve(ref.groupRole("ghost", "Leiter"), 'perm "p"')).rejects.toThrow( /no managed group named "ghost".*pass a numeric id/is, ); }); @@ -160,7 +178,7 @@ describe("Resolver.resolve", () => { const desired: DesiredResource[] = [{ type: "group", key: "kids", fields: {}, dependsOn: [] }]; const client = fakeClient({}); const r = new Resolver({ client, state: emptyState("h"), desired }); - await expect(r.resolve(ref.groupRole("kids", "Leiter"), "perm \"p\"")).rejects.toThrow( + await expect(r.resolve(ref.groupRole("kids", "Leiter"), 'perm "p"')).rejects.toThrow( /declared in this config but not yet created.*Apply the group first/is, ); }); @@ -189,7 +207,10 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { { id: 16, name: "Leiter", groupTypeId: 2 }, // SAME name as #84, different group type { id: 17, name: "Organisator", groupTypeId: 2 }, ]; - const groupTypesCatalog = [{ id: 12, name: "Local Lead" }, { id: 2, name: "Team" }]; + const groupTypesCatalog = [ + { id: 12, name: "Local Lead" }, + { id: 2, name: "Team" }, + ]; it("resolves a (group-type, role) pair to its groupTypeRoleId, disambiguating same-named roles", async () => { const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); @@ -204,7 +225,14 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { it("resolves the group-type half from managed state (no group-type catalog fetch)", async () => { const state = stateWith({ - local_lead: { type: "group-type", id: 12, key: "local_lead", fields: {}, adoptedAt: "t", updatedAt: "t" }, + local_lead: { + type: "group-type", + id: 12, + key: "local_lead", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }, }); const client = fakeClient({ "/group/roles": rolesCatalog }); const r = new Resolver({ client, state, desired: NO_DESIRED }); @@ -215,7 +243,7 @@ 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" }); - await expect(r.resolve(ref.groupTypeRole("team", "Ghost"), "ruleset \"r\"")).rejects.toThrow( + 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, ); }); @@ -254,11 +282,21 @@ 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" }] }); + const client = fakeClient({ + "/campuses": [ + { id: 5, name: "Mainz" }, + { id: 6, name: "Berlin" }, + ], + }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); const value = { campusId: ref.campus("mainz"), - query: { or: [{ "==": [{ var: "ctgroup.campusId" }, ref.campus("mainz")] }, { "==": [{ var: "ctgroup.campusId" }, ref.campus("berlin")] }] }, + query: { + or: [ + { "==": [{ var: "ctgroup.campusId" }, ref.campus("mainz")] }, + { "==": [{ var: "ctgroup.campusId" }, ref.campus("berlin")] }, + ], + }, untouched: 42, }; const out = await r.resolveValue(value, "site"); @@ -323,7 +361,7 @@ describe("catalogs are read PAGINATED (#99 review)", () => { ], }); const r = new Resolver({ client, state, desired: NO_DESIRED }); - expect(await r.resolve(ref.groupRole("kids", "Leiter"), "perm \"p\"")).toBe(2882); + expect(await r.resolve(ref.groupRole("kids", "Leiter"), 'perm "p"')).toBe(2882); }); }); @@ -342,6 +380,9 @@ describe("reresolvePendingValue", () => { }); it("passes non-pending values through untouched", () => { - expect(reresolvePendingValue({ campusId: 4, n: [1, 2] }, emptyState("h"))).toEqual({ campusId: 4, n: [1, 2] }); + expect(reresolvePendingValue({ campusId: 4, n: [1, 2] }, emptyState("h"))).toEqual({ + campusId: 4, + n: [1, 2], + }); }); }); diff --git a/tests/session.test.ts b/tests/session.test.ts index 55fde7b..433f5fc 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -46,9 +46,7 @@ describe("authedSession host↔token binding (issue #30)", () => { readCredentials.mockResolvedValue({ host: "https://prod.church.tools", token: "prod-secret" }); process.env.CT_HOST = "https://staging.church.tools"; - await expect(authedSession()).rejects.toThrow( - /ct auth login --host https:\/\/staging\.church\.tools/, - ); + await expect(authedSession()).rejects.toThrow(/ct auth login --host https:\/\/staging\.church\.tools/); }); it("tolerates a trailing slash mismatch (host is normalized before comparison)", async () => { diff --git a/tests/synthetic-dynamic.test.ts b/tests/synthetic-dynamic.test.ts index 2f1119b..40f5b9c 100644 --- a/tests/synthetic-dynamic.test.ts +++ b/tests/synthetic-dynamic.test.ts @@ -17,32 +17,76 @@ const getClient = (client: unknown) => client as unknown as Pick { it("injects normalized dynamic into desired.fields and actual for an opted-in managed group", async () => { expect(SYNTHETIC_FIELDS.some((f) => f.field === "dynamic")).toBe(true); - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], - dynamic: { status: "manual", ruleset: { description: "x", query: {}, process: {} } } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "manual", ruleset: { description: "x", query: {}, process: {} } }, + }, ]; - const client = { get: vi.fn(async (p: string) => - p.endsWith("/ruleset") ? { description: "x", query: {}, process: {}, dynamicGroupUpdateStarted: "t" } - : { dynamicGroupStatus: "manual" }) }; - const out = await dynamicField().fold({ client: client as unknown as Pick, state, desired, actual }); + const client = { + get: vi.fn(async (p: string) => + p.endsWith("/ruleset") + ? { description: "x", query: {}, process: {}, dynamicGroupUpdateStarted: "t" } + : { dynamicGroupStatus: "manual" }, + ), + }; + const out = await dynamicField().fold({ + client: client as unknown as Pick, + state, + desired, + actual, + }); expect(out.errors).toEqual([]); - expect(actual.get("g")?.dynamic).toEqual({ status: "manual", ruleset: { description: "x", query: {}, process: {} } }); - expect(out.desired[0]?.fields.dynamic).toEqual({ status: "manual", ruleset: { description: "x", query: {}, process: {} } }); + expect(actual.get("g")?.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", query: {}, process: {} }, + }); + expect(out.desired[0]?.fields.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", query: {}, process: {} }, + }); }); it("tolerates a 404 on the ruleset fetch — group is not (yet) a dynamic group", async () => { - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], - dynamic: { status: "active", ruleset: { description: "x", query: {}, process: {} } } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "active", ruleset: { description: "x", query: {}, process: {} } }, + }, ]; - const client = { get: vi.fn(async () => { throw new CtApiError("Not Found", 404, null); }) }; - const out = await dynamicField().fold({ client: client as unknown as Pick, state, desired, actual }); + const client = { + get: vi.fn(async () => { + throw new CtApiError("Not Found", 404, null); + }), + }; + const out = await dynamicField().fold({ + client: client as unknown as Pick, + state, + desired, + actual, + }); expect(out.errors).toEqual([]); expect(actual.get("g")?.dynamic).toEqual({ status: "none", ruleset: {} }); }); @@ -55,17 +99,29 @@ describe("dynamic synthetic field — fold", () => { // FIX 5: the ruleset GET succeeded, so this group HAS a real ruleset. A subsequent status-GET // failure must NOT be swallowed into { status: "none", ruleset: {} } (which would discard the // ruleset and propose a spurious re-PUT) — it degrades the plan via `errors`. - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], - dynamic: { status: "active", ruleset: { description: "x", query: {}, process: {} } } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "active", ruleset: { description: "x", query: {}, process: {} } }, + }, ]; - const client = { get: vi.fn(async (p: string) => { - if (p.endsWith("/ruleset")) return { description: "x", query: {}, process: {} }; - throw new CtApiError("Server Error", 500, null); // status GET fails AFTER a good ruleset GET - }) }; + const client = { + get: vi.fn(async (p: string) => { + if (p.endsWith("/ruleset")) return { description: "x", query: {}, process: {} }; + throw new CtApiError("Server Error", 500, null); // status GET fails AFTER a good ruleset GET + }), + }; const out = await dynamicField().fold({ client: getClient(client), state, desired, actual }); expect(out.errors).toHaveLength(1); expect(out.errors[0]).toMatch(/dynamic g status \(#5\)/); @@ -76,56 +132,100 @@ describe("dynamic synthetic field — fold", () => { // docs/handbuch/dynamic-groups.md tells users to KEEP the dynamic block when demoting, so the authored // ruleset is still present with status "none". The actual side is the { status:"none", ruleset:{} } // sentinel — folding the full ruleset would diff forever. Both must collapse to the same sentinel. - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], - dynamic: { status: "none", ruleset: { description: "kept", query: { "==": [{ var: "x" }, "1"] }, process: {} } } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { + status: "none", + ruleset: { description: "kept", query: { "==": [{ var: "x" }, "1"] }, process: {} }, + }, + }, ]; // Group is already non-dynamic in CT → ruleset GET 404s → actual sentinel. - const client = { get: vi.fn(async () => { throw new CtApiError("Not Found", 404, null); }) }; + const client = { + get: vi.fn(async () => { + throw new CtApiError("Not Found", 404, null); + }), + }; const out = await dynamicField().fold({ client: getClient(client), state, desired, actual }); expect(out.errors).toEqual([]); const sentinel = { status: "none", ruleset: {} }; - expect(actual.get("g")?.dynamic).toEqual(sentinel); // actual side sentinel - expect(out.desired[0]?.fields.dynamic).toEqual(sentinel); // desired folds to the SAME sentinel + expect(actual.get("g")?.dynamic).toEqual(sentinel); // actual side sentinel + expect(out.desired[0]?.fields.dynamic).toEqual(sentinel); // desired folds to the SAME sentinel // Second plan is a no-op: the two sides are deep-equal, so diffFields reports no dynamic change. - expect(diffFields(out.desired[0]!.fields, actual.get("g")!).find((c) => c.field === "dynamic")).toBeUndefined(); + expect( + diffFields(out.desired[0]!.fields, actual.get("g")!).find((c) => c.field === "dynamic"), + ).toBeUndefined(); }); it("folds many opted-in groups concurrently, each with its own (ruleset, status) pair (#35 item 1)", async () => { // Behavior must be identical to the old serial loop: every opted-in managed group gets its actual // dynamic filled from its own ruleset+status GETs, keyed by group id. - const state: State = { version: 1, host: "h", resources: { - g1: { type: "group", id: 1, key: "g1", fields: { name: "G1" }, adoptedAt: "t", updatedAt: "t" }, - g2: { type: "group", id: 2, key: "g2", fields: { name: "G2" }, adoptedAt: "t", updatedAt: "t" }, - g3: { type: "group", id: 3, key: "g3", fields: { name: "G3" }, adoptedAt: "t", updatedAt: "t" }, - } }; + const state: State = { + version: 1, + host: "h", + resources: { + g1: { type: "group", id: 1, key: "g1", fields: { name: "G1" }, adoptedAt: "t", updatedAt: "t" }, + g2: { type: "group", id: 2, key: "g2", fields: { name: "G2" }, adoptedAt: "t", updatedAt: "t" }, + g3: { type: "group", id: 3, key: "g3", fields: { name: "G3" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([ - ["g1", { name: "G1" }], ["g2", { name: "G2" }], ["g3", { name: "G3" }], + ["g1", { name: "G1" }], + ["g2", { name: "G2" }], + ["g3", { name: "G3" }], ]); const desired: DesiredResource[] = ["g1", "g2", "g3"].map((key) => ({ - type: "group", key, fields: { name: key.toUpperCase() }, dependsOn: [], + type: "group", + key, + fields: { name: key.toUpperCase() }, + dependsOn: [], dynamic: { status: "manual", ruleset: { description: key, query: {}, process: {} } }, })); - const client = { get: vi.fn(async (p: string) => { - const id = p.match(/dynamicgroups\/(\d+)\//)![1]; - return p.endsWith("/ruleset") - ? { description: `rs${id}`, query: {}, process: {} } - : { dynamicGroupStatus: "manual" }; - }) }; + const client = { + get: vi.fn(async (p: string) => { + const id = p.match(/dynamicgroups\/(\d+)\//)![1]; + return p.endsWith("/ruleset") + ? { description: `rs${id}`, query: {}, process: {} } + : { dynamicGroupStatus: "manual" }; + }), + }; const out = await dynamicField().fold({ client: getClient(client), state, desired, actual }); expect(out.errors).toEqual([]); - expect(actual.get("g1")?.dynamic).toEqual({ status: "manual", ruleset: { description: "rs1", query: {}, process: {} } }); - expect(actual.get("g2")?.dynamic).toEqual({ status: "manual", ruleset: { description: "rs2", query: {}, process: {} } }); - expect(actual.get("g3")?.dynamic).toEqual({ status: "manual", ruleset: { description: "rs3", query: {}, process: {} } }); + expect(actual.get("g1")?.dynamic).toEqual({ + status: "manual", + ruleset: { description: "rs1", query: {}, process: {} }, + }); + expect(actual.get("g2")?.dynamic).toEqual({ + status: "manual", + ruleset: { description: "rs2", query: {}, process: {} }, + }); + expect(actual.get("g3")?.dynamic).toEqual({ + status: "manual", + ruleset: { description: "rs3", query: {}, process: {} }, + }); expect(client.get).toHaveBeenCalledTimes(6); // ruleset + status per group }); it("ignores groups that did not opt into dynamic", async () => { - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const desired: DesiredResource[] = [{ type: "group", key: "g", fields: { name: "G" }, dependsOn: [] }]; const client = { get: vi.fn() }; @@ -139,47 +239,84 @@ describe("dynamic synthetic field — apply", () => { it("PUTs the ruleset wrapped in the [RuleSet] array envelope (#77), then the status", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; - await dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", from: undefined, - to: { status: "active", ruleset: { description: "x", query: {}, process: {} } } } }); + await dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { + field: "dynamic", + from: undefined, + to: { status: "active", ruleset: { description: "x", query: {}, process: {} } }, + }, + }); // PUT envelope: `{ dynamicGroupRuleSet: [ruleset] }` — object root, array property (live-decoded, #77). - expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", - { dynamicGroupRuleSet: [{ description: "x", query: {}, process: {} }] }); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "active" }); + expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", { + dynamicGroupRuleSet: [{ description: "x", query: {}, process: {} }], + }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "active", + }); }); it("status-only change (ruleset byte-identical) PUTs only the status, skipping the ruleset re-PUT (#35 item 15)", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; const ruleset = { description: "x", query: {}, process: {} }; - await dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", + await dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { + field: "dynamic", from: { status: "active", ruleset }, // same ruleset, only the status flips - to: { status: "inactive", ruleset } } }); + to: { status: "inactive", ruleset }, + }, + }); expect(request).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledWith("PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "inactive" }); + expect(request).toHaveBeenCalledWith("PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "inactive", + }); expect(request).not.toHaveBeenCalledWith("PUT", "/dynamicgroups/5/ruleset", expect.anything()); }); it("still PUTs both when the ruleset changed alongside the status", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; - await dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", + await dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { + field: "dynamic", from: { status: "active", ruleset: { description: "old", query: {}, process: {} } }, - to: { status: "active", ruleset: { description: "new", query: {}, process: {} } } } }); - expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", - { dynamicGroupRuleSet: [{ description: "new", query: {}, process: {} }] }); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "active" }); + to: { status: "active", ruleset: { description: "new", query: {}, process: {} } }, + }, + }); + expect(request).toHaveBeenNthCalledWith(1, "PUT", "/dynamicgroups/5/ruleset", { + dynamicGroupRuleSet: [{ description: "new", query: {}, process: {} }], + }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "active", + }); }); it("demotes to a normal group when status is none: DELETE ruleset then status none", async () => { const request = vi.fn(async () => ({})); const state: State = { version: 1, host: "h", resources: {} }; - await dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", from: { status: "active", ruleset: {} }, to: { status: "none", ruleset: {} } } }); + await dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { + field: "dynamic", + from: { status: "active", ruleset: {} }, + to: { status: "none", ruleset: {} }, + }, + }); expect(request).toHaveBeenNthCalledWith(1, "DELETE", "/dynamicgroups/5/ruleset"); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "none" }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "none", + }); }); it("tolerates a 404 on the demote DELETE (never-dynamic / already-demoted group) and still PUTs status none", async () => { @@ -189,11 +326,17 @@ describe("dynamic synthetic field — apply", () => { }); const state: State = { version: 1, host: "h", resources: {} }; await expect( - dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", from: undefined, to: { status: "none", ruleset: {} } } }), + dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { field: "dynamic", from: undefined, to: { status: "none", ruleset: {} } }, + }), ).resolves.toBeUndefined(); // 404 swallowed — apply does not abort expect(request).toHaveBeenNthCalledWith(1, "DELETE", "/dynamicgroups/5/ruleset"); - expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "none" }); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { + dynamicGroupStatus: "none", + }); }); it("re-throws a non-404 error on the demote DELETE (real failures still abort)", async () => { @@ -203,8 +346,12 @@ describe("dynamic synthetic field — apply", () => { }); const state: State = { version: 1, host: "h", resources: {} }; await expect( - dynamicField().apply({ client: { request } as unknown as Pick, state, id: 5, - change: { field: "dynamic", from: undefined, to: { status: "none", ruleset: {} } } }), + dynamicField().apply({ + client: { request } as unknown as Pick, + state, + id: 5, + change: { field: "dynamic", from: undefined, to: { status: "none", ruleset: {} } }, + }), ).rejects.toThrow(/Server Error/); }); }); @@ -213,7 +360,11 @@ describe("resolveRulesetRef", () => { it("resolves { ref } relative to the given baseDir (config dir), not the process cwd", () => { const dir = mkdtempSync(join(tmpdir(), "ct-ref-")); writeFileSync(join(dir, "rs.json"), JSON.stringify({ description: "from-file", query: {}, process: {} })); - expect(resolveRulesetRef({ ref: "./rs.json" }, dir)).toEqual({ description: "from-file", query: {}, process: {} }); + expect(resolveRulesetRef({ ref: "./rs.json" }, dir)).toEqual({ + description: "from-file", + query: {}, + process: {}, + }); }); it("passes an inline ruleset through unchanged", () => { @@ -222,8 +373,9 @@ describe("resolveRulesetRef", () => { it("throws a clear error naming the group and resolved path when the ref file is missing", () => { const dir = mkdtempSync(join(tmpdir(), "ct-ref-")); - expect(() => resolveRulesetRef({ ref: "./missing.json" }, dir, "all_mainz")) - .toThrow(/group "all_mainz".*cannot read.*missing\.json/is); + expect(() => resolveRulesetRef({ ref: "./missing.json" }, dir, "all_mainz")).toThrow( + /group "all_mainz".*cannot read.*missing\.json/is, + ); }); it("throws a clear error when the ref file is not valid JSON", () => { @@ -243,27 +395,47 @@ describe("dynamic { ref } ruleset — resolved relative to the config file", () ); return dir; }; - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: {}, adoptedAt: "t", updatedAt: "t" } } }; - const foldClient = () => ({ get: vi.fn(async (p: string) => - p.endsWith("/ruleset") ? { description: "actual", query: {}, process: {} } : { dynamicGroupStatus: "manual" }) }); + const state: State = { + version: 1, + host: "h", + resources: { g: { type: "group", id: 5, key: "g", fields: {}, adoptedAt: "t", updatedAt: "t" } }, + }; + const foldClient = () => ({ + get: vi.fn(async (p: string) => + p.endsWith("/ruleset") + ? { description: "actual", query: {}, process: {} } + : { dynamicGroupStatus: "manual" }, + ), + }); it("threads the config dir so { ref } resolves against the config file, not the cwd", async () => { const dir = mkConfig(`{ ref: "./rules.json" }`); - writeFileSync(join(dir, "rules.json"), JSON.stringify({ description: "from-ref", importance: 0, query: {}, process: {} })); + writeFileSync( + join(dir, "rules.json"), + JSON.stringify({ description: "from-ref", importance: 0, query: {}, process: {} }), + ); const { resources, configDir } = await loadConfig(join(dir, "ct.config.ts")); const actual = new Map>([["g", { name: "G" }]]); - const out = await foldSynthetic({ client: getClient(foldClient()), state, desired: resources, actual, configDir }); - expect(out.desired.find((d) => d.key === "g")?.fields.dynamic) - .toMatchObject({ status: "manual", ruleset: { description: "from-ref" } }); + const out = await foldSynthetic({ + client: getClient(foldClient()), + state, + desired: resources, + actual, + configDir, + }); + expect(out.desired.find((d) => d.key === "g")?.fields.dynamic).toMatchObject({ + status: "manual", + ruleset: { description: "from-ref" }, + }); }); it("surfaces a clear error (group + path) when the { ref } file is missing", async () => { const dir = mkConfig(`{ ref: "./missing.json" }`); const { resources, configDir } = await loadConfig(join(dir, "ct.config.ts")); const actual = new Map>([["g", { name: "G" }]]); - await expect(foldSynthetic({ client: getClient(foldClient()), state, desired: resources, actual, configDir })) - .rejects.toThrow(/group "g".*cannot read.*missing\.json/is); + await expect( + foldSynthetic({ client: getClient(foldClient()), state, desired: resources, actual, configDir }), + ).rejects.toThrow(/group "g".*cannot read.*missing\.json/is); }); }); @@ -274,8 +446,13 @@ describe("dynamic synthetic field — un-portablized ruleset reporting (#101)", * other host. Plan time is the only place a config author sees this before it matters. */ it("warns at plan time, naming the dimension, the ids and why they stayed numeric", async () => { - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const ruleset = { description: "x", @@ -283,12 +460,22 @@ describe("dynamic synthetic field — un-portablized ruleset reporting (#101)", process: {}, }; const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], dynamic: { status: "active", ruleset } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "active", ruleset }, + }, ]; - const client = { get: vi.fn(async (p: string) => - p.endsWith("/ruleset") ? ruleset : { dynamicGroupStatus: "active" }) }; + const client = { + get: vi.fn(async (p: string) => (p.endsWith("/ruleset") ? ruleset : { dynamicGroupStatus: "active" })), + }; const errs: string[] = []; - const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { errs.push(String(s)); return true; }); + const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { + errs.push(String(s)); + return true; + }); try { await dynamicField().fold({ client: getClient(client), state, desired, actual }); } finally { @@ -300,8 +487,13 @@ describe("dynamic synthetic field — un-portablized ruleset reporting (#101)", }); it("stays silent for a fully portable ruleset — the warning must mean something", async () => { - const state: State = { version: 1, host: "h", - resources: { g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" } } }; + const state: State = { + version: 1, + host: "h", + resources: { + g: { type: "group", id: 5, key: "g", fields: { name: "G" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; const actual = new Map>([["g", { name: "G" }]]); const ruleset = { description: "x", @@ -309,12 +501,22 @@ describe("dynamic synthetic field — un-portablized ruleset reporting (#101)", process: {}, }; const desired: DesiredResource[] = [ - { type: "group", key: "g", fields: { name: "G" }, dependsOn: [], dynamic: { status: "active", ruleset } }, + { + type: "group", + key: "g", + fields: { name: "G" }, + dependsOn: [], + dynamic: { status: "active", ruleset }, + }, ]; - const client = { get: vi.fn(async (p: string) => - p.endsWith("/ruleset") ? ruleset : { dynamicGroupStatus: "active" }) }; + const client = { + get: vi.fn(async (p: string) => (p.endsWith("/ruleset") ? ruleset : { dynamicGroupStatus: "active" })), + }; const errs: string[] = []; - const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { errs.push(String(s)); return true; }); + const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { + errs.push(String(s)); + return true; + }); try { await dynamicField().fold({ client: getClient(client), state, desired, actual }); } finally { diff --git a/tests/tokenStore-multihost.test.ts b/tests/tokenStore-multihost.test.ts index f6d11a9..9a4a6cd 100644 --- a/tests/tokenStore-multihost.test.ts +++ b/tests/tokenStore-multihost.test.ts @@ -19,26 +19,24 @@ const PROD = "https://mychurch.church.tools"; /** Model a keychain as account → stored blob; wire the `security` CLI mock to read/write it. */ function mockKeychain(store: Map): void { - execFileMock.mockImplementation( - (_cmd: string, args: string[], cb: (e: unknown, v: unknown) => void) => { - const sub = args[0]; - const account = args[args.indexOf("-a") + 1]!; - if (sub === "find-generic-password") { - const val = store.get(account); - if (val === undefined) return cb(new Error("not found"), null); - return cb(null, { stdout: val, stderr: "" }); - } - if (sub === "add-generic-password") { - store.set(account, args[args.indexOf("-w") + 1]!); - return cb(null, { stdout: "", stderr: "" }); - } - if (sub === "delete-generic-password") { - store.delete(account); - return cb(null, { stdout: "", stderr: "" }); - } - return cb(new Error(`unexpected security subcommand ${sub}`), null); - }, - ); + execFileMock.mockImplementation((_cmd: string, args: string[], cb: (e: unknown, v: unknown) => void) => { + const sub = args[0]; + const account = args[args.indexOf("-a") + 1]!; + if (sub === "find-generic-password") { + const val = store.get(account); + if (val === undefined) return cb(new Error("not found"), null); + return cb(null, { stdout: val, stderr: "" }); + } + if (sub === "add-generic-password") { + store.set(account, args[args.indexOf("-w") + 1]!); + return cb(null, { stdout: "", stderr: "" }); + } + if (sub === "delete-generic-password") { + store.delete(account); + return cb(null, { stdout: "", stderr: "" }); + } + return cb(new Error(`unexpected security subcommand ${sub}`), null); + }); } beforeEach(() => { From 30598d0c2bc021189f6eb0584dc795af46b43954 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 13 Aug 2026 18:04:07 +0200 Subject: [PATCH 3/5] docs: fix two links that broke the strict mkdocs build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both introduced by the previous commit's handbuch edits: - dynamic-groups.md linked ../runbook-manual-surface.md. Only pages under handbuch/ publish, so a relative link out of the section resolves to nothing in the built site — strict mode treats that as fatal. Uses the absolute GitHub URL the other cross-section links already use. - permissions.md's in-page anchor had a doubled hyphen from the em dash in the heading; mkdocs collapses whitespace runs to one. Verified by running the strict build locally (mkdocs build --strict, clean). Claude-Session: https://claude.ai/code/session_016TacHkv3oVczwXGHBwckq2 --- .gitignore | 2 ++ docs/handbuch/dynamic-groups.md | 2 +- docs/handbuch/permissions.md | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index a9c933d..a43ce0f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ src/api/schema.d.ts # MkDocs output for docs/handbuch/ (built by .github/workflows/docs.yml, never committed) site/ +# …and the venv docs/README.md tells you to create here to build the section locally +.venv-docs/ diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index dd2835e..5d5442c 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -377,7 +377,7 @@ keeps going after a per-group failure (exiting non-zero). > **The scheduler ping is NOT fired by `ct`.** ChurchTools' admin cron page hits > `GET https:///?q=cron&standby=true`, which runs **every due scheduled > job on the instance** — far beyond auto-groups. It is documented in -> [`docs/runbook-manual-surface.md`](../runbook-manual-surface.md) as a manual +> [`docs/runbook-manual-surface.md`](https://github.com/eqrm/ct-cli/blob/main/docs/runbook-manual-surface.md) as a manual > escape hatch; `ct` deliberately never calls it. ## Full example diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index c0366d8..20e6e58 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -455,7 +455,7 @@ In bulk mode: per-block `WARNING` header stops being a safeguard and becomes something the reader scrolls past, so such domains are skipped and listed instead; - a role instance blocked by an undeclarable scope dimension is skipped and - named, with the dimension to pass to [`preserveUnknown`](#partial-ownership--preserveunknown-opt-in-102). + named, with the dimension to pass to [`preserveUnknown`](#partial-ownership-preserveunknown-opt-in-102). The single-domain form still emits it, deliberately, one domain at a time; - nothing is capped silently — the run prints how many blocks it emitted **and** how many it skipped, with the reason for each. From bd4a0b3043b8c1b8fac41252eb91f633bd2c6bff Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 13 Aug 2026 18:24:36 +0200 Subject: [PATCH 4/5] fix: make every command agree about the permission catalog, and stop unchecked claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on #100–#105. Catalog consistency. `ct plan`/`ct apply` loaded the per-instance capture AFTER loadConfig, so `preserveUnknown` dimensions were validated against the BUNDLED catalog and then planned against the captured one — a dimension present only in the capture threw, and one present only in the bundle passed validation and preserved nothing, which is the exact silent failure that validation exists to prevent. `ct coverage` and `ct adopt grants` never loaded it at all, so they judged declarability off the bundled catalog: coverage reported "blocked by authId N" (failing --json CI gates) and `--all-declarable` SKIPPED role instances that `ct plan` manages without complaint. All four now load it first. Version skew. A per-instance capture is authoritative for its host at capture time, not forever, so suppressing the comparison entirely meant an instance upgraded past its committed capture got no staleness signal at all. The comparison runs either way now; only the remediation wording differs. The unknown-authId warning stops pointing consumer repos at `npm run regenerate:permission-catalog`, a script they do not have. Unchecked claims. `scanUnportablized` runs portablizeRuleset with no id maps and no role catalog, so its "unmanaged" / "role-unknown" reasons were never checked against anything — `ct plan` told anyone who hand-wrote the raw id of an ADOPTED group that it is not under management, and handed them a `ct adopt` that fails. Scan mode now degrades to a neutral `left-numeric`. Details are id-free so formatPortablizeWarnings can merge ids without stamping one id's remedy across the group; the detail joins the grouping key so a future id-bearing one splits. Also: `ct get raw --page 2` appended a second `limit`, making the window size depend on the server's parsing rule — rejected now, since both spellings are explicit. A per-instance catalog whose entries lack `authId` passed the "is it an object" gate and reached `ct apply` as a PUT with `authId: undefined` — entry shape is validated on load. Pagination guard. Every `/permissions/` read was a plain `get` on the unchecked belief that the endpoint returns one instance-wide blob. If that is ever wrong the failure is silent, because `request()` drops the `meta` that is the only evidence rows are missing. `fetchPermissionRows` reads the envelope and pages properly when pagination says more rows exist — one request, as before, while the endpoint stays un-paged. Claude-Session: https://claude.ai/code/session_016TacHkv3oVczwXGHBwckq2 --- docs/handbuch/dynamic-groups.md | 23 +++++--- docs/handbuch/permissions.md | 23 ++++---- src/commands/adopt-grants.ts | 20 +++++-- src/commands/apply.ts | 7 +-- src/commands/coverage.ts | 17 ++++-- src/commands/get.ts | 12 ++++- src/commands/plan.ts | 10 ++-- src/config/query-refs.ts | 75 ++++++++++++++++++++------ src/permissions/catalog-store.ts | 27 ++++++++++ src/permissions/fetch.ts | 43 +++++++++++++++ src/permissions/plan.ts | 32 ++++++----- tests/adopt-group-command.test.ts | 4 +- tests/get-command.test.ts | 9 ++++ tests/permission-catalog-store.test.ts | 14 +++++ tests/permission-fetch.test.ts | 64 ++++++++++++++++++++++ tests/permission-plan.test.ts | 51 +++++++++++++++++- tests/query-refs.test.ts | 28 +++++++--- 17 files changed, 383 insertions(+), 76 deletions(-) create mode 100644 src/permissions/fetch.ts create mode 100644 tests/permission-fetch.test.ts diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index 5d5442c..59915dc 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -187,23 +187,32 @@ materially worse than a wrong permission scope, because an auto-group's payload IS group membership — which is what carries grants. So every id left numeric is named, with its reason, **both at capture and at -plan time**: +plan time**. + +At **capture** time (`ct adopt … --with-dynamic`) the state file and the +`/group/roles` catalog are both in hand, so the reason is a checked fact: ```text ! rulesets/jugend.json keeps 3 host-specific id(s) — NOT portable to another host: - ctgroup.id: 1246 left numeric — group #1246 is not under management — `ct adopt group 1246` (then re-adopt) makes it portable + 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 ``` +At **plan** time the same scan runs over every declared dynamic group, but with +no state, no catalogs and no network — it can prove that an id sits in an entity +position and nothing more. So it says exactly that, rather than asserting a +reason it never checked: + ```text ! dynamic group "jugend": ruleset carries 1 host-specific id(s) — not portable to another instance: - ctgroup.id: 1246 left numeric — group #1246 is not under management — … + ctgroup.id: 1246 left numeric — host-specific id(s) frozen into a cross-host ruleset — re-adopt the group with `--with-dynamic` to rewrite them into logical references (it reports what, if anything, blocks each) ``` -The 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). +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). **`--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/permissions.md b/docs/handbuch/permissions.md index 20e6e58..4c4ac4d 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -109,11 +109,13 @@ ct permissions catalog --refresh --env prod # → .ct/permission-catalog..json`). A repo -that never runs the refresh is unaffected — the bundled catalog stays the -fallback. +**Commit that file.** Every subsequent command that needs to know what a right +_is_ — `ct plan`, `ct apply`, `ct coverage`, `ct adopt grants` — loads it in +preference to the catalog bundled with the `ct` release, and says so in its +header (`permission catalog: .ct/permission-catalog..json`). They all load +it, and all load it before evaluating your config, so no two of them can disagree +about the same right on the same host. A repo that never runs the refresh is +unaffected — the bundled catalog stays the fallback. This exists because the bundled catalog is a snapshot of **one** instance's ChurchTools version, and the staleness warning below used to tell you to run a @@ -137,11 +139,10 @@ records the version the catalog was captured from. On every `plan`/`apply`: - If the live instance's CT version differs from `$meta.ctVersion`, `ct plan` prints a warning — right names/authIds/scopeFields may have drifted; capture - one for this instance to be sure. **This warning is suppressed once a - per-instance catalog is loaded** (#105): a capture taken from the host you are - planning against is authoritative for it, and a warning that fires on every - single plan is one nobody reads — including on the plan where a moved authId - would actually matter. + one for this instance to be sure. A per-instance capture is authoritative for + its host **at capture time, not forever**: the instance gets upgraded while the + committed file does not, so the comparison still runs against a capture, and + the warning then asks you to _re-capture_ rather than to capture. - If a **live grant carries an `authId` the catalog cannot name** (a stale or foreign right), `ct plan` names the `authId` + domain and **leaves the grant untouched** — it is deliberately kept _out_ of the diff so `ct apply` never @@ -149,7 +150,7 @@ records the version the catalog was captured from. On every `plan`/`apply`: is excluded every run, so it neither churns nor silently disappears. Both are warnings, not errors: the plan still runs and the exit code stays -success. Regenerating the catalog (above) is the fix for both. +success. `ct permissions catalog --refresh` is the fix for both. ## `domainId` semantics diff --git a/src/commands/adopt-grants.ts b/src/commands/adopt-grants.ts index f716b52..326a81c 100644 --- a/src/commands/adopt-grants.ts +++ b/src/commands/adopt-grants.ts @@ -7,6 +7,8 @@ import { prepareEnv } from "../env/context.js"; import { assertNotPeople } from "../engine/guard.js"; import { buildAdoptedGrants, type AdoptedGrantsBlock } from "../permissions/adopt.js"; import type { DomainType, RawPermission } from "../permissions/grants.js"; +import { fetchPermissionRows, type PermissionReader } from "../permissions/fetch.js"; +import { loadHostCatalog } from "../permissions/catalog-store.js"; import { declarability, decodeGroupsWithRoles, type RoleInstance } from "../coverage/report.js"; import { slug } from "../resources/registry.js"; import { loadState, type State } from "../state/state.js"; @@ -91,6 +93,12 @@ export function adoptGrantsCommand(): Command { const config = await resolveConfig(); const statePath = cmdEnv.statePath; const state = await loadState(statePath, config.host); + // Bulk selection runs the same declarability verdict as `ct coverage`, 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(config.host); + if (hostCatalog) info(`permission catalog: ${hostCatalog}`); const { client } = await authedSession(); const emitted = bulk @@ -119,7 +127,7 @@ export function adoptGrantsCommand(): Command { /** The original single-domain form: `ct adopt grants `. */ async function emitSingle( - client: Pick, + client: PermissionReader, state: State, rawType: string | undefined, rawId: string | undefined, @@ -136,7 +144,7 @@ async function emitSingle( const domainId = Number.parseInt(rawId, 10); const path = `/permissions/${domainType}/${domainId}`; assertNotPeople(path); // belt-and-suspenders: the domain-type guard already excludes people - const rows = await client.get(path); + const rows = await fetchPermissionRows(client, path); return buildAdoptedGrants({ domainType, domainId, rows, state }); } @@ -147,7 +155,7 @@ async function emitSingle( * per-block WARNING header stops being a safeguard and becomes noise the reader scrolls past. */ async function emitBulk( - client: Pick, + client: PermissionReader & Pick, state: State, opts: AdoptGrantsOptions, ): Promise { @@ -162,9 +170,11 @@ async function emitBulk( if (Number.isFinite(id) && typeof r.name === "string") roleNamesById.set(id, r.name); } const groups = decodeGroupsWithRoles(groupRows.data, roleNamesById); - const permissions = await client.get("/permissions/group_role"); + // Guarded read (see permissions/fetch.ts): a silent first page here would drop most role instances + // into the "no authored grants" bucket, which reads identically to a correct run. + const permissions = await fetchPermissionRows(client, "/permissions/group_role"); const rowsByDomainId = new Map(); - for (const row of Array.isArray(permissions) ? permissions : []) { + for (const row of permissions) { const list = rowsByDomainId.get(row.domainId); if (list) list.push(row); else rowsByDomainId.set(row.domainId, [row]); diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 7a316f2..668ad59 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -57,12 +57,13 @@ export function applyCommand(): Command { const config = await resolveConfig(); const configPath = resolveConfigPath(opts.config); const statePath = cmdEnv.statePath; - const { resources: desired, permissions, configDir } = await loadConfig(configPath); - const state = await loadState(statePath, config.host); // A per-instance permission catalog this repo committed for THIS host wins over the bundled - // one (#105) — same precedence as `ct plan`, so the two never disagree about what a right is. + // one (#105) — same precedence AND same ordering as `ct plan`, so the two never disagree about + // what a right is (config evaluation validates scope dimensions against the active catalog). const hostCatalog = await loadHostCatalog(config.host); if (hostCatalog) info(`permission catalog: ${hostCatalog}`); + const { resources: desired, permissions, configDir } = await loadConfig(configPath); + const state = await loadState(statePath, config.host); const { client } = await authedSession(); // One shared resolver (#20) across both concurrent plans — see commands/plan.ts. diff --git a/src/commands/coverage.ts b/src/commands/coverage.ts index 5429f9a..608dbdb 100644 --- a/src/commands/coverage.ts +++ b/src/commands/coverage.ts @@ -19,7 +19,8 @@ import { renderRoleInstances, type CoverageReport, } from "../coverage/report.js"; -import type { RawPermission } from "../permissions/grants.js"; +import { fetchPermissionRows, type PermissionReader } from "../permissions/fetch.js"; +import { loadHostCatalog } from "../permissions/catalog-store.js"; import { slug } from "../resources/registry.js"; import { loadState, type State } from "../state/state.js"; import { info, out } from "../ui.js"; @@ -53,6 +54,12 @@ export function coverageCommand(): Command { const cmdEnv = await prepareEnv(opts); const config = await resolveConfig(); const state = await loadState(cmdEnv.statePath, config.host); + // The declarability verdict is computed from the catalog's authIds and scope dimensions, so it + // must read the SAME catalog `ct plan` does for this host (#105) — otherwise a right the + // committed capture can name is reported here as "blocked by authId N" (failing a `--json` CI + // gate) while `ct plan` manages it without complaint. + const hostCatalog = await loadHostCatalog(config.host); + if (hostCatalog) info(`permission catalog: ${hostCatalog}`); const { client } = await authedSession(); const report = await collectCoverage(client, config.host, state); @@ -92,7 +99,7 @@ export function coverageCommand(): Command { /** Fetch everything the report needs. Split out from the action so it is reusable and mockable. */ export async function collectCoverage( - client: Pick, + client: PermissionReader & Pick, host: string, state: State, ): Promise { @@ -102,9 +109,9 @@ export async function collectCoverage( client.getAll>("/dynamicgroups"), client.getAll>("/group/roles"), ]); - // `/permissions/group_role` is a single instance-wide blob, not a paged list — same read the - // planner performs. - const groupRolePermissions = await client.get("/permissions/group_role"); + // Same guarded read the planner performs: one request while the endpoint stays un-paged, and a + // proper paging pass if it ever does paginate — never a silent first page (see permissions/fetch.ts). + const groupRolePermissions = await fetchPermissionRows(client, "/permissions/group_role"); const groupTypeNames = new Map(); for (const row of groupTypeRows.data) { diff --git a/src/commands/get.ts b/src/commands/get.ts index 637c780..2a5f508 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -152,8 +152,16 @@ export async function getRaw( } page = Number.parseInt(String(opts.page), 10); } - // A path that already carries page/limit is the caller hand-rolling their own paging: honour it - // verbatim rather than appending a second, conflicting pair. + // A path that already carries page/limit is the caller hand-rolling their own paging. Combining it + // with --page produced `?limit=50&page=2&limit=100` — a duplicated param whose winner is the + // server's parsing rule, so the returned window's size was not predictable from what was typed. + // Rejected rather than silently picking one: both spellings are explicit, so neither may be dropped. + if (page !== undefined && hasOwnPageParams(target)) { + throw new Error( + `--page ${page} conflicts with the page/limit already in the path — ` + + `keep the query string's own paging, or drop it and use --page.`, + ); + } const single = opts.paginate === false || page !== undefined || hasOwnPageParams(target); if (single) { diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 26a04ea..0f0ecb3 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -37,13 +37,15 @@ export function planCommand(): Command { const cmdEnv = await prepareEnv(opts); const config = await resolveConfig(); const configPath = resolveConfigPath(opts.config); - const { resources: desired, permissions, configDir } = await loadConfig(configPath); - // loadState already refuses a host mismatch (state.ts) — no second guard needed here. - const state = await loadState(cmdEnv.statePath, config.host); // A per-instance permission catalog this repo committed for THIS host wins over the one bundled - // with the release (#105). Loaded before the plan is built so every right resolves against it. + // with the release (#105). Loaded BEFORE loadConfig, not just before the plan: config evaluation + // validates `preserveUnknown` dimensions against the active catalog's KNOWN_SCOPE_FIELDS, so + // loading it later would validate against the bundled catalog and plan against the captured one. const hostCatalog = await loadHostCatalog(config.host); if (hostCatalog) info(`permission catalog: ${hostCatalog}`); + const { resources: desired, permissions, configDir } = await loadConfig(configPath); + // loadState already refuses a host mismatch (state.ts) — no second guard needed here. + const state = await loadState(cmdEnv.statePath, config.host); const { client } = await authedSession(); // One shared resolver (#20): buildPlan and buildPermissionPlan run concurrently, so a single diff --git a/src/config/query-refs.ts b/src/config/query-refs.ts index a6bf75f..28c9177 100644 --- a/src/config/query-refs.ts +++ b/src/config/query-refs.ts @@ -78,7 +78,15 @@ export type PortablizeReason = /** A role resolved, but the group TYPE it belongs to is not managed, so no portable pair exists. */ | "role-group-type-unmanaged" /** The dimension has no logical reference form at all (e.g. group statuses — no REST catalog). */ - | "no-ref-kind"; + | "no-ref-kind" + /** + * Reported by {@link scanUnportablized} only: the id sits in an entity position and is therefore + * not portable, and that is ALL that was checked. The rewrite reasons above each assert a fact + * about the host (this group is unmanaged / no such role row exists) that a positional scan with no + * state, no catalogs and no network never established — claiming one of them here would print a + * confident falsehood, and a remedy that fails, for an id whose group IS adopted. + */ + | "left-numeric"; /** An id left numeric — collected, not thrown (the escape hatch), and reported with its reason (#101). */ export interface PortablizeWarning { @@ -101,13 +109,25 @@ const UNPORTABLE_ENTITY_VARS: Readonly> = { "ctgroup.groupStatusId": "group statuses have no REST catalog (#67) — no logical form exists", }; -/** The reason text for an id no logical key mapped to, per kind. */ -function unmanagedDetail(kind: RefKind, id: number): string { +/** + * The reason text for an id no logical key mapped to, per kind. + * + * Deliberately ID-FREE: {@link formatPortablizeWarnings} merges every id sharing a (var, reason) into + * ONE line and prints the ids itself, so a detail naming one specific id would be stamped across all + * of them — "12, 34, 56 left numeric — group #12 is not under management" reads as if the remedy for + * #12 covers the other two. + */ +function unmanagedDetail(kind: RefKind): string { return kind === "group" - ? `group #${id} is not under management — \`ct adopt group ${id}\` (then re-adopt) makes it portable` - : `no managed ${kind} carries id ${id} on this host`; + ? "not under management — `ct adopt group ` for each (then re-adopt) makes them portable" + : `no managed ${kind} on this host carries these ids`; } +/** The scan-mode detail: says only what a positional scan can actually know. See "left-numeric". */ +const LEFT_NUMERIC_DETAIL = + "host-specific id(s) frozen into a cross-host ruleset — re-adopt the group with " + + "`--with-dynamic` to rewrite them into logical references (it reports what, if anything, blocks each)"; + export interface PortablizeOptions { /** Per-kind numeric-id → logical-key maps, supplied by the caller (from state/catalogs). Deterministic. */ idToKeyByKind: Partial>>; @@ -121,6 +141,12 @@ export interface PortablizeOptions { * key. A role whose group type is unmanaged (no key) is left numeric with a warning (escape hatch). */ groupTypeIdToKey?: Map; + /** + * Report-only mode ({@link scanUnportablized}): no maps are supplied, so every reason degrades to + * the neutral `left-numeric` rather than asserting an unchecked one. Internal — callers that + * actually rewrite never set it. + */ + scanOnly?: boolean; } export interface PortablizeResult { @@ -153,17 +179,29 @@ function varNameOf(node: unknown): string | undefined { */ export function portablizeRuleset( ruleset: Record, - { idToKeyByKind, roleCatalog, groupTypeIdToKey }: PortablizeOptions, + { idToKeyByKind, roleCatalog, groupTypeIdToKey, scanOnly }: PortablizeOptions, ): PortablizeResult { const warnings: PortablizeWarning[] = []; + /** Push either the checked reason, or — in scan mode — the only one a positional scan earned. */ + const warnLeftNumeric = ( + varName: string, + id: number, + checked: () => Omit, + ) => + warnings.push({ + var: varName, + id, + ...(scanOnly ? { reason: "left-numeric" as const, detail: LEFT_NUMERIC_DETAIL } : checked()), + }); + const marker = (kind: RefKind, key: string): SimpleRef => ({ __ctRef: true, kind, key }) as SimpleRef; const mapScalar = (value: unknown, kind: RefKind, varName: string): unknown => { if (typeof value !== "number") return value; // booleans/strings/nulls are literals, never entity ids const key = idToKeyByKind[kind]?.get(value); if (key !== undefined) return marker(kind, key); - warnings.push({ var: varName, id: value, reason: "unmanaged", detail: unmanagedDetail(kind, value) }); + warnLeftNumeric(varName, value, () => ({ reason: "unmanaged", detail: unmanagedDetail(kind) })); return value; }; @@ -185,21 +223,17 @@ export function portablizeRuleset( // Two genuinely different failures, kept apart: the role is unknown to `/group/roles` at all, or // it resolved but its group type is unmanaged. They need different fixes, so they get different // reasons rather than one "could not portablize". - warnings.push( + warnLeftNumeric(varName, value, () => entry ? { - var: varName, - id: value, reason: "role-group-type-unmanaged", detail: - `role "${entry.name}" belongs to group type #${entry.groupTypeId}, which is not managed — ` + - `adopt that group type to make the (group-type, role) pair portable`, + "the role's group type is not managed — adopt that group type to make the " + + "(group-type, role) pair portable", } : { - var: varName, - id: value, reason: "role-unknown", - detail: `no /group/roles row carries groupTypeRoleId ${value} on this host`, + detail: "no /group/roles row on this host carries these groupTypeRoleIds", }, ); return value; @@ -275,6 +309,11 @@ export function scanUnportablized(ruleset: unknown): PortablizeWarning[] { // No id→key maps and no role catalog: every numeric entity id therefore fails to map and is // reported, while an already-portable `{ __ctRef }` marker is not a number and is never flagged. idToKeyByKind: {}, + // …which also means the "unmanaged" / "role-unknown" verdicts the rewrite path computes were + // never actually checked here — `scanOnly` degrades them to `left-numeric` so this scan states + // only what position proves. Without it, `ct plan` tells someone whose group IS adopted that it + // is not under management, and hands them a `ct adopt` that fails. + scanOnly: true, }); return warnings; } @@ -283,11 +322,15 @@ export function scanUnportablized(ruleset: unknown): PortablizeWarning[] { * One line per (var, reason), naming the ids — the shape both `ct adopt --with-dynamic` and * `ct plan` print (#101). Grouped so a ruleset with 30 unmanaged group ids is one readable line, not * thirty; ids are sorted so the output is stable across runs and diffable in CI logs. + * + * The `detail` is part of the grouping key, not just the reason: merging ids under ONE detail is only + * honest while that detail holds for all of them, so a detail that ever names a specific id splits + * into its own line instead of being stamped across the group (details are kept id-free above). */ export function formatPortablizeWarnings(warnings: readonly PortablizeWarning[]): string[] { const grouped = new Map(); for (const w of warnings) { - const k = `${w.var} ${w.reason}`; + const k = `${w.var} ${w.reason} ${w.detail}`; const hit = grouped.get(k); if (hit) { if (!hit.ids.includes(w.id)) hit.ids.push(w.id); diff --git a/src/permissions/catalog-store.ts b/src/permissions/catalog-store.ts index 6ad33ce..4614432 100644 --- a/src/permissions/catalog-store.ts +++ b/src/permissions/catalog-store.ts @@ -63,10 +63,37 @@ export async function loadHostCatalog(host: string, dir: string = CATALOG_DIR): if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error(`Malformed permission catalog ${path}: expected a JSON object at the top level.`); } + assertCatalogShape(parsed as Record, path); useCatalog(parsed, { perInstance: true }); return path; } +/** + * Check every right entry, not just the top level. "It parsed as an object" is far too weak a gate + * for a file that decides what a permission NAME means: an entry missing its `authId` still resolves + * truthily, so `resolveAuthId` hands back `{ authId: undefined }`, the tuple matches no actual, and + * `ct apply` PUTs a permission row with `authId: undefined`. A hand-edited, half-merged or + * future-shaped capture must fail here, loudly, rather than three layers downstream on a write. + */ +function assertCatalogShape(parsed: Record, path: string): void { + for (const [name, value] of Object.entries(parsed)) { + if (name === "$meta") continue; // reserved provenance key — not a right (see catalog.ts) + const bad = (why: string): never => { + throw new Error(`Malformed permission catalog ${path}: right "${name}" ${why}.`); + }; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + bad("is not an object"); + } + const entry = value as Record; + if (typeof entry.authId !== "number" || !Number.isFinite(entry.authId)) { + bad("has no numeric authId"); + } + if (entry.scopeField !== null && typeof entry.scopeField !== "string") { + bad("has a scopeField that is neither a string nor null"); + } + } +} + /** The legacy master-data shape (`auth_table[module][right]`) — see `capturePermissionCatalog`. */ interface RawRight { id: number; diff --git a/src/permissions/fetch.ts b/src/permissions/fetch.ts new file mode 100644 index 0000000..27d4658 --- /dev/null +++ b/src/permissions/fetch.ts @@ -0,0 +1,43 @@ +/** + * The one read every permission path shares: `GET /permissions/` (and its + * `/` form). + * + * These reads were written as a plain `client.get`, on the belief that a permission domain returns a + * single instance-wide blob rather than a paged list. That belief is not checked anywhere, and #100 + * is the whole catalogue of what happens when it is wrong: a paged endpoint read with a plain `get` + * returns CT's default first page as a valid-looking array, with `meta` — the only evidence rows are + * missing — discarded by `request()`'s `data` unwrap. On a host with a few hundred authored grants + * that would make `ct plan` see a truncated actual set (re-PUTting grants that already exist), + * `ct coverage` under-report, and `ct adopt grants --all-declarable` skip most role instances as + * "no authored grants" — all of it silent, all of it looking exactly like a correct run. + * + * So the assumption is now enforced instead of assumed: read the envelope, and if `meta.pagination` + * says more rows exist than arrived, page the endpoint properly rather than returning a first page. + * When the endpoint is un-paged (the expected case today) this costs one request, exactly as before. + */ +import { hasMorePages, type CtClient } from "../api/ctClient.js"; +import type { RawPermission } from "./grants.js"; + +/** + * A client able to perform the guarded read. `getRaw`/`getAll` are optional so the callers' existing + * `Pick` contract still holds for the narrow test doubles that supply only `get` — + * a real {@link CtClient} always carries all three and always takes the guarded path. + */ +export type PermissionReader = Pick & Partial>; + +/** Read one permission domain's rows, following pagination if the endpoint turns out to paginate. */ +export async function fetchPermissionRows(client: PermissionReader, path: string): Promise { + if (!client.getRaw || !client.getAll) { + // No envelope access (test double): fall back to the plain read rather than silently returning + // nothing. Production always has both. + const rows = await client.get(path); + return Array.isArray(rows) ? rows : []; + } + const { data, meta } = await client.getRaw(path); + const rows = Array.isArray(data) ? data : []; + if (!hasMorePages(meta, rows.length)) { + return rows; + } + const all = await client.getAll(path); + return all.data; +} diff --git a/src/permissions/plan.ts b/src/permissions/plan.ts index 749c77b..d7b60f8 100644 --- a/src/permissions/plan.ts +++ b/src/permissions/plan.ts @@ -4,8 +4,8 @@ * (the managed-guard — unmanaged domainIds are never surfaced or touched), * and diff. Mirrors `src/engine/build.ts`'s fetch-error handling. */ -import type { CtClient } from "../api/ctClient.js"; import { CtApiError } from "../api/ctClient.js"; +import { fetchPermissionRows, type PermissionReader } from "./fetch.js"; import type { State } from "../state/state.js"; import type { DesiredResource } from "../engine/types.js"; import { @@ -197,7 +197,7 @@ async function resolveDomainIds( } export async function buildPermissionPlan( - client: Pick, + client: PermissionReader, state: State, permissions: DesiredPermission[], desired: DesiredResource[] = [], @@ -211,22 +211,26 @@ export async function buildPermissionPlan( // live instance reports a different version, right names/authIds/scopeFields may have drifted — // warn (never fail) so the diff is trusted-but-verified. // - // Two narrowings since #105, both aimed at the same failure: a warning that fires on every plan is - // a warning nobody reads, including on the plan where a moved authId would actually matter. - // 1. A catalog captured FROM THIS HOST is authoritative for it, so a version delta against the - // BUNDLED catalog is the only thing worth mentioning. - // 2. The remediation now names a command a consumer repo can actually run. + // The remediation now names a command a consumer repo can actually run (#105) — the old text + // pointed at a script that only exists in this repo, so the warning was unactionable where printed. + // + // A per-instance capture is authoritative for its host AT CAPTURE TIME, not forever: the instance + // gets upgraded while the committed file does not. So the version is compared either way; only the + // wording differs, because "re-capture" and "capture one" are different asks. if ( permissions.length > 0 && instanceVersion && CATALOG_META && - !CATALOG_IS_PER_INSTANCE && compareVersions(instanceVersion, CATALOG_META.ctVersion) !== 0 ) { warnings.push( - `Permission catalog was captured from ChurchTools ${CATALOG_META.ctVersion} but this instance ` + - `runs ${instanceVersion}. Right names/authIds may be stale — capture one for this instance ` + - `with \`ct permissions catalog --refresh\` (see docs/handbuch/permissions.md).`, + CATALOG_IS_PER_INSTANCE + ? `Permission catalog for this host was captured against ChurchTools ${CATALOG_META.ctVersion} ` + + `but the instance now runs ${instanceVersion}. Right names/authIds may have drifted since — ` + + `re-capture with \`ct permissions catalog --refresh\` (see docs/handbuch/permissions.md).` + : `Permission catalog was captured from ChurchTools ${CATALOG_META.ctVersion} but this instance ` + + `runs ${instanceVersion}. Right names/authIds may be stale — capture one for this instance ` + + `with \`ct permissions catalog --refresh\` (see docs/handbuch/permissions.md).`, ); } // Resolve logical domainIds (#20) up front. Shares the command layer's resolver so master-data @@ -244,7 +248,7 @@ export async function buildPermissionPlan( const byType = new Map(); for (const dt of new Set(resolved.filter((p) => p.pendingDomain === undefined).map((p) => p.domainType))) { try { - byType.set(dt, await client.get(`/permissions/${dt}`)); + byType.set(dt, await fetchPermissionRows(client, `/permissions/${dt}`)); } catch (err) { const message = err instanceof CtApiError ? `${err.status}` : (err as Error).message; fetchErrors.push(`permissions ${dt}: ${message}`); @@ -299,8 +303,8 @@ export async function buildPermissionPlan( for (const authId of [...unknownAuthIds].sort((a, b) => a - b)) { warnings.push( `${p.domainType} #${p.domainId} ("${p.key}"): a live grant carries authId ${authId}, which is ` + - `not in the permission catalog — left untouched (never revoked). Regenerate the catalog ` + - `(\`npm run regenerate:permission-catalog\`) if this right should be manageable.`, + `not in the permission catalog — left untouched (never revoked). Capture this instance's own ` + + `catalog (\`ct permissions catalog --refresh\`) if this right should be manageable.`, ); } items.push({ diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index c118661..3f06300 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -396,7 +396,9 @@ describe("ct adopt group --with-dynamic --portable-rulesets (#76 Stage 3)", () = // The whole point of #101: not "left N ids numeric", but WHICH dimension, WHICH ids, and WHY. expect(warned).toContain("rulesets/portable_dynamic.json keeps"); expect(warned).toContain("NOT portable to another host"); - expect(warned).toMatch(/ctgroup\.id: 999 left numeric — group #999 is not under management/); + // 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/); }); diff --git a/tests/get-command.test.ts b/tests/get-command.test.ts index 0896ded..ca0148f 100644 --- a/tests/get-command.test.ts +++ b/tests/get-command.test.ts @@ -225,4 +225,13 @@ describe("ct get raw pagination (#100)", () => { it("rejects a nonsense --page instead of silently probing page 1", async () => { await expect(runGet(["raw", "/groups", "--page", "0"])).rejects.toThrow(/Invalid --page/); }); + + // Both spellings are explicit, so neither may be silently dropped. Appending anyway produced + // `?limit=50&page=2&limit=100` — a duplicated param whose winner is the server's parsing rule. + it("rejects --page combined with page/limit already in the path", async () => { + await expect(runGet(["raw", "/groups?limit=50", "--page", "2"])).rejects.toThrow( + /--page 2 conflicts with the page\/limit already in the path/, + ); + expect(getRawMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/permission-catalog-store.test.ts b/tests/permission-catalog-store.test.ts index 39bceb2..37fec30 100644 --- a/tests/permission-catalog-store.test.ts +++ b/tests/permission-catalog-store.test.ts @@ -99,6 +99,20 @@ describe("loadHostCatalog", () => { const dir = tempCatalogDir([]); await expect(loadHostCatalog(HOST, dir)).rejects.toThrow(/expected a JSON object/); }); + + // "It parsed as an object" is not enough for a file that decides what a permission NAME means: an + // entry with no authId resolves truthily, matches no actual, and reaches `ct apply` as a PUT + // carrying `authId: undefined`. It has to fail here, not three layers downstream on a write. + it("rejects an entry with no numeric authId rather than PUTting `authId: undefined` later", async () => { + const dir = tempCatalogDir({ "churchdb:view": { scopeField: null, revocable: false, desc: "" } }); + await expect(loadHostCatalog(HOST, dir)).rejects.toThrow(/right "churchdb:view" has no numeric authId/); + expect(CATALOG_IS_PER_INSTANCE).toBe(false); + }); + + it("rejects an entry whose scopeField is neither a string nor null", async () => { + const dir = tempCatalogDir({ "churchdb:view": { authId: 1, scopeField: 7, revocable: false, desc: "" } }); + await expect(loadHostCatalog(HOST, dir)).rejects.toThrow(/scopeField that is neither a string nor null/); + }); }); describe("capturePermissionCatalog", () => { diff --git a/tests/permission-fetch.test.ts b/tests/permission-fetch.test.ts new file mode 100644 index 0000000..6ae99ee --- /dev/null +++ b/tests/permission-fetch.test.ts @@ -0,0 +1,64 @@ +/** + * The guard on `GET /permissions/` (review of #100/#103/#104/#105). + * + * Every permission read was a plain `client.get`, on the unchecked belief that a permission domain + * returns one instance-wide blob rather than a paged list. If that is ever wrong, the failure is + * completely silent: `request()` drops `meta`, so a first page of 10 rows is indistinguishable from + * a complete answer — `ct plan` sees a truncated actual set, `ct coverage` under-reports, and + * `ct adopt grants --all-declarable` files most role instances under "no authored grants". + */ +import { describe, it, expect, vi } from "vitest"; +import { fetchPermissionRows } from "../src/permissions/fetch.js"; + +const row = (id: number) => ({ id, domainType: "group_role", domainId: id, authId: 1, isInherited: false }); + +describe("fetchPermissionRows", () => { + it("costs exactly one request while the endpoint returns no pagination block", async () => { + const getRaw = vi.fn(async () => ({ data: [row(1), row(2)] })); + const getAll = vi.fn(); + const rows = await fetchPermissionRows( + { get: vi.fn(), getRaw, getAll } as never, + "/permissions/group_role", + ); + expect(rows).toHaveLength(2); + expect(getRaw).toHaveBeenCalledWith("/permissions/group_role"); + expect(getAll).not.toHaveBeenCalled(); + }); + + it("pages the endpoint properly when meta says more rows exist than arrived", async () => { + const getRaw = vi.fn(async () => ({ + data: [row(1)], + meta: { pagination: { total: 300, current: 1, lastPage: 3, limit: 100 } }, + })); + const getAll = vi.fn(async () => ({ data: [row(1), row(2), row(3)] })); + const rows = await fetchPermissionRows( + { get: vi.fn(), getRaw, getAll } as never, + "/permissions/group_role", + ); + expect(rows).toHaveLength(3); + expect(getAll).toHaveBeenCalledWith("/permissions/group_role"); + }); + + it("also catches a `total` reported without page numbers", async () => { + const getRaw = vi.fn(async () => ({ data: [row(1)], meta: { pagination: { total: 42 } } })); + const getAll = vi.fn(async () => ({ data: [row(1), row(2)] })); + const rows = await fetchPermissionRows({ get: vi.fn(), getRaw, getAll } as never, "/permissions/status"); + expect(rows).toHaveLength(2); + }); + + it("falls back to the plain read for a narrow client that has no envelope access", async () => { + const get = vi.fn(async () => [row(1)]); + const rows = await fetchPermissionRows({ get } as never, "/permissions/group_role"); + expect(rows).toEqual([row(1)]); + expect(get).toHaveBeenCalledWith("/permissions/group_role"); + }); + + it("returns [] for a non-array body rather than letting it reach the diff", async () => { + const getRaw = vi.fn(async () => ({ data: { message: "nope" } })); + const rows = await fetchPermissionRows( + { get: vi.fn(), getRaw, getAll: vi.fn() } as never, + "/permissions/group_role", + ); + expect(rows).toEqual([]); + }); +}); diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index 3a89c05..884540b 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi } from "vitest"; import { desiredTuples, buildPermissionPlan } from "../src/permissions/plan.js"; -import { CATALOG_META } from "../src/permissions/catalog.js"; +import { CATALOG, CATALOG_META, useCatalog, useBundledCatalog } from "../src/permissions/catalog.js"; import { ref } from "../src/resolve/refs.js"; import type { State } from "../src/state/state.js"; +/** The bundled rights, snapshotted before any test swaps the catalog (CATALOG is a live binding). */ +const bundledForTest = { ...CATALOG }; + const state: State = { version: 1, host: "h", @@ -415,6 +418,52 @@ describe("buildPermissionPlan", () => { ); expect(warnings).toEqual([]); }); + + // A per-instance capture is authoritative for its host AT CAPTURE TIME — not forever. The instance + // gets upgraded while the committed file does not, which is exactly when a moved authId silently + // changes what a declared right grants, so the comparison must still run (#105 review). + it("still warns on version skew under a PER-INSTANCE catalog, naming --refresh as the remedy", async () => { + const client = { get: vi.fn(async () => []) }; + useCatalog( + { ...bundledForTest, $meta: { ...CATALOG_META, ctVersion: "3.135.0" } }, + { perInstance: true }, + ); + try { + const { warnings } = await buildPermissionPlan( + client as never, + state, + [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }], + [], + undefined, + "3.150.0", + ); + expect(warnings.some((w) => /captured against ChurchTools 3\.135\.0/.test(w))).toBe(true); + expect(warnings.some((w) => /ct permissions catalog --refresh/.test(w))).toBe(true); + } finally { + useBundledCatalog(); + } + }); + + it("stays quiet under a per-instance catalog captured against the version the host still runs", async () => { + const client = { get: vi.fn(async () => []) }; + useCatalog( + { ...bundledForTest, $meta: { ...CATALOG_META, ctVersion: "3.135.0" } }, + { perInstance: true }, + ); + try { + const { warnings } = await buildPermissionPlan( + client as never, + state, + [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }], + [], + undefined, + "3.135.0", + ); + expect(warnings).toEqual([]); + } finally { + useBundledCatalog(); + } + }); // The PERSON-status domain end to end (#90): a `personStatus` ref resolves against /statuses, the // planner bulk-fetches /permissions/status, and the live `dataId: -1` ALL sentinel round-trips to // a clean no-op — which is the whole point (an instance-wide grant that churned every plan would diff --git a/tests/query-refs.test.ts b/tests/query-refs.test.ts index 11cd719..014fdd8 100644 --- a/tests/query-refs.test.ts +++ b/tests/query-refs.test.ts @@ -88,7 +88,7 @@ describe("portablizeRuleset (#76 Stage 2)", () => { var: "ctgroup.id", id: 999, reason: "unmanaged", - detail: "group #999 is not under management — `ct adopt group 999` (then re-adopt) makes it portable", + detail: "not under management — `ct adopt group ` for each (then re-adopt) makes them portable", }, ]); }); @@ -156,7 +156,7 @@ describe("portablizeRuleset (#76 Stage 2)", () => { var: "role.id", id: 999, reason: "role-unknown", - detail: "no /group/roles row carries groupTypeRoleId 999 on this host", + detail: "no /group/roles row on this host carries these groupTypeRoleIds", }, ]); }); @@ -174,7 +174,7 @@ describe("portablizeRuleset (#76 Stage 2)", () => { id: 84, reason: "role-group-type-unmanaged", detail: - 'role "Leiter" belongs to group type #12, which is not managed — ' + + "the role's group type is not managed — " + "adopt that group type to make the (group-type, role) pair portable", }, ]); @@ -190,7 +190,7 @@ describe("portablizeRuleset (#76 Stage 2)", () => { var: "role.id", id: 84, reason: "role-unknown", - detail: "no /group/roles row carries groupTypeRoleId 84 on this host", + detail: "no /group/roles row on this host carries these groupTypeRoleIds", }, ]); }); @@ -243,7 +243,7 @@ describe("portablizeRuleset (#76 Stage 2)", () => { var: "groupTypeRoleId", id: 4242, reason: "role-unknown", - detail: "no /group/roles row carries groupTypeRoleId 4242 on this host", + detail: "no /group/roles row on this host carries these groupTypeRoleIds", }, ]); }); @@ -333,8 +333,7 @@ describe("portablizeRuleset (#76 Stage 2)", () => { var: "ctgroup.id", id: 1246, reason: "unmanaged", - detail: - "group #1246 is not under management — `ct adopt group 1246` (then re-adopt) makes it portable", + 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. @@ -354,5 +353,20 @@ describe("portablizeRuleset (#76 Stage 2)", () => { true, ); }); + + // The scan has no state, no catalogs and no network, so it can prove POSITION and nothing else. + // Reporting "unmanaged" from here told someone whose group IS adopted that it is not under + // management — on every plan — and handed them a `ct adopt` that fails because it already is. + it("reports only what position proves — never an unchecked 'unmanaged'/'role-unknown' verdict", () => { + const left = scanUnportablized(normalized); + expect(left.some((w) => w.var === "ctgroup.id")).toBe(true); + 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"), + ).toBe(true); + }); }); }); From 62835b066be4ec0ad5ca3a52699d9c4144dad984 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 13 Aug 2026 18:26:01 +0200 Subject: [PATCH 5/5] docs: re-sign the three handbook pages whose sources this fix touched Claude-Session: https://claude.ai/code/session_016TacHkv3oVczwXGHBwckq2 --- docs/handbuch/dynamic-groups.md | 2 +- docs/handbuch/field-definitions.md | 2 +- docs/handbuch/permissions.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index 59915dc..63a1a12 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,7 +6,7 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/commands/adopt-group.ts -sources_hash: a1fd81d6d76bba76 +sources_hash: 3fe32a405c9a8b4f reviewed: 2026-08-10 --- diff --git a/docs/handbuch/field-definitions.md b/docs/handbuch/field-definitions.md index 0c4ade2..a41a6a5 100644 --- a/docs/handbuch/field-definitions.md +++ b/docs/handbuch/field-definitions.md @@ -3,7 +3,7 @@ title: Field definitions & security levels sources: - src/commands/get.ts - src/api/ctClient.ts -sources_hash: e22ccc069dc6b95a +sources_hash: 16a6283b7ce1d982 reviewed: 2026-08-13 --- diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 4c4ac4d..fe0c962 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -5,7 +5,7 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: 2fe2c8ac3f4e7c00 +sources_hash: 050a604ccb2391c8 reviewed: 2026-08-13 ---