diff --git a/README.md b/README.md index 82eb9f7..75530e9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,13 @@ Early scaffold. See the [epic (#1)](https://github.com/eqrm/ct-cli/issues/1) and - ✅ **Phase 2 — Read/Adopt** ([#4](https://github.com/eqrm/ct-cli/issues/4)): `ct adopt` + JSON state file. - ✅ **Phase 3 — Declarative engine** ([#5](https://github.com/eqrm/ct-cli/issues/5)): config DSL, `plan`/diff, dependency graph, group hierarchy. - ✅ **Phase 4 — Apply + guardrails** ([#6](https://github.com/eqrm/ct-cli/issues/6)): `ct apply` / `ct destroy`, confirmation, backup, `preventDestroy`. -- ⬜ Phase 5: blueprints. +- ✅ **Phase 5 — Blueprints** ([#7](https://github.com/eqrm/ct-cli/issues/7)): + all three planned features landed — auto-groups / dynamic groups + ([#14](https://github.com/eqrm/ct-cli/issues/14)), permissions + ([#13](https://github.com/eqrm/ct-cli/issues/13)), and blueprints + ([#7](https://github.com/eqrm/ct-cli/issues/7)) — see + [Auto-groups](#auto-groups), [Permissions](#permissions), and + [Blueprints](#blueprints) below. ## Requirements @@ -95,6 +101,68 @@ export default (ct) => { Machine-readable output goes to **stdout** (pipe/`jq` it); human status lines go to **stderr**. +### Blueprints + +A "blueprint" is a plain function over the injected `ConfigContext` — no +special machinery. Pull a repeated structure (e.g. a campus's Kids area) +into a function and call it once per campus, prefixing every key with +`${campus}_` to keep each instantiation's resources unique and its managed +hierarchy (`parents`) scoped to that campus. `ct plan`/`ct apply` order +campuses → groups → hierarchy automatically via the dependency graph, and +an undeclared `parents` reference throws at config-load time (typo guard). +See [`docs/blueprints.md`](docs/blueprints.md) for the full guide and +[`examples/campus-blueprint.config.ts`](examples/campus-blueprint.config.ts) +for a runnable example. + +### Auto-groups + +A group can opt in to a `dynamic` block to manage its ChurchTools "dynamic +group" (auto-group) ruleset + status alongside its plain fields: + +```ts +ct.group({ + key: "all_mainz", + name: "Alle Mainz", + groupTypeId: 1, + dynamic: { status: "manual", ruleset: churchQueryRuleset /* inline object | { ref } | q + churchQuery */ }, +}); +``` + +`ct apply --refresh` opts in to a post-apply, per-group membership +recompute for every dynamic group that changed. See +[`docs/dynamic-groups.md`](docs/dynamic-groups.md) for the full guide (the +`status` states, the three ways to supply a ruleset, the typed query DSL, +and how drift normalization avoids cosmetic false diffs) and +[`examples/dynamic-group.config.ts`](examples/dynamic-group.config.ts) for a +runnable example. + +### Permissions + +`ct.groupRole` / `ct.groupTypeRole` declare ChurchTools permission grants +(group-role and group-type-role rights) as code, reconciled with the same +`plan`/`apply` workflow: + +```ts +ct.groupTypeRole({ + key: "leiter_tpl", + id: 1, // the domainId — the group type's own id for group_type_role + grants: [ + "churchgroup:view", // unscoped + { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped to a managed group + ], +}); +``` + +Right names (`"module:right"`) are validated against a static, offline +catalog — discover them with `ct get permissions-catalog`. Grants you remove +from the config are diffed as deletions and reconciled via `ct apply`, same +as any other resource; system-default and inherited grants are never touched +or surfaced. See [`docs/permissions.md`](docs/permissions.md) for the full +guide (domainId semantics, scope resolution, domain rules, the +baseline-tolerance model) and +[`examples/permissions.config.ts`](examples/permissions.config.ts) for a +runnable example. + ## Auth model The personal login token authenticates via a session handshake, **not** an diff --git a/docs/blueprints.md b/docs/blueprints.md new file mode 100644 index 0000000..f13f596 --- /dev/null +++ b/docs/blueprints.md @@ -0,0 +1,213 @@ +# Blueprints (parametrized, reusable config) + +A "blueprint" is not a feature the tool implements — it's a naming for a +pattern the existing config DSL already supports for free. `ct.config.ts` +default-exports a plain function that receives the injected +[`ConfigContext`](../src/config/context.ts) (`ct.campus`, `ct.group`, +`ct.groupTypeRole`, ...) and calls it however you like: in a loop, from a +helper function, across multiple files. There is no special "blueprint" +API, decorator, or registration step — just TypeScript functions closing +over `ct` (issue #7). + +See [`examples/campus-blueprint.config.ts`](../examples/campus-blueprint.config.ts) +for a complete, runnable example; this doc walks through what it does and +why. + +## A blueprint is a function over `ConfigContext` + +Pull the campus-specific parts of a config out into a function that takes +`ct: ConfigContext` plus whatever varies per instantiation (a campus key, a +label, an id): + +```ts +import type { ConfigContext } from "../src/config/context.js"; + +function kidsArea(ct: ConfigContext, campus: string): void { + const lead = `${campus}_kids_lead`; + ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupTypeId: 2, parents: [] }); + // ...more ct.group(...) calls scoped to this campus +} +``` + +Calling `kidsArea(ct, "mainz")` and `kidsArea(ct, "berlin")` from the same +default export declares two independent Kids-area structures — one per +campus — using the same code. That's the whole mechanism: no templating +language, no generated files, just a function called twice. + +## The loop-over-campuses pattern and `${campus}_`-prefixed keys + +Because every declared resource needs a config-wide-unique `key` +(`createContext`'s `define` throws on a duplicate), a blueprint that's +instantiated per campus must prefix every key it declares with the thing +that makes it unique — typically the campus key: + +```ts +const CAMPUSES = ["mainz", "berlin"] as const; + +export default (ct: ConfigContext): void => { + for (const campus of CAMPUSES) { + ct.campus({ key: campus, name: `Campus ${campus}`, shorty: campus.slice(0, 3).toUpperCase() }); + kidsArea(ct, campus); // declares mainz_kids_lead, mainz_kids_0_3, ... / berlin_kids_lead, ... + } +}; +``` + +`${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 +once in `kidsArea`. Add a third campus to `CAMPUSES` and the same function +produces a third, fully independent structure — no changes to `kidsArea` +itself. + +## `parents` scopes hierarchy per campus + +Group hierarchy (`parents`) is opt-in and references other groups **by +key** (see [`docs/dynamic-groups.md`](dynamic-groups.md) and the DSL doc +comment in [`src/config/context.ts`](../src/config/context.ts)). Because +each campus's group keys are prefixed, a blueprint's `parents: [lead]` +inside `kidsArea` naturally scopes hierarchy to that one campus's own +`lead` variable — `berlin_kids_0_3`'s `parents` can only ever point at +`berlin_kids_lead` (the closure captured `lead = "berlin_kids_lead"` for +that call), never at Mainz's tree: + +```ts +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) { + ct.group({ + key: `${campus}_kids_${suffix}`, + name: `${campus} · Kids ${label}`, + groupTypeId: 2, + parents: [lead], // always this campus's own lead group + }); + } +} +``` + +No cross-campus hierarchy edges can leak in by accident — the prefixing +convention and lexical scoping do that for you. + +## Composing an auto-group and a permission grant inside a blueprint + +Because a blueprint is just code calling `ct.*`, it can freely mix resource +types. `kidsArea` declares a `dynamic` block (an auto-group, #14 — see +[`docs/dynamic-groups.md`](dynamic-groups.md)) on the "all members" group +right alongside the plain groups: + +```ts +ct.group({ + key: `${campus}_kids_all`, + name: `${campus} · Kids (alle)`, + groupTypeId: 2, + parents: [lead], + dynamic: { + status: "manual", + ruleset: { + description: `Alle aktiven Kids-Mitarbeiter ${campus}`, + importance: 0, + personIdFieldName: "person.id", + process: {}, + query: churchQuery(q.eq("person.isArchived", false)), + }, + }, +}); +``` + +And the outer default export layers a permission grant (#13 — see +[`docs/permissions.md`](permissions.md)) on a shared `groupTypeRole` +template, declared once and applying across every campus's groups of that +type: + +```ts +export default (ct: ConfigContext): void => { + for (const campus of CAMPUSES) { + ct.campus({ key: campus, name: `Campus ${campus}`, shorty: campus.slice(0, 3).toUpperCase() }); + kidsArea(ct, campus); + } + const kidsLeads = CAMPUSES.map((c) => `${c}_kids_lead`); + ct.groupTypeRole({ + key: "kids_lead_tpl", + id: 2, + grants: [ + { right: "churchgroup:view group", scope: kidsLeads }, + { right: "churchgroup:edit group memberships of group", scope: kidsLeads }, + ], + }); +}; +``` + +There's nothing blueprint-specific about `dynamic` or `groupTypeRole` here +— they're the same DSL calls documented in +[`docs/dynamic-groups.md`](dynamic-groups.md) and +[`docs/permissions.md`](permissions.md), just invoked from inside a +reusable function instead of inline at the top level. + +## Ordering is automatic + +`ct plan` / `ct apply` order every declared resource with +[`orderKeys`](../src/engine/graph.ts), which sorts by a fixed type tier +(`campus` / `group-type` / ... at tier 0, `group` at tier 1) and then, within +a tier, by dependency edges — honouring ties by original declaration order. +A blueprint doesn't need to worry about sequencing: + +- **Campuses before groups.** `ct.campus(...)` is tier 0; `ct.group(...)` is + tier 1, so every campus in the loop is created before any of that campus's + groups, regardless of call order inside the loop body. +- **Parent groups before child groups.** `parents: [lead]` adds `lead` as a + dependency edge on the child (`toDesired` folds `parents` into + `dependsOn`), so `orderKeys` always places `${campus}_kids_lead` before + `${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 + `SYNTHETIC_FIELDS` in [`src/engine/synthetic.ts`](../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 + `parents`, its referenced parent groups) already exist. +- **Permissions apply after the structural plan.** `ct.groupTypeRole` / + `ct.groupRole` declarations aren't part of `orderKeys`'s dependency graph + at all — they go through a separate plan/apply pass + (`buildPermissionPlan` / `applyPermissionPlan`) that `ct apply` runs only + after the structural plan (campuses, groups, hierarchy, auto-groups) has + been applied. So a scoped grant's `scope: ["some_group_key"]` can safely + reference a group declared earlier in the same blueprint, even one created + in this very run. + +Net effect for a two-campus blueprint like the example: `ct plan` always +produces campuses → lead groups → ministry-team groups (in per-campus, +per-group declaration order) → permission grants, for as many campuses as +the loop instantiates, with no manual `parent:`/`dependsOn` bookkeeping +beyond the `parents: [lead]` you'd write anyway. + +## The managed-parent typo guard + +`parents` references are validated **at config-evaluation time**, before +any plan or diff is computed (`validateReferences` in +[`src/config/context.ts`](../src/config/context.ts), run by +`evaluateConfig`). Every key listed in a `parents` array must resolve to a +`group` declared *somewhere in the same config* — including inside a +blueprint function called from the top-level export. A typo, a forgotten +`kidsArea(ct, campus)` call, or a `parents` key pointing at a non-group +resource throws immediately: + +``` +Group "berlin_kids_0_3" declares hierarchy parent "berlin_kids_laed", which is not declared in this config. +Managed parents must reference a group by its key (omit unmanaged parents entirely). +``` + +This matters more in a blueprint than in a hand-written flat config, +because the `${campus}_`-prefixed key is itself computed +(`` `${campus}_kids_lead` ``, not a literal string) — a copy-paste slip in +one branch of a blueprint (e.g. reusing `mainz`'s lead key inside the +`berlin` iteration) is exactly the kind of mistake this guard exists to +catch before it ever reaches `ct plan` against a live instance. + +## Full example + +See [`examples/campus-blueprint.config.ts`](../examples/campus-blueprint.config.ts) +for the complete, runnable config this doc describes: a `kidsArea(ct, +campus)` blueprint instantiated over two campuses (`mainz`, `berlin`), +each producing a lead group, three ministry-team groups (managed hierarchy +under the lead), and a dynamic "all members" auto-group — plus one +`groupTypeRole` permission grant shared across both campuses. diff --git a/docs/dynamic-groups.md b/docs/dynamic-groups.md new file mode 100644 index 0000000..1780582 --- /dev/null +++ b/docs/dynamic-groups.md @@ -0,0 +1,206 @@ +# Auto-groups (dynamic groups) + +ChurchTools "dynamic groups" (aka auto-groups) compute their membership from a +saved query ("ruleset") instead of manual add/remove. `ct-cli` manages a +group's dynamic-group configuration — the ruleset and its status — as an +opt-in extra on `ct.group(...)`, diffed and applied exactly like any other +managed field. + +> People are still never touched. `ct-cli` writes the *ruleset that computes* +> membership, never a person↔group relationship directly. + +## The `dynamic` block + +```ts +ct.group({ + key: "all_mainz", + name: "Alle Mainz", + groupTypeId: 1, + dynamic: { + status: "manual", // "active" | "manual" | "inactive" | "none" + ruleset: { + /* RuleSet object | { ref: "./x.json" } | churchQuery(...) build */ + }, + }, +}); +``` + +`dynamic` is **opt-in** and only valid on `ct.group(...)` — declaring it on +any other resource type throws at config-load time. Omitting it entirely +(`undefined`) means "not a dynamic group", mirroring how `parents` works for +group hierarchy. Validation lives in `src/config/context.ts` (`toDesired`). + +### `status` + +- `active` — ChurchTools recomputes membership automatically. +- `manual` — the ruleset is stored, but membership is only recomputed when + explicitly triggered (`ct apply --refresh`, or manually in the ChurchTools + UI). +- `inactive` — the ruleset is stored but paused. +- `none` — demotes the group back to an ordinary (non-dynamic) group. Keep + the `dynamic` block (the DSL still requires a `ruleset` object — `{}` is + fine, since its content is irrelevant on demote) and set + `status: "none"`. `ct apply` responds with `DELETE + /dynamicgroups/{id}/ruleset` followed by `PUT /dynamicgroups/{id}/status` + with `{ dynamicGroupStatus: "none" }`. + +Statuses are validated against exactly `["active", "inactive", "manual", +"none"]` in `src/config/context.ts`. + +### Ordering: the group must exist first + +A group must exist before it can carry a ruleset. You never have to sequence +this yourself: `dynamic` is a synthetic field on `group` (like `parents`), +not a separate resource with its own tier in `TYPE_TIER` +(`src/engine/graph.ts`). Its ruleset/status writes happen inline, as part of +the group's own tier-1 apply, after the group itself has been +created/updated — so `ct apply` always writes against the group's real +(possibly just-created) id. + +## Supplying a ruleset — three ways + +1. **Inline `RuleSet` object literal** — write the object by hand (e.g. + copy-pasted from `ct get raw /dynamicgroups/{id}/ruleset` and trimmed). +2. **`{ ref: "./relative/path.json" }`** — a file reference. Resolved + relative to the process CWD at fold time by `resolveRulesetRef` + (`src/engine/dynamic.ts`); the referenced file's JSON contents are used + verbatim as the ruleset. Handy for pasting an exported ruleset without + inlining a huge JSONLogic tree into the `.ts` config. +3. **Typed query builder** — build the JSONLogic filter with `q` and wrap it + with `churchQuery` (`src/config/query.ts`, re-exported from + `src/config/context.ts`). + +All three ultimately produce a plain JS object with the same shape; which one +you pick is purely a matter of how you want to author/version it. + +### The `RuleSet` shape + +Real ChurchTools rulesets (captured from `GET /dynamicgroups/{id}/ruleset` — +see `tests/fixtures/dynamic/README.md`) look like: + +```jsonc +{ + "description": "Alle aktiven Personen in Mainz", // human label — lives HERE, not inside `query` + "shorty": "Autom. Mitgliedschaft Alle Mainz", + "personIdFieldName": "person.id", + "importance": 0, + "query": { "method": "ChurchQuery", "params": { "..." : "..." } }, + "process": {}, +} +``` + +`description` and `shorty` are fields **on the ruleset object itself** — a +sibling of `query`, not an argument to `churchQuery(...)`. + +### Typed query builder (`q` / `churchQuery`) + +`q` (`src/config/query.ts`) emits a JSONLogic tree: + +| helper | emits | +| ------------------------------ | ----------------------------------------- | +| `q.and(...nodes)` | `{ and: nodes }` | +| `q.or(...nodes)` | `{ or: nodes }` | +| `q.not(node)` | `{ "!": [node] }` | +| `q.var(name)` | `{ var: name }` | +| `q.eq(varName, value)` | `{ "==": [{ var: varName }, value] }` | +| `q.oneof(varName, values)` | `{ oneof: [{ var: varName }, values] }` | +| `q.isnull(varName)` | `{ isnull: [{ var: varName }] }` | + +`var` values are **raw ChurchTools ids** (e.g. `ctgroup.campusId`) — resolve +any key → id lookup at config-build time, before calling `q.eq`/`q.oneof`, +and pass the number. + +`churchQuery(filter, opts?)` wraps a JSONLogic filter tree in the same +envelope shape ChurchTools itself returns: + +```ts +{ + method: "ChurchQuery", + params: { + groupBy: opts.groupBy ?? ["person.id"], + filter, + primaryEntityAlias: opts.primaryEntityAlias ?? "person", + responseFields: opts.responseFields ?? ["person.id", "person.firstName", "person.lastName"], + }, +} +``` + +**`churchQuery` does not take a `description` argument** — pass `description` +as a field on the ruleset object instead (see the shape above). `opts` only +covers `primaryEntityAlias` / `responseFields` / `groupBy`, for a query keyed +on something other than `person.id`. + +```ts +import { q, churchQuery } from "../src/config/context.js"; + +const ruleset = { + description: "Alle aktiven Personen in Mainz", + shorty: "Autom. Mitgliedschaft Alle Mainz", + importance: 0, + personIdFieldName: "person.id", + process: {}, + query: churchQuery(q.and(q.eq("ctgroup.campusId", mainzCampusId), q.eq("person.isArchived", false))), +}; +``` + +## Drift, normalization, and no-op re-applies + +ChurchTools' stored rulesets carry cosmetic noise that would otherwise show +up as permanent phantom diffs on every `ct plan`: + +- **`dterm: [label, expr]` wrappers** — a cosmetic UI label around a + subtree. Never evaluated by ChurchTools; stripped down to `expr`. +- **int/string inconsistency** — the same logical id shows up as both `1` + and `"1"` across (and even within) a single ruleset. Numeric-looking + strings inside the `query` subtree are coerced to numbers. +- **read-only fields** (`dynamicGroupUpdateStarted`, + `dynamicGroupUpdateFinished`) and the transport envelopes — `GET` returns + a single-element `[RuleSet]` array, `PUT` expects a `{ dynamicGroupRuleSet + }` wrapper — are unwrapped/dropped before comparison. + +Both the desired side (your config) and the actual side (fetched from +ChurchTools) are run through the same `normalizeRuleset` +(`src/engine/dynamic.ts`) before diffing. That means: declare a ruleset, +`ct apply` it, then `ct plan` again — no drift, only real content changes +produce a diff. Normalization is scoped to the `query` subtree only; it's +never applied to ruleset-level string fields like `description`/`shorty`, so +a numeric-looking label (e.g. a `description` of `"2024"`) is never silently +retyped to a number and corrupted on write-back. + +## Managed guard: undeclared groups stay invisible + +Dynamic-group state is only ever fetched for a group that is **both**: (a) +already under state management, and (b) opted in via a `dynamic` block in +the *current* config (`dynamicField.fold` in `src/engine/synthetic.ts`). A +managed group without a `dynamic` block is never queried for its +ruleset/status — any auto-group configuration it happens to have in +ChurchTools stays completely invisible to `ct plan` / `ct apply`, exactly +like any other resource this tool doesn't manage. + +## Applying and refreshing membership + +`ct apply` writes the ruleset (`PUT /dynamicgroups/{id}/ruleset`) and status +(`PUT /dynamicgroups/{id}/status`) as part of the same run as everything +else — but this does **not** recompute membership. Recomputation happens on +ChurchTools' own schedule for `active` groups, or must be explicitly +triggered. + +Pass `--refresh` to `ct apply` to opt in to a post-apply refresh: it POSTs +`/dynamicgroups/{id}/refresh` for each dynamic group whose `dynamic` field +actually changed in this run. + +```bash +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. + +## Full example + +See [`examples/dynamic-group.config.ts`](../examples/dynamic-group.config.ts) +for a runnable config declaring a campus and a dynamic group built with the +typed query DSL. diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..6bd7bbd --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,150 @@ +# Permissions (`ct.groupRole` / `ct.groupTypeRole`) + +Declare ChurchTools permission grants — group-role and group-type-role rights — +as code, and reconcile them idempotently with the same `ct plan` / `ct apply` +workflow used for structural resources (issue #13). + +## The two DSL functions + +```ts +export default (ct) => { + ct.groupTypeRole({ + key: "leiter_tpl", // logical key (unique across the whole config) + id: 8, // the domainId — see "domainId semantics" below + grants: [ + "churchgroup:view group", // unscoped + { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped + ], + }); + + ct.groupRole({ + key: "kids_lead_grant", + id: 2882, // the internal (group, role) domainId — see below + // "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"] }], + }); +}; +``` + +Both take the same shape, `{ key, id, grants }`: + +- **`key`** — the logical key, unique across the whole config (shared + namespace with every other resource type). +- **`id`** — the explicit **domainId** of the permission domain object. This + tool does not look it up for you; you supply it directly (see + "domainId semantics" below). +- **`grants`** — an array of `Grant`s, each either: + - a bare string, `"module:right"` — an **unscoped** grant, or + - an object `{ right: "module:right", scope: string[] }` — a **scoped** + grant, where `scope` is a list of logical keys of managed groups + (resolved to their ChurchTools `dataId`s at plan time — see "Scope + resolution" below). + +## Discovering right names — `ct get permissions-catalog` + +Right names are validated against a static, offline catalog (`module:right` → +`authId`), because the name↔authId mapping is not exposed by the ChurchTools +REST API (see `src/permissions/README.md` for how it was captured). List it: + +```bash +ct get permissions-catalog +# churchgroup:view group -> 1104 (scoped) +# churchcore:administer settings -> 1 (unscoped) +# ... +``` + +Each line shows the name, its numeric `authId`, and whether it's `scoped` +(accepts a `scope: [...]` — i.e. the catalog entry has a non-null +`scopeField`) or `unscoped`. An unknown name throws a clear error at +**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 +`{ right, scope }`; it does not resolve the name against the catalog.) + +## `domainId` semantics + +The two DSL functions manage two different ChurchTools "domain types," and +`id` means something different for each: + +- **`group_type_role`** (`ct.groupTypeRole`) — `id` is the **group type's own + id** (the same id you'd pass as `groupTypeId` on `ct.group`). It scopes the + grant to "every role holder of this group type." +- **`group_role`** (`ct.groupRole`) — `id` 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 + id. There is no lookup helper for this in the CLI; find it via the + ChurchTools permission editor / an existing `GET /permissions/group_role` + response for a group+role you already have, and hardcode it in the config + like any other domainId. + +## Scope resolution + +A scoped grant's `scope: [...]` is a list of **logical keys of groups managed +by this tool** (declared via `ct.group` or adopted into state) — not raw +ChurchTools ids. At plan time each key is resolved to that group's `dataId` +via the state file; a key that isn't a managed group throws: + +``` +Scope key "kids_area" does not resolve to a managed group. Declare/adopt it, +or use a group already under management. +``` + +This is a deliberate constraint (see `src/permissions/scope.ts`): scope +targets must be tool-visible so `ct plan` can show what a grant actually +resolves to, and so renaming/re-keying a group doesn't silently orphan a +grant's scope. + +## Domain rules (validated, throw on violation) + +- **`group_type_role` requires `authId < 10000`.** Rights with `authId >= + 10000` are not writable through this domain type in ChurchTools; declaring + one under `ct.groupTypeRole` throws at plan/evaluation time + (`src/permissions/plan.ts`, `desiredTuples`). Use `ct.groupRole` for those + rights instead. +- **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 + 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) + computes it as a tuple to **delete**, and `ct apply` issues a `DELETE` + against ChurchTools for it — you just don't author `"revoke"` yourself. + +## The baseline-tolerance model + +`ct plan` and `ct apply` reconcile only the grants **you author** on a +domainId — never the platform's own bookkeeping. `normalizeActual` +(`src/permissions/grants.ts`) filters two kinds of rows out of every actual +fetch before diffing, making both invisible to reconciliation: + +- **System baseline rows** — any row with `meta.modifiedPid === -1`. These + are ChurchTools' own self-re-adding defaults; they are never proposed for + deletion and never conflict with a desired grant. +- **Inherited rows** — any row with `isInherited: true`. These come from + hierarchy/role inheritance, not this domainId's own grant table; they are + not owned here either. + +Combined with the **managed-guard** (`buildPermissionPlan` only ever surfaces +the `domainId`s you've declared — a bulk `GET /permissions/{domainType}` +response is filtered down to just those before diffing), this means: +unmanaged domains, system defaults, and inherited grants are all completely +invisible to `ct plan`/`ct apply` — only the grant set you explicitly declare +for a domainId you explicitly declare is ever read, diffed, or written. + +## Set reconciliation + +A grant's identity for diffing purposes is `(authId, sorted dataId[], type)` +(`tupleKey` in `src/permissions/grants.ts`). `ct plan` computes +`toPut`/`toDelete` as a straightforward set difference between desired and +(filtered) actual tuples; `ct apply` issues one `PUT` per `toPut` tuple and +one `DELETE` per `toDelete` tuple against +`/permissions/{domainType}/{domainId}`. Re-running `ct apply` against an +unchanged instance diffs to empty and issues no requests — the reconciliation +is idempotent. + +## Example + +See [`examples/permissions.config.ts`](../examples/permissions.config.ts) for +a runnable `ct.groupTypeRole` declaration with one global grant and one +scoped grant. diff --git a/docs/superpowers/plans/2026-07-08-auto-groups.md b/docs/superpowers/plans/2026-07-08-auto-groups.md new file mode 100644 index 0000000..dc7874f --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-auto-groups.md @@ -0,0 +1,979 @@ +# Auto-groups (Dynamic Groups) Implementation Plan — Issue #14 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Manage ChurchTools dynamic groups (ruleset + status) as declarative code via an opt-in `dynamic` block on a group, with drift detection, idempotent apply, and a typed query builder. + +**Architecture:** A dynamic group is an ordinary group whose `settings` carry a ruleset + status — there is no separate entity and no create endpoint. We model it as an opt-in **object pseudo-field** (`dynamic`) on a group, mirroring the existing `parents` set-field: folded into the diff on both sides in `build.ts` (via a new generic synthetic-field seam), and routed at apply time to the dedicated `/dynamicgroups/{id}/ruleset` + `/status` endpoints instead of the group body. A normalizer strips cosmetic labels and coerces representational differences so drift is real, not spurious. + +**Tech Stack:** TypeScript (NodeNext ESM, `.js` import specifiers), Vitest, existing `CtClient`. + +## Global Constraints + +- Node ≥ 20; repo pins 22 via `.nvmrc`. +- ESM with NodeNext: **all relative imports use `.js` specifiers** even from `.ts` sources. +- Dependencies are minimal (`commander`, `jiti`, `openapi-fetch`, `picocolors`) — **add no new runtime dependency** without calling it out. +- People are never managed: every new write path must pass through `assertNotPeople` (`src/engine/guard.ts`). +- Managed-guard: only resources declared in config or present in the state file are ever diffed/written; everything else stays invisible. +- CT ids can be `0` — never use truthiness on an id; use explicit null/undefined checks. +- Tests mirror `tests/*.test.ts`, import from `../src/**/*.js`, use `describe/it/expect`. +- Live instance for integration/fixtures: `eqrm-dev.church.tools` (write-safe dev box, CT 3.134.1-RC1). Integration tests that write are **opt-in, gated behind `CT_LIVE=1`**, and must clean up after themselves. + +## Authoritative API shapes (from the live OpenAPI, 2026-07-08) + +- `GET /dynamicgroups` → `int[]` (ids of dynamic groups). +- `GET /dynamicgroups/{id}/ruleset` → `{ data: RuleSet }` (**bare**). +- `PUT /dynamicgroups/{id}/ruleset` → body **wrapped**: `{ dynamicGroupRuleSet: RuleSet }`. Response echoes `{ data: RuleSet }`. +- `DELETE /dynamicgroups/{id}/ruleset` → `204` (demote to normal group). +- `GET /dynamicgroups/{id}/status` → `{ dynamicGroupStatus: "active"|"inactive"|"manual"|"none" }`. +- `PUT /dynamicgroups/{id}/status` → body `{ dynamicGroupStatus: string }`. +- `POST /dynamicgroups/{id}/refresh` → `{ data: [{ groupId, created, updated, deleted, unprocessed }] }` (per-group; **never** call `/dynamicgroups/refresh`, the all-groups variant). +- `RuleSet = { description, importance, personIdFieldName, process, query, shorty? }`. + - `query` is a **ChurchQuery envelope**: `{ description, method: "ChurchQuery", params: { filter: {…JSONLogic…}, responseFields, orderBy, … } }`. The JSONLogic tree lives inside the opaque `params.filter`. + - `process` is a `{ groupOnly, groupAndQueryResult, queryResultOnly }` set, each mapping a member status (`active|none|requested|to_delete|waiting`) → `{ handleMembership: {…} }`. `handleMembership` contents are opaque in OpenAPI → captured live in Task 1. + - Read-only timestamps `dynamicGroupUpdateStarted` / `dynamicGroupUpdateFinished` may appear on the group's `settings`; they always drift and must be ignored. + +**Note on `CtClient.request`:** it unwraps a `{ data }` envelope automatically (returns `parsed.data ?? parsed`). So `client.get("/dynamicgroups/5/ruleset")` returns the bare `RuleSet`, and `client.get("/dynamicgroups/5/status")` returns `{ dynamicGroupStatus }` (no `data` wrapper on that endpoint). Verify per-endpoint in Task 1 and rely on the captured fixtures. + +--- + +## 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. + +**STATUS: COMPLETE** — captured by the controller **read-only** from the production instance `eqrm.church.tools` (CT 3.134.0), which has 68 real dynamic groups. Committed fixtures: `tests/fixtures/dynamic/ruleset-683.get.json` (string + object `dterm` labels, nested `or`/`and`/`!`, `oneof` string arrays, `isnull`, `"true and"`), `ruleset-2022.get.json` (`isnull`, `subgroups`, `campusId`, `groupMemberStatus`, mixed int/string), `ruleset-1092.get.json`, `ruleset.get.json` (canonical = 683), `status.get.json`, and `README.md` documenting the shapes. + +**The one shape correction downstream tasks depend on:** `GET /dynamicgroups/{id}/ruleset` returns a **single-element array `[RuleSet]`**, not the bare object the OpenAPI schema advertised. `normalizeRuleset` (Task 4) unwraps it. See `tests/fixtures/dynamic/README.md` for the full shape notes. + +--- + +## 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 { + client: Pick; + state: State; + desired: DesiredResource[]; + actual: Map>; + } + 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 + } + export interface SyntheticField { + 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 function isSyntheticField(field: string): boolean; + 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"; +import type { State } from "../src/state/state.js"; +import type { DesiredResource } from "../src/engine/types.js"; + +describe("synthetic-field registry", () => { + it("recognises registered pseudo-fields and nothing else", () => { + expect(isSyntheticField("parents")).toBe(true); + expect(SYNTHETIC_FIELDS.some((f) => f.field === "parents")).toBe(true); + expect(isSyntheticField("name")).toBe(false); + }); + + it("parents fold folds managed hierarchy into desired + actual", async () => { + const state: State = { + 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" }, + }, + }; + const actual = new Map>([ + ["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 out = await foldSynthetic({ client, state, desired, actual }); + expect(out.errors).toEqual([]); + expect(actual.get("child")?.parents).toEqual(["parent"]); + expect(out.desired[0]?.fields.parents).toEqual(["parent"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/synthetic.test.ts` +Expected: FAIL — cannot find module `../src/engine/synthetic.js`. + +- [ ] **Step 3: Implement `src/engine/synthetic.ts` with the parents entry** + +```ts +/** + * Generic seam for "synthetic sub-resource fields": pseudo-fields that are not + * real API columns on a resource (like `parents`), but are folded into the diff + * on both sides and routed at apply time to a dedicated endpoint. `parents` is + * the first entry; dynamic groups (and later permission grants) join the same + * registry so `build.ts`/`execute.ts` never grow per-feature branches. + */ +import type { CtClient } from "../api/ctClient.js"; +import type { State } from "../state/state.js"; +import type { DesiredResource, FieldChange } from "./types.js"; +import { applyHierarchy, parentIdsByGroupId, type HierarchyEntry } from "./hierarchy.js"; +import { assertNotPeople } from "./guard.js"; + +export interface SyntheticFoldCtx { + client: Pick; + state: State; + desired: DesiredResource[]; + actual: Map>; +} +export interface SyntheticApplyCtx { + client: Pick; + state: State; + id: number; + change: FieldChange; +} +export interface SyntheticField { + field: string; + fold(ctx: SyntheticFoldCtx): Promise<{ desired: DesiredResource[]; errors: string[] }>; + apply(ctx: SyntheticApplyCtx): Promise; +} + +function resolveId(state: State, key: string): number { + const managed = state.resources[key]; + if (!managed) throw new Error(`Cannot resolve parent "${key}" — not under management yet.`); + return managed.id; +} + +/** `parents`: many-to-many group hierarchy, reconciled per-edge. Wraps the existing hierarchy helpers. */ +const parentsField: SyntheticField = { + field: "parents", + async fold({ client, state, desired, actual }) { + const hasManagedGroups = Object.values(state.resources).some((m) => m.type === "group"); + if (!hasManagedGroups) return { desired, errors: [] }; + try { + const raw = await client.get("/groups/hierarchies"); + const parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []); + return { desired: applyHierarchy(desired, state, actual, parentIds), errors: [] }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Leave `parents` undiffed rather than fabricate "add all parents" from an empty map. + return { desired, errors: [`group hierarchies: ${message}`] }; + } + }, + async apply({ client, state, id, change }) { + const from = new Set(Array.isArray(change.from) ? (change.from as string[]) : []); + const to = new Set(Array.isArray(change.to) ? (change.to as string[]) : []); + for (const key of [...to].filter((k) => !from.has(k))) { + const path = `/groups/${id}/parents/${resolveId(state, key)}`; + assertNotPeople(path); + await client.request("PUT", path); + } + for (const key of [...from].filter((k) => !to.has(k))) { + const path = `/groups/${id}/parents/${resolveId(state, key)}`; + assertNotPeople(path); + await client.request("DELETE", path); + } + }, +}; + +export const SYNTHETIC_FIELDS: SyntheticField[] = [parentsField]; + +const BY_FIELD = new Map(SYNTHETIC_FIELDS.map((f) => [f.field, f])); +export function isSyntheticField(field: string): boolean { + return BY_FIELD.has(field); +} +export function syntheticField(field: string): SyntheticField | undefined { + return BY_FIELD.get(field); +} + +/** Run every registered fold in order, threading the (immutably) augmented desired through each. */ +export async function foldSynthetic( + ctx: SyntheticFoldCtx, +): Promise<{ desired: DesiredResource[]; errors: string[] }> { + let desired = ctx.desired; + const errors: string[] = []; + for (const f of SYNTHETIC_FIELDS) { + const res = await f.fold({ ...ctx, desired }); + desired = res.desired; + errors.push(...res.errors); + } + return { desired, errors }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run tests/synthetic.test.ts` +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 }); +``` +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"; + +function snapshotFromChanges(base: Record, changes: FieldChange[]): Record { + const snap = { ...base }; + for (const c of changes) if (!isSyntheticField(c.field)) snap[c.field] = c.to; + for (const f of Object.keys(snap)) if (isSyntheticField(f)) delete snap[f]; + return snap; +} + +async function applySyntheticFields( + client: Pick, state: State, id: number, changes: FieldChange[], +): Promise { + for (const c of changes) { + const f = syntheticField(c.field); + if (f) await f.apply({ client, state, id, change: c }); + } +} +``` +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** + +Run: `npx vitest run && npm run typecheck` +Expected: PASS — existing `tests/hierarchy.test.ts`, `tests/execute.test.ts`, `tests/build.test.ts` still green (parents behavior unchanged). + +- [ ] **Step 8: Commit** + +```bash +git add src/engine/synthetic.ts src/engine/build.ts src/engine/execute.ts tests/synthetic.test.ts +git commit -m "refactor(engine): generic synthetic-field seam; migrate parents into it" +``` + +--- + +## 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 + } + // DesiredResource gains: dynamic?: DynamicSpec; + ``` +- Consumes: `ResourceInput` (already an index-signature bag), `toDesired`. + +- [ ] **Step 1: Write the failing test** (append to `tests/context.test.ts`) + +```ts +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: {} } } }); + 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 + }); + + 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); + }); + + 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); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/context.test.ts` +Expected: FAIL — `g.dynamic` undefined; no validation. + +- [ ] **Step 3: Implement in `types.ts` and `context.ts`** + +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 }; + } +``` +- 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). + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run tests/context.test.ts && npm run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/config/context.ts src/engine/types.ts tests/context.test.ts +git commit -m "feat(dynamic): accept and validate the dynamic block in the config DSL" +``` + +--- + +## 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 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. + +- [ ] **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"; + +describe("stripCosmeticLabels", () => { + it("unwraps a dterm with a string label to its expr, recursively", () => { + const input = { and: [{ dterm: ["Campus", { "==": [{ var: "ctgroup.campusId" }, 1] }] }] }; + 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" }] }] }; + 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] }); + }); + 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"] }); + }); +}); + +describe("normalizeRuleset", () => { + it("drops read-only timestamps and the PUT envelope, and is idempotent", () => { + const withEnvelope = { dynamicGroupRuleSet: { description: "x", dynamicGroupUpdateStarted: "t", process: {}, query: {} } }; + const once = normalizeRuleset(withEnvelope); + expect(once).not.toHaveProperty("dynamicGroupUpdateStarted"); + expect(once).not.toHaveProperty("dynamicGroupRuleSet"); + expect(normalizeRuleset(once)).toEqual(once); // idempotent: read→normalize→normalize == normalize + }); + + it("unwraps the single-element array that GET returns", () => { + const arr = [{ description: "x", process: {}, query: {} }]; + expect(normalizeRuleset(arr)).toEqual(normalizeRuleset(arr[0])); + }); + + it("read-then-normalize of every live fixture is stable and label-free", () => { + 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 + } + }); +}); + +describe("normalizeDynamic", () => { + it("normalizes status + ruleset together", () => { + const out = normalizeDynamic({ status: "manual", ruleset: { description: "x", query: {}, process: {} } }); + expect(out.status).toBe("manual"); + expect(out.ruleset).toHaveProperty("description", "x"); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/dynamic.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `src/engine/dynamic.ts`** + +```ts +/** + * Normalizer for dynamic-group rulesets. CT returns rulesets with cosmetic + * labels, inconsistent int/string leaf types, read-only timestamps, and (on + * write) a `dynamicGroupRuleSet` envelope. Normalizing both the desired and + * actual sides to one canonical form is what keeps drift real, not spurious. + */ +import type { DynamicStatus } from "./types.js"; + +const READ_ONLY_KEYS = new Set(["dynamicGroupUpdateStarted", "dynamicGroupUpdateFinished"]); + +/** Recursively unwrap `dterm: [label, expr]` cosmetic wrappers to their `expr`. */ +export function stripCosmeticLabels(node: unknown): unknown { + if (Array.isArray(node)) return node.map(stripCosmeticLabels); + if (node && typeof node === "object") { + const obj = node as Record; + if (Array.isArray(obj.dterm) && obj.dterm.length === 2 && Object.keys(obj).length === 1) { + return stripCosmeticLabels(obj.dterm[1]); // keep the expression, drop the label + } + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) out[k] = stripCosmeticLabels(v); + return out; + } + return node; +} + +/** Coerce numeric-string leaves to numbers (CT is int/string-inconsistent for `var` values). */ +export function coerceScalars(node: unknown): unknown { + if (Array.isArray(node)) return node.map(coerceScalars); + if (node && typeof node === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(node as Record)) out[k] = coerceScalars(v); + return out; + } + if (typeof node === "string" && /^-?\d+$/.test(node)) return Number.parseInt(node, 10); + return node; +} + +function dropReadOnly(rule: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(rule)) if (!READ_ONLY_KEYS.has(k)) out[k] = v; + return out; +} + +/** 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] + let obj = (r ?? {}) as Record; + if (obj.dynamicGroupRuleSet && typeof obj.dynamicGroupRuleSet === "object") { + obj = obj.dynamicGroupRuleSet as Record; // unwrap the PUT envelope + } + return coerceScalars(stripCosmeticLabels(dropReadOnly(obj))) as 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) }; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run tests/dynamic.test.ts` +Expected: PASS. If the live-fixture assertion reveals an unhandled shape (e.g. a nested envelope or a non-numeric id that must stay a string), adjust `normalizeRuleset` and re-run — the fixture is the arbiter. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/dynamic.ts tests/dynamic.test.ts +git commit -m "feat(dynamic): ruleset normalizer (labels, scalars, timestamps, envelope)" +``` + +--- + +## 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"`. + +**Design:** the desired side carries `dynamic` on the group's `DesiredResource`, not in `fields`. The `dynamic` fold injects a normalized `dynamic` value into **both** the group's desired `fields` and its `actual` record (fetched from `/dynamicgroups/{id}/ruleset` + `/status`), so the generic diff produces a single `dynamic` FieldChange. Apply routes that change to the ruleset/status endpoints. + +- [ ] **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"; +import type { State } from "../src/state/state.js"; +import type { DesiredResource } from "../src/engine/types.js"; + +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 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: {} } } }, + ]; + 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: {} } }); + }); + + 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 actual = new Map>([["g", { name: "G" }]]); + const desired: DesiredResource[] = [{ type: "group", key: "g", fields: { name: "G" }, dependsOn: [] }]; + const client = { get: vi.fn() }; + await dynamicField().fold({ client, state, desired, actual }); + expect(client.get).not.toHaveBeenCalled(); + expect(actual.get("g")).not.toHaveProperty("dynamic"); + }); +}); + +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" }); + }); + + 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: {} } } }); + expect(request).toHaveBeenNthCalledWith(1, "DELETE", "/dynamicgroups/5/ruleset"); + expect(request).toHaveBeenNthCalledWith(2, "PUT", "/dynamicgroups/5/status", { dynamicGroupStatus: "none" }); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/synthetic-dynamic.test.ts` +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"; + +/** Resolve a `{ ref: "./file.json" }` ruleset to its JSON contents; pass through inline rulesets. */ +export function resolveRulesetRef(ruleset: unknown, baseDir: string = process.cwd()): unknown { + if (ruleset && typeof ruleset === "object" && typeof (ruleset as { ref?: unknown }).ref === "string") { + const p = resolve(baseDir, (ruleset as { ref: string }).ref); + return JSON.parse(readFileSync(p, "utf8")); + } + 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"; + +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)); + if (optedIn.size === 0) return { desired, errors: [] }; + const errors: string[] = []; + for (const managed of Object.values(state.resources)) { + if (managed.type !== "group" || !optedIn.has(managed.key)) continue; + const a = actual.get(managed.key); + 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`); + a.dynamic = { + status: (statusRes?.dynamicGroupStatus ?? "none") as DynamicStatus, + ruleset: normalizeRuleset(ruleset), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`dynamic ${managed.key} (#${managed.id}): ${message}`); + } + } + 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, + ); + return { desired: augmented, errors }; + }, + async apply({ client, id, change }) { + const to = change.to as { status: DynamicStatus; ruleset: Record } | undefined; + if (!to || to.status === "none") { + assertNotPeople(`/dynamicgroups/${id}/ruleset`); + await client.request("DELETE", `/dynamicgroups/${id}/ruleset`); + assertNotPeople(`/dynamicgroups/${id}/status`); + await client.request("PUT", `/dynamicgroups/${id}/status`, { dynamicGroupStatus: "none" }); + return; + } + assertNotPeople(`/dynamicgroups/${id}/ruleset`); + await client.request("PUT", `/dynamicgroups/${id}/ruleset`, { dynamicGroupRuleSet: to.ruleset }); + assertNotPeople(`/dynamicgroups/${id}/status`); + await client.request("PUT", `/dynamicgroups/${id}/status`, { dynamicGroupStatus: to.status }); + }, +}; + +export const SYNTHETIC_FIELDS: SyntheticField[] = [parentsField, dynamicField]; +``` +(Replace the previous single-entry `SYNTHETIC_FIELDS` line.) + +- [ ] **Step 5: Run to verify it passes** + +Run: `npx vitest run tests/synthetic-dynamic.test.ts && npm run typecheck` +Expected: PASS. + +- [ ] **Step 6: Run the full suite (no regressions)** + +Run: `npx vitest run` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/engine/dynamic.ts src/engine/synthetic.ts tests/synthetic-dynamic.test.ts +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"; +import { normalizeRuleset } from "../src/engine/dynamic.js"; +import { authedSession } from "../src/api/session.js"; + +const live = process.env.CT_LIVE === "1"; +const GID = Number(process.env.CT_DYNAMIC_FIXTURE_GID ?? "0"); // the group id from Task 1 + +describe.runIf(live)("dynamic round-trip (live)", () => { + it("read → normalize → write-back is a no-op (drift-free)", async () => { + const { client } = await authedSession(); + const before = await client.get>(`/dynamicgroups/${GID}/ruleset`); + const normalized = normalizeRuleset(before); + await client.request("PUT", `/dynamicgroups/${GID}/ruleset`, { dynamicGroupRuleSet: normalized }); + const after = await client.get>(`/dynamicgroups/${GID}/ruleset`); + expect(normalizeRuleset(after)).toEqual(normalized); // writing back the normalized form does not drift + }); + + it("the committed fixture matches what the instance still returns", async () => { + const { client } = await authedSession(); + const raw = JSON.parse(readFileSync("tests/fixtures/dynamic/ruleset.get.json", "utf8")); + const fixture = raw.data ?? raw; + const nowRuleset = await client.get>(`/dynamicgroups/${GID}/ruleset`); + expect(normalizeRuleset(nowRuleset)).toEqual(normalizeRuleset(fixture)); + }); +}); +``` + +- [ ] **Step 2: Run it (gated off by default → skipped)** + +Run: `npx vitest run tests/dynamic.integration.test.ts` +Expected: SKIPPED (no `CT_LIVE`). Then run live: `CT_LIVE=1 CT_DYNAMIC_FIXTURE_GID= npx vitest run tests/dynamic.integration.test.ts` → PASS. If it fails, the normalizer is missing a real-world transform; fix `normalizeRuleset` and re-run. + +- [ ] **Step 3: Add `--refresh` to `ct apply`** + +In `src/commands/apply.ts`, add a `--refresh` option. After `executePlan` succeeds, for each applied item whose changes include a `dynamic` field, `POST /dynamicgroups/{id}/refresh` (per-group only). Guard: never call `/dynamicgroups/refresh` (all-groups). Show a one-line summary of `{created, updated, deleted}` from the response. + +```ts +// after a successful executePlan(...) +if (opts.refresh) { + for (const item of plan.items) { + if (item.action === "no-op" || item.action === "delete") continue; + if (!item.changes.some((c) => c.field === "dynamic")) continue; + const id = state.resources[item.key]?.id; + if (id === undefined) continue; + const res = await client.request>( + "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`. + +- [ ] **Step 5: Run tests + typecheck** + +Run: `npx vitest run && npm run typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/commands/apply.ts tests/dynamic.integration.test.ts tests/apply-refresh.test.ts +git commit -m "feat(dynamic): end-to-end apply, opt-in --refresh, live round-trip test" +``` + +--- + +## 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 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; + }; + export function churchQuery(filter: QueryNode, opts?: { description?: string }): Record; + ``` +- Consumes: `normalizeRuleset` (so a built query normalizes identically to a read-back one). + +**Design:** `q.*` emits a JSONLogic tree; `churchQuery(filter)` wraps it in the ChurchQuery envelope `{ description, method: "ChurchQuery", params: { filter } }` that the ruleset expects. `var` values are raw ids — the config author resolves keys via the existing pattern (a campus/group id is looked up from live/state at config-build time; for Phase 2 keep resolution explicit — the author passes the numeric id or a resolver result — rather than hiding a live call inside `q`). + +- [ ] **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"; +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] }, + ] }); + }); + + it("oneof and isnull", () => { + 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] } } }); + }); + + 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)) }; + expect(normalizeRuleset(normalizeRuleset(ruleset))).toEqual(normalizeRuleset(ruleset)); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/query.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `src/config/query.ts`** + +```ts +/** + * Typed query builder for dynamic-group rulesets (Phase 2). Emits a JSONLogic + * 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 const q = { + and: (...n: QueryNode[]): QueryNode => ({ and: n }), + or: (...n: QueryNode[]): QueryNode => ({ or: n }), + not: (n: QueryNode): QueryNode => ({ "!": n }), + var: (name: string): QueryNode => ({ var: name }), + eq: (varName: string, value: unknown): QueryNode => ({ "==": [{ var: varName }, value] }), + oneof: (varName: string, values: unknown[]): QueryNode => ({ oneof: [{ var: varName }, values] }), + isnull: (varName: string): QueryNode => ({ isnull: [{ var: varName }] }), +}; + +/** Wrap a JSONLogic filter in the ChurchQuery envelope the ruleset `query` field expects. */ +export function churchQuery(filter: QueryNode, opts: { description?: string } = {}): Record { + return { description: opts.description ?? "", method: "ChurchQuery", params: { filter } }; +} +``` + +- [ ] **Step 4: Run to verify it passes; reconcile with the live fixture** + +Run: `npx vitest run tests/query.test.ts` +Expected: PASS. Then compare `churchQuery(...)` output against the real `query` in `tests/fixtures/dynamic/ruleset.get.json`: if the live envelope carries extra required `params` keys (e.g. `responseFields`, `primaryEntityAlias`), add them as defaulted options to `churchQuery` and extend the test. The fixture is the arbiter of the envelope's required shape. + +- [ ] **Step 5: Export `q`/`churchQuery` from the config surface** + +Re-export from wherever the config DSL is imported (e.g. add to `src/config/context.ts` exports or a barrel the config file imports), so a `ct.config.ts` can `import { q, churchQuery } from "ct-cli/query"` (match the package's existing export convention — check `package.json` `exports`/`bin`; if none, document the import path in Task 8). + +- [ ] **Step 6: Commit** + +```bash +git add src/config/query.ts src/config/context.ts tests/query.test.ts +git commit -m "feat(dynamic): typed query builder + ChurchQuery envelope (Phase 2)" +``` + +--- + +## 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) + +- [ ] **Step 1: Write `docs/dynamic-groups.md`** + +Cover: the `dynamic` block shape; `status` semantics (`active`/`manual`/`inactive`/`none`=demote); the three ways to supply a ruleset (inline object, `{ ref: "./rulesets/x.json" }`, typed `churchQuery(q.and(...))`); that membership is computed on refresh and `--refresh` is opt-in/per-group; the normalizer's guarantees (no cosmetic drift); and the managed-guard (an undeclared ruleset stays invisible). + +- [ ] **Step 2: Write `examples/dynamic-group.config.ts`** + +```ts +import { q, churchQuery } from "../src/config/query.js"; // or the published import path +export default (ct: any) => { + ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); + ct.group({ + 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))), + }, + }, + }); +}; +``` + +- [ ] **Step 3: Add the README subsection** + +Add a short "Auto-groups" block under the usage section pointing to `docs/dynamic-groups.md`, and flip the Phase 5 status bullet to note dynamic-groups landed. + +- [ ] **Step 4: Verify the example loads** + +Run: `CT_CONFIG=examples/dynamic-group.config.ts npx tsx src/index.ts plan --help 2>&1 | head` (or a dry `plan` against the live instance if logged in). Expected: no config-load/validation error from the example. + +- [ ] **Step 5: Commit** + +```bash +git add README.md docs/dynamic-groups.md examples/dynamic-group.config.ts +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). ✓ +- `status: none` demotes; optional refresh materializes → Task 5 (none branch) + Task 6 (`--refresh`). ✓ +- Round-trip test against a real dynamic group (read → normalize → write-back = no-op) → Task 6. ✓ +- Typed query DSL (Phase 2) with name→ID resolution → Task 7 (builder + explicit id resolution; keys resolved at config-build time). ✓ +- Opaque round-trip + `{ ref }` file reference → Task 3 (accepts any object) + Task 5 (`resolveRulesetRef`). ✓ +- Managed-guard / per-group refresh / no refresh-all → Tasks 5 (opted-in only) + 6 (per-group gate). ✓ + +**Placeholder scan:** the only deferred specifics are the `dterm`/`params.filter` internals, which Task 1 captures live and Tasks 4/7 validate against the committed fixture — real code is provided for every known transform; no `TODO`/`TBD`/"handle edge cases". The `apply.ts` refresh snippet uses the file's existing logger (named `info` as a stand-in — the implementer substitutes the real helper, which exists). + +**Type consistency:** `SyntheticField`/`SyntheticFoldCtx`/`SyntheticApplyCtx` (Task 2) are consumed unchanged in Task 5; `DynamicSpec`/`DynamicStatus` (Task 3) flow into `normalizeDynamic` (Task 4) and the `dynamic` fold/apply (Task 5); `normalizeRuleset` (Task 4) is reused by Tasks 6 and 7; `churchQuery`/`q` (Task 7) produce input that Task 4's normalizer accepts. No signature drift. diff --git a/docs/superpowers/plans/2026-07-08-blueprints.md b/docs/superpowers/plans/2026-07-08-blueprints.md new file mode 100644 index 0000000..6eba4c3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-blueprints.md @@ -0,0 +1,202 @@ +# Blueprints Implementation Plan — Issue #7 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Prove and document that the config DSL supports parametrized, reusable campus blueprints — a blueprint function instantiated across ≥2 campuses produces a correct, dependency-ordered plan, and composes with auto-groups (#14) and permission grants (#13). + +**Architecture:** No new engine machinery. The DSL (`(ct) => { ... }`, `src/config/context.ts`) is already a plain function receiving an injected context, so a blueprint is just a function `(campus: string) => { ct.group(...); ... }` called in a loop over campuses. This issue delivers a runnable example, a test that locks the ordered-plan guarantee, and docs. **YAGNI: add no abstraction** unless a helper obviously earns its place. + +**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. +- Blueprints use ONLY the existing DSL surface (`ct.campus/group/groupType/.../groupRole/groupTypeRole`, `dynamic`, `parents`) — this issue must not require new DSL functions. +- 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. + +--- + +## 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** + +```ts +/** + * Parametrized campus blueprint: define the Kids area once, instantiate per campus. + * Demonstrates that the config DSL needs no special "blueprint" machinery — a + * blueprint is a plain function over the injected context, called in a loop. + */ +import type { ConfigContext } from "../src/config/context.js"; +import { q, churchQuery } from "../src/config/context.js"; + +const CAMPUSES = ["mainz", "berlin"] as const; + +/** One campus's Kids area: a lead group with three ministry teams under it, plus a dynamic "all members" group. */ +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) { + ct.group({ + key: `${campus}_kids_${suffix}`, + name: `${campus} · Kids ${label}`, + groupTypeId: 2, + parents: [lead], // managed hierarchy: team sits under the campus lead group + }); + } + // A dynamic auto-group (#14) composed inside the blueprint. + ct.group({ + key: `${campus}_kids_all`, + name: `${campus} · Kids (alle)`, + groupTypeId: 2, + parents: [lead], + dynamic: { + status: "manual", + ruleset: { + description: `Alle aktiven Kids-Mitarbeiter ${campus}`, + importance: 0, + personIdFieldName: "person.id", + process: {}, + query: churchQuery(q.eq("person.isArchived", false)), + }, + }, + }); +} + +export default (ct: ConfigContext): void => { + for (const campus of CAMPUSES) { + ct.campus({ key: campus, name: `Campus ${campus}`, shorty: campus.slice(0, 3).toUpperCase() }); + 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"] }); +}; +``` + +- [ ] **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** + +```bash +git add examples/campus-blueprint.config.ts +git commit -m "feat(blueprints): example parametrized campus blueprint (kids area × 2 campuses)" +``` + +--- + +## 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** + +```ts +import { describe, it, expect } from "vitest"; +import { evaluateConfig, type ConfigContext } from "../src/config/context.js"; +import { orderKeys } from "../src/engine/graph.js"; + +// A blueprint instantiated across two campuses — the core #7 guarantee. +const blueprint = (ct: ConfigContext): void => { + for (const c of ["mainz", "berlin"]) { + ct.campus({ key: c, name: `Campus ${c}`, shorty: c.slice(0, 3) }); + ct.group({ key: `${c}_lead`, name: `${c} lead`, groupTypeId: 2, parents: [] }); + ct.group({ key: `${c}_team`, name: `${c} team`, groupTypeId: 2, parents: [`${c}_lead`] }); + } +}; + +describe("campus blueprint", () => { + it("instantiates the full structure for every campus", async () => { + const { resources } = await evaluateConfig(blueprint); + const keys = resources.map((r) => r.key).sort(); + expect(keys).toEqual(["berlin", "berlin_lead", "berlin_team", "mainz", "mainz_lead", "mainz_team"]); + }); + + it("produces a correctly ordered plan: campuses before groups, parents before children", async () => { + const { resources } = await evaluateConfig(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}_lead`)).toBeLessThan(pos(`${c}_team`)); // parent before child (intra-tier dependency) + } + }); + + it("rejects a blueprint whose managed parent is undeclared (typo guard)", async () => { + const broken = (ct: ConfigContext) => { + ct.group({ key: "g", name: "g", groupTypeId: 2, parents: ["missing"] }); + }; + await expect(evaluateConfig(broken)).rejects.toThrow(/not declared/i); + }); +}); +``` + +- [ ] **Step 2: Run to verify it passes** + +Run: `npx vitest run tests/blueprint.test.ts && npm run typecheck && npm run lint` +Expected: PASS (these exercise existing, merged behavior; the test documents/locks the blueprint guarantee). + +- [ ] **Step 3: Commit** + +```bash +git add tests/blueprint.test.ts +git commit -m "test(blueprints): lock ordered-plan guarantee for a blueprint across 2 campuses" +``` + +--- + +## Task 3: Docs + README + +**Files:** +- Create: `docs/blueprints.md` +- Modify: `README.md` + +- [ ] **Step 1: Write `docs/blueprints.md`** + +Cover: a blueprint is a plain function over the injected `ConfigContext` (no special machinery); the loop-over-campuses pattern; how `parents` scopes hierarchy per campus with `${campus}_`-prefixed keys; composing an auto-group (`dynamic`) and a permission grant (`groupTypeRole`) inside a blueprint; that the dependency graph orders campuses → groups → hierarchy automatically; and the managed-parent typo guard. Link to `examples/campus-blueprint.config.ts`. + +- [ ] **Step 2: Update `README.md`** + +Add a short "Blueprints" subsection under usage pointing to `docs/blueprints.md`, and flip the Phase 5 status bullet to note blueprints + permissions + auto-groups all landed (Phase 5 complete). + +- [ ] **Step 3: Commit** + +```bash +git add docs/blueprints.md README.md +git commit -m "docs(blueprints): parametrized campus blueprint guide; Phase 5 complete" +``` + +--- + +## 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.) +- One blueprint across ≥2 campuses → correct ordered plan → Task 2 (ordering assertions). ✓ +- Apply builds the full structure for a new campus → the ordered plan + #14/#13 apply mechanisms already cover this; not live-tested here (production login) — noted in docs. ✓ (⚠️ controller: a live apply-a-new-campus check would need a dev instance.) + +**Placeholder scan:** none — all steps carry real code/commands. The example's `groupTypeId`/`id` values are illustrative (documented as such, mirroring the #14/#13 examples). + +**Type consistency:** `ConfigContext`, `evaluateConfig`, `q`/`churchQuery` (from `src/config/context.js`) and `orderKeys` (from `src/engine/graph.js`) are all existing, merged exports used with their current signatures. diff --git a/docs/superpowers/plans/2026-07-08-permissions.md b/docs/superpowers/plans/2026-07-08-permissions.md new file mode 100644 index 0000000..dcac188 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-permissions.md @@ -0,0 +1,731 @@ +# Permissions as Code Implementation Plan — Issue #13 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Manage ChurchTools permission grants on `group_role` and `group_type_role` domain objects as declarative code — human-readable `module:right` names, idempotent set reconciliation via PUT/DELETE. + +**Architecture:** Permissions are a **standalone reconciliation subsystem** (`src/permissions/`), not folded into the group-body engine: a grant is a tuple `(domainType, domainId, authId, dataId[])` reconciled as a set (no create/update split, no POST). A grant-bearing declaration (`ct.groupRole`/`ct.groupTypeRole`) names an existing domain object by explicit `id` and a `grants` set. The subsystem resolves names→authId via a shipped static catalog, fetches actual grants per domainType (excluding the self-re-adding system baseline), diffs as sets, and writes PUT (grant/revoke) / DELETE per tuple. It reuses the state file, `assertNotPeople`, and the plan/apply commands. + +**Tech Stack:** TypeScript (NodeNext ESM, `.js` specifiers), Vitest, existing `CtClient`. + +## Global Constraints + +- Node ≥ 20; ESM NodeNext — **all relative imports use `.js` specifiers**. +- Add no new runtime dependency. +- People are never managed: every write path passes through `assertNotPeople` (`src/engine/guard.ts`). +- Managed-guard: only the domain objects explicitly declared (or in state) are reconciled; every other permission assignment in ChurchTools is invisible. +- CT ids can be `0` — never truthiness on an id. +- Tests mirror `tests/*.test.ts` (Vitest, import from `../src/**/*.js`). +- Live instance is currently **production** (`eqrm.church.tools`) — investigation is read-only; any write/round-trip test is gated behind `CT_LIVE=1` and must target a **dev** instance, never run against production. + +## Authoritative findings (live, 2026-07-08) + +- **Catalog** (`src/permissions/catalog.json`, already committed): 187 rights, `"module:right" → { authId, scopeField, revocable, desc }`. `scopeField` non-null ⇒ the right takes a `dataId[]` scope (71 such rights, e.g. `churchgroup:view group` → `authId 1104`, `scopeField "cdb_gruppe"`). Not available via REST — captured from the `POST /index.php?q=churchauth/ajax func=getMasterData` `auth_table`. +- **Raw assignment / GET** `GET /permissions/{domainType}` (and `/{domainType}/{domainId}`) → `{ data: Permission[] }`, `Permission = { domainType, domainId, authId, dataId: int|null, isInherited, type: "grant"|"revoke", reason, meta: { modifiedDate, modifiedPid } }`. **`dataId` is a scalar `int|null` on read.** +- **Write** `PUT /permissions/{domainType}/{domainId}` body = single `PermissionRequest { authId, dataId?: int[], isInherited?: bool, type?: "grant"|"revoke", reason?: string }` — **`dataId` is an array on write.** `DELETE` same path, same body shape (tuple in body). Both return no content. +- **Domain rules** (from the `PermissionRequest` schema + live data): `authId < 10000` required for `status`/`person`/`group_type_role` writes (authId `10127` unsupported); `type:"revoke"` only for `group_role`; `isInherited:true` only for `group_role`/`group_type_role`. +- **Baseline:** rows with `meta.modifiedPid === -1` (system-authored) and `isInherited === true` re-add themselves — exclude them from the diff set so reconciliation doesn't fight the system. +- **domainId:** `group_role` domainId is an internal (group,role)-link id (range 51–43623, no clean enumeration endpoint) → the DSL takes it **explicitly**. `group_type_role` domainId is a template id (small). + +**Note on `CtClient.request`:** unwraps `{ data }` automatically, so `client.get("/permissions/group_role")` returns `Permission[]` directly. + +--- + +## 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 + ``` +- 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"; + +describe("permission catalog", () => { + it("resolves a known global right to its authId", () => { + const e = resolveAuthId("churchgroup:view"); + expect(e.authId).toBe(1101); + expect(e.scopeField).toBeNull(); + }); + it("resolves a scoped right and exposes its scopeField", () => { + const e = resolveAuthId("churchgroup:view group"); + expect(e.authId).toBe(1104); + 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); + }); + it("loads the whole catalog (187 rights)", () => { + expect(Object.keys(loadCatalog()).length).toBeGreaterThanOrEqual(180); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/permission-catalog.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `src/permissions/catalog.ts`** + +```ts +/** + * The permission catalog: the static name→authId bridge (see src/permissions/README.md). + * The catalog is reference data captured from the instance's churchauth masterdata; it is + * NOT available via the REST API, so it is shipped as JSON and read from disk once. + */ +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 } + +let cache: Record | null = null; + +export function loadCatalog(): Record { + if (cache) return cache; + const here = dirname(fileURLToPath(import.meta.url)); + cache = JSON.parse(readFileSync(join(here, "catalog.json"), "utf8")) as Record; + return cache; +} + +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 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** + +Run: `npx vitest run tests/permission-catalog.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/permissions/catalog.ts tests/permission-catalog.test.ts +git commit -m "feat(permissions): catalog resolver (name→authId, scope, revocable)" +``` + +--- + +## 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 + ``` +- 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"; + +describe("normalizeActual", () => { + it("coerces scalar dataId to a sorted array and drops baseline + inherited rows", () => { + const rows = [ + { 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 + ]; + expect(normalizeActual(rows)).toEqual([ + { authId: 1104, dataId: [3], type: "grant" }, + { authId: 1101, dataId: [], type: "grant" }, + ]); + }); +}); + +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 + ]; + const actual = [ + { authId: 1104, dataId: [3, 7], type: "grant" as const }, // same tuple, different order + { 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" })]); + expect(d.toDelete.map(tupleKey)).toEqual([tupleKey({ authId: 2000, dataId: [], type: "grant" })]); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/permission-grants.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `src/permissions/grants.ts`** + +```ts +/** + * Grant tuples and set reconciliation. A grant's identity is (authId, sorted dataId, type). + * Actuals exclude the self-re-adding system baseline (modifiedPid === -1) and inherited rows, + * so reconciliation owns only user-authored grants and never fights the platform. + */ +export type DomainType = "group_role" | "group_type_role"; + +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 }; +} + +export function tupleKey(t: { authId: number; dataId: number[]; type: string }): string { + return `${t.type}:${t.authId}:${[...t.dataId].sort((a, b) => a - b).join(",")}`; +} + +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 + 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 function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): GrantDiff { + const desiredKeys = new Map(desired.map((t) => [tupleKey(t), t])); + const actualKeys = new Map(actual.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 }; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run tests/permission-grants.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/permissions/grants.ts tests/permission-grants.test.ts +git commit -m "feat(permissions): grant-tuple model + set reconciliation (baseline-tolerant)" +``` + +--- + +## 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[] } + // ConfigContext gains: groupRole(input), groupTypeRole(input) + // evaluateConfig now returns BOTH resource declarations and permission declarations. + ``` +- Consumes: `DomainType` (Task 2). + +**Design:** permission declarations live in a **separate list** from `DesiredResource[]` (they reconcile through their own subsystem, not the group engine). `evaluateConfig` returns `{ resources, permissions }`; update `loadConfig` and its callers to thread `permissions` (default `[]` when a config declares none — backward compatible). + +- [ ] **Step 1: Write the failing test** (append to `tests/context.test.ts`) + +```ts +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.groupRole({ key: "kids_lead", id: 2882, grants: ["churchgroup:edit group members"] }); + }; + const { permissions } = await evaluateConfig(mod); // evaluateConfig now returns {resources, permissions} + expect(permissions).toHaveLength(2); + expect(permissions[0]).toMatchObject({ key: "leiter_tpl", domainType: "group_type_role", domainId: 8 }); + 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); + }); +}); +``` +(Adjust the existing `evaluateConfig` import/usages in this file to the new `{ resources, permissions }` return.) + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/context.test.ts` +Expected: FAIL — `groupRole`/`groupTypeRole` not defined; `evaluateConfig` returns an array, not `{resources, permissions}`. + +- [ ] **Step 3: Implement** + +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".`); + if (typeof input.id !== "number") throw new Error(`${domainType} "${input.key}": "id" must be a number (the domainId).`); + if (!Array.isArray(input.grants)) throw new Error(`${domainType} "${input.key}": "grants" must be an array.`); + for (const g of input.grants) { + const right = typeof g === "string" ? g : g?.right; + if (typeof right !== "string" || !right.includes(":")) throw new Error(`${domainType} "${input.key}": each grant must be a "module:right" string or { right, scope }.`); + if (typeof g === "object" && !Array.isArray(g.scope)) throw new Error(`${domainType} "${input.key}": scoped grant needs "scope": string[].`); + } + if (seen.has(input.key)) throw new Error(`Duplicate logical key "${input.key}" in config.`); + seen.add(input.key); + permissions.push({ key: input.key, domainType, domainId: input.id, grants: input.grants }); + }; + // ... + 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. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run tests/context.test.ts tests/load.test.ts && npm run typecheck` +Expected: PASS (fix any load.test.ts expectations for the new return shape). + +- [ ] **Step 5: Commit** + +```bash +git add src/config/context.ts src/config/load.ts src/permissions/types.ts src/commands/plan.ts src/commands/apply.ts tests/ +git commit -m "feat(permissions): groupRole/groupTypeRole DSL + threaded permission declarations" +``` + +--- + +## 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 + ``` +- Consumes: `State` (`src/state/state.js`). + +**Design (MVP):** scoped rights whose `scopeField` is `cdb_gruppe` take group `dataId`s. Resolve each scope key as a managed **group** logical key → its CT id (from state). Unknown key → clear error. (Other scope fields — `cdb_bereich`, `cdb_station` — are out of MVP scope; a scope key that doesn't resolve to a managed group errors with guidance. Extend later.) + +- [ ] **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" }, +}}; + +describe("resolveScope", () => { + it("maps managed group keys to sorted ids", () => { + expect(resolveScope(["other", "kids_area"], state)).toEqual([7, 42]); + }); + it("throws for a key that is not a managed group", () => { + expect(() => resolveScope(["nope"], state)).toThrow(/scope key "nope"/i); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/permission-scope.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `src/permissions/scope.ts`** + +```ts +import type { State } from "../state/state.js"; + +/** Resolve scope logical keys to sorted group dataIds. MVP: scope keys must be managed groups. */ +export function resolveScope(scopeKeys: string[], state: State): number[] { + const ids: 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.`); + } + ids.push(m.id); + } + return ids.sort((a, b) => a - b); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run tests/permission-scope.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/permissions/scope.ts tests/permission-scope.test.ts +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 async function buildPermissionPlan( + 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). + +**Managed-guard / fetch:** fetch actuals once per distinct `domainType` via bulk `GET /permissions/{domainType}`, then filter to the declared `domainId`s. A fetch failure records a `fetchError` (mirrors `build.ts`). + +- [ ] **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" }, +}}; + +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); + 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 + }); +}); + +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"] }]); + expect(fetchErrors).toEqual([]); + expect(items[0].diff.toPut).toEqual([]); // 1104 unscoped already present + expect(items[0].diff.toDelete).toEqual([]); // domainId 99 is unmanaged → invisible + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/permission-plan.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `src/permissions/plan.ts`** + +```ts +import type { CtClient } from "../api/ctClient.js"; +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 type { DesiredPermission } from "./types.js"; + +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.`); + } + const dataId = typeof g === "string" ? [] : resolveScope(g.scope, state); + return { authId: entry.authId, dataId, type: "grant" as const }; + }); +} + +export async function buildPermissionPlan( + client: Pick, state: State, permissions: DesiredPermission[], +): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[] }> { + const items: PermissionPlanItem[] = []; + const fetchErrors: string[] = []; + // one bulk fetch per distinct domainType + const byType = new Map(); + for (const dt of new Set(permissions.map((p) => p.domainType))) { + try { + byType.set(dt, await client.get(`/permissions/${dt}`)); + } catch (err) { + const message = err instanceof CtApiError ? `${err.status}` : (err as Error).message; + fetchErrors.push(`permissions ${dt}: ${message}`); + byType.set(dt, null); + } + } + for (const p of permissions) { + 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) }); + } + return { items, fetchErrors }; +} +``` +(Add `domainId` to `RawPermission` in `grants.ts` — `domainId: number` — so the filter typechecks.) + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run tests/permission-plan.test.ts && npm run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/permissions/plan.ts src/permissions/grants.ts tests/permission-plan.test.ts +git commit -m "feat(permissions): build permission plan (tuples, bulk actuals, managed-guard, validation)" +``` + +--- + +## 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 function renderPermissionPlan(items: PermissionPlanItem[]): string; + ``` +- Consumes: `PermissionPlanItem` (T5), `assertNotPeople`. + +**Apply:** per item, `PUT /permissions/{domainType}/{domainId}` with `{ authId, dataId, type }` for each `toPut` (omit `dataId` when empty → unscoped), `DELETE` same path with `{ authId, dataId }` for each `toDelete`. Guard every path with `assertNotPeople`. Idempotent (re-run diffs to empty). + +**Command wiring:** in `plan.ts`/`apply.ts`, after the resource plan, `buildPermissionPlan(client, state, permissions)`; render its section; on apply (after resource `executePlan` succeeds) call `applyPermissionPlan`. Fold permission `fetchErrors` into the same incomplete-plan handling. + +- [ ] **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"; + +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); + 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" }); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run tests/permission-apply.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `src/permissions/apply.ts` and `render.ts`** + +```ts +// src/permissions/apply.ts +import type { CtClient } from "../api/ctClient.js"; +import { assertNotPeople } from "../engine/guard.js"; +import type { PermissionPlanItem } from "./plan.js"; +import type { GrantTuple } from "./grants.js"; + +function body(t: GrantTuple): Record { + const b: Record = { authId: t.authId, type: t.type }; + if (t.dataId.length) b.dataId = t.dataId; // omit when unscoped + return b; +} + +export async function applyPermissionPlan( + items: PermissionPlanItem[], client: Pick, +): Promise<{ granted: number; deleted: number }> { + 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++; } + } + 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** + +Run: `npx vitest run tests/permission-apply.test.ts` +Expected: PASS. + +- [ ] **Step 5: Wire into commands + `ct get permissions-catalog`** + +- `plan.ts`: after the resource plan render, build+render the permission plan; fold its `fetchErrors` into the INCOMPLETE handling. +- `apply.ts`: after resource `executePlan` succeeds (and before/with the `--refresh` block), `applyPermissionPlan(permItems, client)` and report `granted`/`deleted`. Include permission changes in the confirmation count. +- `get.ts`: add a `permissions-catalog` subcommand printing `loadCatalog()` as sorted `name → authId (scoped?)` lines (helps users discover valid names). + +- [ ] **Step 6: Run tests + typecheck + lint** + +Run: `npx vitest run && npm run typecheck && npm run lint` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/permissions/apply.ts src/permissions/render.ts src/commands/ tests/permission-apply.test.ts +git commit -m "feat(permissions): apply grants (PUT/DELETE) + plan/apply wiring + catalog helper" +``` + +--- + +## 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. + +- [ ] **Step 2: Confirm it SKIPS by default** + +Run: `npx vitest run tests/permission.integration.test.ts` +Expected: SKIPPED (no `CT_LIVE`). **Do not run it against the current production login.** + +- [ ] **Step 3: Commit** + +```bash +git add tests/permission.integration.test.ts +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` +- Modify: `README.md` (Permissions subsection; flip status bullet) + +- [ ] **Step 1: Ensure the catalog ships in the build** + +Confirm how `tsup` handles `src/permissions/catalog.json`. If it is not emitted next to `dist/permissions/catalog.js`, either bundle it via `import catalog from "./catalog.json" with { type: "json" }` in `catalog.ts` (and drop the `readFileSync`), or add a build copy step. Verify: `npm run build && node dist/index.js get permissions-catalog | head`. + +- [ ] **Step 2: Docs + example** + +`docs/permissions.md`: the two DSL functions, `module:right` names + `ct get permissions-catalog`, scoped `{right, scope}` grants, the baseline-tolerance model (system + inherited grants are invisible), domain rules, and that `group_role` domainIds are taken explicitly. `examples/permissions.config.ts`: a runnable example declaring a `groupTypeRole` with one global + one scoped grant. README: add the subsection, flip the status bullet. + +- [ ] **Step 3: Verify example loads + full suite** + +Run: `npm run build && node dist/index.js get permissions-catalog | head` and load the example config (config-load only — **no live writes to production**). `npx vitest run && npm run typecheck && npm run lint`. + +- [ ] **Step 4: Commit** + +```bash +git add package.json src/permissions/catalog.ts docs/permissions.md examples/permissions.config.ts README.md +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). ✓ +- Domain-rule validation + dataId/name normalization → Tasks 2 (dataId scalar→array, baseline exclusion), 5 (authId<10000, scope), 7 (live checks). ✓ +- Baseline tolerance (modifiedPid −1, inherited) → Task 2. ✓ +- Scope keys → dataId → Task 4. ✓ + +**Placeholder scan:** the only deferred item is the exact `tsup` catalog-shipping mechanism (Task 8 Step 1), which is a concrete verify-and-fix with two named options — not a vague TODO. `render.ts` formatting is described, not code-complete; the implementer follows `engine/render.ts`'s existing style. + +**Type consistency:** `DomainType`/`GrantTuple`/`GrantDiff`/`RawPermission` (T2) flow into `plan.ts` (T5) and `apply.ts` (T6); `CatalogEntry`/`resolveAuthId` (T1) into T5; `DesiredPermission`/`Grant` (T3) into T5; `resolveScope` (T4) into T5. `evaluateConfig`/`loadConfig` return-shape change (T3) is threaded into both commands in the same task. 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 new file mode 100644 index 0000000..5b8adc6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.md @@ -0,0 +1,184 @@ +# Phase 5 — Permissions, Auto-groups, and Blueprints (#13, #14, #7) + +**Status:** approved design, ready for implementation plan +**Date:** 2026-07-08 +**Branch:** `feat/phase-5-permissions-autogroups-blueprints` +**Issues:** [#13 permissions-as-code](https://github.com/eqrm/ct-cli/issues/13), [#14 auto-groups](https://github.com/eqrm/ct-cli/issues/14), [#7 blueprints](https://github.com/eqrm/ct-cli/issues/7) + +## Summary + +Three related features, one branch, implemented in dependency order **#14 → #13 → #7**: + +1. **Auto-groups (#14)** — manage dynamic groups (ruleset + status) as code, with both an opaque round-trip path and a typed query DSL. +2. **Permissions (#13)** — manage `group_role` / `group_type_role` permission grants as code. +3. **Blueprints (#7)** — prove and document parametrized campus blueprints; add examples/tests. Mostly non-code; the DSL already supports it. + +All three extend one existing seam rather than introducing a parallel engine. + +## Live-API findings (validated 2026-07-08 against `eqrm-dev.church.tools`, CT 3.134.1-RC1) + +The instance is a **write-safe dev box**, so round-trip integration tests may genuinely PUT/DELETE. Superadmin token present. + +- **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 } } + ``` + - `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. +- **Effective/internal endpoints** (`/permissions/internal/{persons,groups}/{id}`) return `module → { "+right": value }` with **no `authId`** — they are read-only computed views, **not** the name↔authId bridge. The bridge is the legacy churchcore auth masterdata (`masterData.auth`), to be resolved as implementation step 1. +- **No dynamic groups exist** on the instance (`GET /dynamicgroups` → `[]`). A fixture dynamic group must be created on eqrm-dev during implementation to capture the `query.params.filter` JSONLogic internals and `handleMembership` contents (which OpenAPI leaves opaque) and seed the round-trip test. + +### Authoritative OpenAPI shapes (pulled 2026-07-08 from `/system/runtime/swagger/openapi.json`) + +- **`PermissionRequest`** (PUT/DELETE body, single object, not an array): `{ authId: int (required), dataId?: int[], isInherited?: bool, type?: "grant"|"revoke", reason?: string }`. `GET /permissions/{domainType}[/{domainId}]` → `{ data: Permission[] }` where `Permission.dataId` is a **single `int|null`**. ⇒ **GET returns `dataId` scalar, PUT sends `dataId` array** — normalize on read. +- The `authId < 10000` rule for `status`/`person`/`group_type_role` **is documented on the write body** (`PermissionRequest.authId`), and `authId 10127` is explicitly unsupported. The live `group_type_role` row with `authId 10101` is a `modifiedPid:-1` system baseline — excluded by the baseline rule below, so the write-side constraint and the read-side data do not conflict. `type:"revoke"` and `isInherited:true` are documented as valid only for the two role domains (revoke: `group_role` only). +- **Ruleset:** `GET /dynamicgroups/{id}/ruleset` → `{ data: DynamicGroupRule }` (bare). `PUT` body is **wrapped**: `{ dynamicGroupRuleSet: DynamicGroupRule }`. `DELETE` → 204. `DynamicGroupRule = { description, importance, personIdFieldName, process, query, shorty? }`. `query` is a **ChurchQuery** envelope `{ description, method: "ChurchQuery", params: { filter: {…JSONLogic…}, responseFields, … } }` — the JSONLogic tree lives in the opaque `params.filter`. ⇒ the **GET-vs-PUT difference is the `dynamicGroupRuleSet` envelope**, resolved by the normalizer. +- **Status:** `GET /dynamicgroups/{id}/status` → `{ dynamicGroupStatus: "active"|"inactive"|"manual"|"none" }`. `PUT` body `{ dynamicGroupStatus: string }`. +- **Refresh:** `POST /dynamicgroups/{id}/refresh` → `{ data: [{ groupId, created, updated, deleted, unprocessed }] }`. + +## 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`). + +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` | + +### The refactor + +Generalize the one-off `applyHierarchy` fold into a small **synthetic-field registry**. Each entry: + +- `fold(desired, state, actual)` — inject the normalized pseudo-field onto both the desired and actual sides for opted-in resources (mirrors `applyHierarchy`), so `computePlan`/`diffFields` handle it generically with zero changes to the diff core. +- `apply(client, state, item, changes)` — route the write to the dedicated endpoint(s) instead of the resource body (mirrors `applyParentEdges`). + +`parents` becomes the first registry entry; `grants` and `dynamic` join it. This keeps `build.ts` and `execute.ts` from accreting three bespoke branches and leaves the generic diff/plan/order core (`plan.ts`, `graph.ts`, `types.ts`) untouched except for the tiers already reserved. + +`graph.ts` already reserves the tiers: `group-hierarchy: 2`, `group-role: 3`, `permission: 4`, `dynamic-group: 5`. + +The `assertNotPeople` guard extends to every new write path. + +## Feature 1 — Auto-groups (#14) + +### DSL + +```ts +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)), + // …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`. +- Actual read from the dedicated endpoints (`/dynamicgroups/{id}/ruleset` + `/status`), not the group body. + +### Normalizer (the crux) + +Runs on **both** desired and actual before diffing, so cosmetic and representational differences never produce false diffs: + +- Strip cosmetic `dterm: [label, expr]` label / i18n-key subtrees (label is not evaluated by CT). +- Coerce int↔string consistently (CT is inconsistent on `var` value types). +- Ignore read-only timestamps `dynamicGroupUpdateStarted` / `dynamicGroupUpdateFinished`. +- Reconcile the GET-vs-PUT `process` nesting difference (shapes differ; cannot blindly write back what was read) — exact transform pinned against the live fixture. + +Both the typed builder and an opaque blob compile to the **same normalized JSONLogic**, so they diff and round-trip identically. + +### Typed query DSL (Phase 2 — opted in) + +`q.and / or / not / eq / oneof / isnull / var` helpers emitting the JSONLogic tree, with key→id resolution for `var` values reusing the shared campus/group/role resolver (§ Shared resolver). Opaque blobs and `{ ref }` file references remain valid for predicates the typed layer doesn't cover yet. + +### Write & ordering + +- Group exists first (tier 5 applies after group tier 1), then `PUT /dynamicgroups/{id}/ruleset` → `PUT /dynamicgroups/{id}/status`. +- Demote: `status: "none"` (or `DELETE …/ruleset`) reverts to a normal group. +- **Refresh: opt-in, off by default.** Optional post-apply `POST /dynamicgroups/{id}/refresh` (per-group only — never `/dynamicgroups/refresh` all) behind a CLI flag / config opt-in. Rationale: contain blast radius; materialization is a separate concern from reconciliation. **[Approved.]** +- Managed-guard: a ruleset the tool didn't author is unmanaged unless declared. + +## Feature 2 — Permissions (#13) + +### 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[] +``` + +- `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[]`). +- `group_role` (per-group override) and `group_type_role` (template across a group type) are distinguished explicitly to the user via the two DSL functions. + +### Name → authId resolution + +DSL vocabulary is human-readable `module:right` names. Resolution to numeric `authId` uses the churchcore auth masterdata bridge (`masterData.auth`), **resolved as implementation step 1** — the REST catalog and effective endpoints expose names but not the name↔authId map. Scope keys resolve to `dataId[]` via the shared resolver. + +### Diff / write + +- **Set reconciliation** keyed by `(domainType, domainId, authId, sorted dataId)`. +- Actuals fetched via bulk `GET /permissions/{domainType}`, filtered to managed `domainId`s; `dataId` normalized (sorted, `null` ↔ `[]`). +- Write: `PUT` (grant) / `DELETE` (remove) per tuple; idempotent, no create/update split. +- **Baseline tolerance [approved]:** reconcile only **user-authored** grants — exclude `meta.modifiedPid: -1` (system) and `isInherited: true` rows from the diff set. Gives strict "as-code" ownership of declared grants without fighting the self-re-adding system baseline. (Rejected alternative: purely additive reconciliation that never deletes undeclared grants — inconsistent with the rest of the tool's desired-state model.) +- Managed-guard: only declared domain objects are reconciled; all others invisible. + +### Validation (empirical) + +Domain rules are derived from the live API + round-trip tests, not the issue text (whose `authId < 10000` rule is already falsified). To pin live before finalizing: + +- Real per-domain constraints on `authId`, `revoke` (issue claims `group_role` only), and `isInherited` (issue claims the two role domains only). +- `domainId` semantics for `group_role`: whether it is the role-definition id (`/group/roles`) or a per-`(group, role)` id. The DSL's `id:` maps to `domainId`; its meaning is confirmed before the DSL is frozen. + +## Feature 3 — Blueprints (#7) + +The injected-context DSL (`src/config/context.ts`) is already a plain function, so parametrized blueprints and campus loops work today with no new machinery: + +```ts +const kidsArea = (campus: string) => { + ct.group({ key: `${campus}_kids_lead`, type: "local_lead", name: `${campus} · Kids Leitung` }); + 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); } +``` + +Deliverables (mostly non-code): + +- An example `kidsArea(campus)` blueprint + a test proving one blueprint instantiated across ≥2 campuses yields the correct **ordered** plan. +- A dynamic auto-group inside a blueprint reconciling idempotently (exercises #14). +- Docs (README / a short blueprint guide). +- **No new abstraction** unless a helper obviously earns its place (YAGNI). The value delivered is the proof + examples that the function-based DSL scales. + +## Shared resolver + +A single key/name → CT-id resolver used by: + +- Auto-group typed `var` values (`campus("mainz")` → campus id, group/role by key → id). +- Permission scope keys → `dataId[]`. + +It resolves logical keys against the state file (managed set) and, where needed, against live catalog data (campuses, roles). Centralizing avoids three divergent lookups. + +## Testing + +- **Unit:** normalizers (JSONLogic `dterm` stripping, int/string coercion, timestamp removal, GET↔PUT `process` transform); permission set reconciliation (tuple keying, `dataId` normalization, baseline/inherited exclusion); name→authId and key→dataId resolution; typed-query → JSONLogic compilation. +- **Round-trip integration (opt-in, env-gated — they write to eqrm-dev):** read → normalize → write-back = no-op for a real dynamic group (fixture created during implementation) and for a permission grant set. Pins the "preliminary / subject to change" APIs. + +## Sequencing + +One branch. Implement **#14 → #13 → #7**. #14 and #13 are independent; #7 lands last because its DoD exercises both. + +## Open items to resolve during implementation (all live-resolvable on eqrm-dev) + +1. **Name↔authId bridge** (churchcore auth masterdata) — permissions step 1. **Still open:** no REST endpoint (`/permissions/masterdata`, `/permissions` both 404; legacy `?q=churchcore/ajax&func=getMasterData` needs correct params). Resolve via the legacy churchcore masterdata call or `cc_authview.js`'s source. +2. `group_role` `domainId` semantics (role-def id vs per-(group,role) id) — freeze permission DSL after. **Still open.** +3. `query.params.filter` JSONLogic internals + `handleMembership` contents — **fixture-only** (OpenAPI leaves them opaque); captured by the #14 fixture task. + +**Resolved by the 2026-07-08 OpenAPI probe** (see "Authoritative OpenAPI shapes" above): domain validation rules (`authId<10000`, `revoke`/`isInherited` domains); `dataId` GET-scalar-vs-PUT-array; ruleset GET-bare-vs-PUT-`dynamicGroupRuleSet`-wrapped envelope; status/refresh bodies. diff --git a/examples/campus-blueprint.config.ts b/examples/campus-blueprint.config.ts new file mode 100644 index 0000000..c3cf75f --- /dev/null +++ b/examples/campus-blueprint.config.ts @@ -0,0 +1,59 @@ +/** + * Parametrized campus blueprint: define the Kids area once, instantiate per campus. + * Demonstrates that the config DSL needs no special "blueprint" machinery — a + * blueprint is a plain function over the injected context, called in a loop. + */ +import type { ConfigContext } from "../src/config/context.js"; +import { q, churchQuery } from "../src/config/context.js"; + +const CAMPUSES = ["mainz", "berlin"] as const; + +/** One campus's Kids area: a lead group with three ministry teams under it, plus a dynamic "all members" group. */ +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) { + ct.group({ + key: `${campus}_kids_${suffix}`, + name: `${campus} · Kids ${label}`, + groupTypeId: 2, + parents: [lead], // managed hierarchy: team sits under the campus lead group + }); + } + // A dynamic auto-group (#14) composed inside the blueprint. + ct.group({ + key: `${campus}_kids_all`, + name: `${campus} · Kids (alle)`, + groupTypeId: 2, + parents: [lead], + dynamic: { + status: "manual", + ruleset: { + description: `Alle aktiven Kids-Mitarbeiter ${campus}`, + importance: 0, + personIdFieldName: "person.id", + process: {}, + query: churchQuery(q.eq("person.isArchived", false)), + }, + }, + }); +} + +export default (ct: ConfigContext): void => { + for (const campus of CAMPUSES) { + ct.campus({ key: campus, name: `Campus ${campus}`, shorty: campus.slice(0, 3).toUpperCase() }); + kidsArea(ct, campus); + } + // A permission grant (#13) on a shared group-type-role template — id is an illustrative placeholder. + // Both rights are scoped (they carry a `scopeField` in the catalog), so they must be declared + // as `{ right, scope: [...] }` — a bare string would grant them globally and is rejected. + const kidsLeads = CAMPUSES.map((c) => `${c}_kids_lead`); + ct.groupTypeRole({ + key: "kids_lead_tpl", + id: 2, + grants: [ + { right: "churchgroup:view group", scope: kidsLeads }, + { right: "churchgroup:edit group memberships of group", scope: kidsLeads }, + ], + }); +}; diff --git a/examples/dynamic-group.config.ts b/examples/dynamic-group.config.ts new file mode 100644 index 0000000..70eeeba --- /dev/null +++ b/examples/dynamic-group.config.ts @@ -0,0 +1,48 @@ +/** + * Runnable example: a campus + a dynamic ("auto") group whose ruleset is + * built with the typed query DSL. See docs/dynamic-groups.md for the full + * feature guide. + * + * This is illustrative only — `mainzCampusId` below is a placeholder. In a + * real config, resolve name → id lookups (e.g. from `ct get campuses`) at + * config-build time before calling `q.eq`. + */ +import type { ConfigContext } from "../src/config/context.js"; +import { q, churchQuery } from "../src/config/context.js"; + +const mainzCampusId = 0; // placeholder — replace with the real campus id + +export default (ct: ConfigContext): void => { + ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); + + // The group must exist before it can carry a ruleset — the engine handles + // this ordering automatically. `dynamic` is a synthetic sub-resource field + // (like `parents`), not a separate resource type: its ruleset/status write is + // folded into the group's OWN tier-1 apply and routed to the dynamic-group + // endpoints inline (see src/engine/synthetic.ts and docs/dynamic-groups.md). + ct.group({ + key: "all_mainz", + name: "Alle Mainz", + groupTypeId: 1, + dynamic: { + // "manual": ChurchTools stores the ruleset but only recomputes + // membership when explicitly triggered — via `ct apply --refresh` or + // manually in the ChurchTools UI. Use "active" for continuous + // automatic recomputation, "inactive" to keep the ruleset stored but + // paused, or "none" to demote back to a plain (non-dynamic) group. + status: "manual", + ruleset: { + // `description`/`shorty` live on the ruleset object itself — a + // sibling of `query`, NOT an argument to churchQuery(...). + description: "Alle aktiven Personen in Mainz", + shorty: "Autom. Mitgliedschaft Alle Mainz", + importance: 0, + personIdFieldName: "person.id", + process: {}, + query: churchQuery( + q.and(q.eq("ctgroup.campusId", mainzCampusId), q.eq("person.isArchived", false)), + ), + }, + }, + }); +}; diff --git a/examples/permissions.config.ts b/examples/permissions.config.ts new file mode 100644 index 0000000..e49f686 --- /dev/null +++ b/examples/permissions.config.ts @@ -0,0 +1,30 @@ +/** + * Runnable example: a group-type-role declaration with one unscoped (global) + * grant and one scoped grant. See docs/permissions.md for the full feature + * guide, and `ct get permissions-catalog` to discover right names. + * + * This is illustrative only — the `id: 1` on `groupTypeRole` below is a + * placeholder domainId (the group type's own id; see docs/permissions.md + * "domainId semantics"). In a real config, use the actual group type id + * (e.g. from `ct get group-types`). + */ +import type { ConfigContext } from "../src/config/context.js"; + +export default (ct: ConfigContext): void => { + // The scoped grant below references this group by its logical key. Scope + // keys must resolve to a group managed by this tool (declared or adopted), + // so ct plan can show what the grant's scope resolves to. + ct.group({ key: "kids_area", name: "Kids · Bereich", groupTypeId: 1 }); + + ct.groupTypeRole({ + key: "leiter_tpl", + id: 1, // placeholder — the real group type's id (e.g. from `ct get group-types`) + grants: [ + // Unscoped: applies everywhere this group type's role holds. authId 1101. + "churchgroup:view", + // Scoped: applies only to the listed managed group(s). authId 1104 + // (`scopeField: "cdb_gruppe"` in the catalog — a scoped right). + { right: "churchgroup:view group", scope: ["kids_area"] }, + ], + }); +}; diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 2b70417..adb6c7c 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -1,14 +1,19 @@ import { dirname, join } from "node:path"; import { Command } from "commander"; +import type { CtClient } from "../api/ctClient.js"; import { authedSession } from "../api/session.js"; import { resolveConfig } from "../config.js"; -import { loadState, resolveStatePath, saveState } from "../state/state.js"; +import { loadState, resolveStatePath, saveState, type State } from "../state/state.js"; import { loadConfig, resolveConfigPath } from "../config/load.js"; import { buildPlan } from "../engine/build.js"; import { executePlan } from "../engine/execute.js"; import { writeBackup } from "../engine/backup.js"; import { renderPlan } from "../engine/render.js"; -import { summarize } from "../engine/types.js"; +import { summarize, type Plan } from "../engine/types.js"; +import { assertNotPeople } from "../engine/guard.js"; +import { buildPermissionPlan } from "../permissions/plan.js"; +import { renderPermissionPlan } from "../permissions/render.js"; +import { applyPermissionPlan } from "../permissions/apply.js"; import { confirm } from "../ui/prompt.js"; import { info, warn, success, error } from "../ui.js"; @@ -17,6 +22,50 @@ interface ApplyOptions { state?: string; backupDir?: string; autoApprove?: boolean; + refresh?: boolean; +} + +interface RefreshResult { + created: number; + updated: number; + deleted: number; +} + +/** + * Post-apply dynamic-group refresh (opt-in via `--refresh`). For each applied + * item whose changes touched the `dynamic` synthetic field, POST the + * per-group `/dynamicgroups/{id}/refresh` endpoint to materialize computed + * membership. Deliberately per-group only — the all-groups + * `/dynamicgroups/refresh` endpoint has a huge blast radius and must never be + * called from here. + * + * The id is read from state (post-apply, so creates have their real id) using + * an explicit `undefined` check — CT ids can legitimately be `0`. + */ +export async function refreshChangedDynamicGroups( + plan: Plan, + state: State, + client: Pick, +): Promise { + for (const item of plan.items) { + if (item.action === "no-op" || item.action === "delete") continue; + const dynamicChange = item.changes.find((c) => c.field === "dynamic"); + if (!dynamicChange) continue; + const to = dynamicChange.to as { status?: string } | undefined; + if (to?.status === "none") continue; // demoted to a non-dynamic group — nothing to refresh + const id = state.resources[item.key]?.id; + if (id === undefined) continue; + const path = `/dynamicgroups/${id}/refresh`; + assertNotPeople(path); + try { + const res = await client.request("POST", path); + const r = res?.[0]; + if (r) info(`refreshed ${item.key}: +${r.created} ~${r.updated} -${r.deleted}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + warn(`Failed to refresh ${item.key} (#${id}): ${message}`); + } + } } /** backups/ dir: explicit flag → CT_BACKUP_DIR → `backups/` beside the state file. */ @@ -35,25 +84,34 @@ export function applyCommand(): Command { .option("-s, --state ", "state file (or set CT_STATE)") .option("--backup-dir ", "directory for the pre-apply backup (or set CT_BACKUP_DIR)") .option("-y, --auto-approve", "skip the confirmation prompt") + .option( + "--refresh", + "after a successful apply, POST /dynamicgroups/{id}/refresh for each changed dynamic group (per-group only)", + ) .action(async (opts: ApplyOptions) => { const config = await resolveConfig(); const configPath = resolveConfigPath(opts.config); const statePath = resolveStatePath(opts.state); - const desired = await loadConfig(configPath); + const { resources: desired, permissions, configDir } = await loadConfig(configPath); const state = await loadState(statePath, config.host); const { client } = await authedSession(); - const { plan, actual, fetchErrors } = await buildPlan(client, state, desired); + const { plan, actual, fetchErrors } = await buildPlan(client, state, desired, { configDir }); + const { items: permItems, fetchErrors: permFetchErrors } = await buildPermissionPlan(client, state, permissions); - if (fetchErrors.length > 0) { + const allFetchErrors = [...fetchErrors, ...permFetchErrors]; + if (allFetchErrors.length > 0) { error( - `Aborting: ${fetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`, + `Aborting: ${allFetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`, ); process.exitCode = 1; return; } process.stdout.write(`${renderPlan(plan)}\n`); + if (permItems.length > 0) { + process.stdout.write(`\n${renderPermissionPlan(permItems)}\n`); + } const deletes = plan.items.filter((i) => i.action === "delete"); if (deletes.length > 0) { @@ -64,7 +122,11 @@ export function applyCommand(): Command { } const s = summarize(plan); - const changeCount = s.create + s.update; + 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."); return; @@ -91,6 +153,16 @@ export function applyCommand(): Command { `Stopped at ${result.failed.key}: ${result.failed.message}. State saved up to this point — re-run to resume.`, ); process.exitCode = 1; + return; + } + + const permResult = await applyPermissionPlan(permItems, client); + if (permResult.granted > 0 || permResult.deleted > 0) { + success(`Permissions applied: ${permResult.granted} granted, ${permResult.deleted} deleted.`); + } + + if (opts.refresh) { + await refreshChangedDynamicGroups(plan, state, client); } }); } diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 4b68913..739049f 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -64,7 +64,7 @@ export function destroyCommand(): Command { } // preventDestroy guard: a target still declared with the flag is blocked. - const desired = await loadConfig(resolveConfigPath(opts.config)); + const { resources: desired } = await loadConfig(resolveConfigPath(opts.config)); const protectedKeys = new Set(desired.filter((d) => d.preventDestroy).map((d) => d.key)); const blocked = targets.filter((k) => protectedKeys.has(k)); if (blocked.length > 0) { diff --git a/src/commands/get.ts b/src/commands/get.ts index b058c6e..cb3f683 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { authedSession } from "../api/session.js"; +import { loadCatalog } from "../permissions/catalog.js"; import { out } from "../ui.js"; /** @@ -35,6 +36,19 @@ export function getCommand(): Command { }); } + cmd + .command("permissions-catalog") + .description("List the static permission-name → authId catalog (for use in config `grants`)") + .action(() => { + const catalog = loadCatalog(); + for (const name of Object.keys(catalog).sort()) { + const entry = catalog[name]; + if (!entry) continue; + const scoped = entry.scopeField ? "scoped" : "unscoped"; + process.stdout.write(`${name} -> ${entry.authId} (${scoped})\n`); + } + }); + cmd .command("raw ") .description("GET an arbitrary API path, e.g. `ct get raw /groups/42`") diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 27e8d6e..214d836 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -5,6 +5,8 @@ import { loadState, resolveStatePath } from "../state/state.js"; import { loadConfig, resolveConfigPath } from "../config/load.js"; import { buildPlan } from "../engine/build.js"; import { renderPlan } from "../engine/render.js"; +import { buildPermissionPlan } from "../permissions/plan.js"; +import { renderPermissionPlan } from "../permissions/render.js"; import { info, warn, out } from "../ui.js"; interface PlanOptions { @@ -22,24 +24,29 @@ export function planCommand(): Command { .action(async (opts: PlanOptions) => { const config = await resolveConfig(); const configPath = resolveConfigPath(opts.config); - const desired = await loadConfig(configPath); + const { resources: desired, permissions, configDir } = await loadConfig(configPath); const state = await loadState(resolveStatePath(opts.state), config.host); if (state.host !== config.host) { throw new Error(`State host (${state.host}) does not match CT_HOST (${config.host}).`); } const { client } = await authedSession(); - const { plan, fetchErrors } = await buildPlan(client, state, desired); + const { plan, fetchErrors } = await buildPlan(client, state, desired, { configDir }); + const { items: permItems, fetchErrors: permFetchErrors } = await buildPermissionPlan(client, state, permissions); if (opts.json) { - out(plan); + out({ plan, permissions: permItems }); } else { info(`config: ${configPath} · state host: ${state.host}`); process.stdout.write(`${renderPlan(plan)}\n`); + if (permItems.length > 0) { + process.stdout.write(`\n${renderPermissionPlan(permItems)}\n`); + } } - if (fetchErrors.length > 0) { + const allFetchErrors = [...fetchErrors, ...permFetchErrors]; + if (allFetchErrors.length > 0) { warn( - `Plan is INCOMPLETE — ${fetchErrors.length} resource(s) could not be fetched; their diff is missing. Re-run to retry.`, + `Plan is INCOMPLETE — ${allFetchErrors.length} resource(s) could not be fetched; their diff is missing. Re-run to retry.`, ); process.exitCode = 1; } diff --git a/src/config/context.ts b/src/config/context.ts index d8e3a74..ad17338 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -11,7 +11,15 @@ * The context is injected (no global state), so blueprints are just functions * and loops, and the whole thing is trivially testable without file I/O. */ -import type { DesiredResource } from "../engine/types.js"; +import type { DesiredResource, DynamicSpec, DynamicStatus } from "../engine/types.js"; +import type { DomainType } from "../permissions/grants.js"; +import type { DesiredPermission, Grant } from "../permissions/types.js"; +// Re-exported so a config file can pull the query DSL from the same module as +// `ConfigContext`: `import { q, churchQuery } from "../../src/config/context.js"`. +export { q, churchQuery } from "./query.js"; +export type { QueryNode } from "./query.js"; + +const DYNAMIC_STATUSES = ["active", "inactive", "manual", "none"] as const; export interface ResourceInput { key: string; @@ -29,6 +37,12 @@ export interface ResourceInput { [field: string]: unknown; } +export interface PermissionInput { + key: string; + id: number; + grants: Grant[]; +} + export interface ConfigContext { campus(input: ResourceInput): void; group(input: ResourceInput): void; @@ -36,12 +50,14 @@ export interface ConfigContext { ageGroup(input: ResourceInput): void; targetGroup(input: ResourceInput): void; relationshipType(input: ResourceInput): void; + groupRole(input: PermissionInput): void; + groupTypeRole(input: PermissionInput): void; } export type ConfigModule = (ct: ConfigContext) => void | Promise; function toDesired(type: string, input: ResourceInput): DesiredResource { - const { key, parent, parents, dependsOn = [], preventDestroy, ...fields } = input; + const { key, parent, parents, dependsOn = [], preventDestroy, dynamic, ...fields } = input; if (!key || typeof key !== "string") { throw new Error(`${type} declaration is missing a string "key".`); } @@ -52,13 +68,37 @@ function toDesired(type: string, input: ResourceInput): DesiredResource { if (parents !== undefined && (!Array.isArray(parents) || parents.some((p) => typeof p !== "string"))) { throw new Error(`${type} "${key}": "parents" must be an array of string group keys.`); } + // `dynamic` is a synthetic field for auto-groups, handled separately from the plain diffed + // field bag. Opt-in: `undefined` means "not a dynamic group" (mirrors `parents`). + let dynamicSpec: DynamicSpec | undefined; + if (dynamic !== undefined) { + if (type !== "group") throw new Error(`${type} "${key}": "dynamic" is only valid on a group.`); + if (dynamic == null || typeof dynamic !== "object") { + throw new Error(`group "${key}": "dynamic" must be an object with { status, ruleset }.`); + } + const d = dynamic as Record; + if (!DYNAMIC_STATUSES.includes(d.status as DynamicStatus)) + 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 }; + } // `parent` is an ordering hint only — a dependency edge, never a diffed/managed field // (its pre-hierarchy meaning; a `parent` may point at a campus). Group hierarchy is // managed opt-in via `parents`: `undefined` → unmanaged, `[]` → managed with no parents. const parentKey = typeof parent === "string" && parent !== "" ? parent : undefined; const parentKeys = parents !== undefined ? [...new Set(parents)] : undefined; const edges = [...new Set([...dependsOn, ...(parentKey ? [parentKey] : []), ...(parentKeys ?? [])])]; - return { type, key, fields, parent: parentKey, parents: parentKeys, dependsOn: edges, preventDestroy }; + return { + type, + key, + fields, + parent: parentKey, + parents: parentKeys, + dynamic: dynamicSpec, + dependsOn: edges, + preventDestroy, + }; } /** @@ -87,9 +127,18 @@ function validateReferences(resources: DesiredResource[]): void { } } -export function createContext(): { ct: ConfigContext; resources: DesiredResource[] } { +export function createContext(): { + ct: ConfigContext; + resources: DesiredResource[]; + permissions: DesiredPermission[]; +} { const resources: DesiredResource[] = []; + const permissions: DesiredPermission[] = []; const seen = new Set(); + // Tracks (domainType, domainId) -> declaring key, so two declarations aiming at the same + // permission target (even under different logical keys) are rejected at eval time instead + // of each diffing against the other's grants and proposing them as deletes forever. + const seenDomains = new Map(); const define = (type: string) => (input: ResourceInput): void => { @@ -100,6 +149,33 @@ export function createContext(): { ct: ConfigContext; resources: DesiredResource seen.add(resource.key); resources.push(resource); }; + const definePermission = + (domainType: DomainType) => + (input: PermissionInput): void => { + if (typeof input.key !== "string" || !input.key) + throw new Error(`${domainType} declaration missing a string "key".`); + if (typeof input.id !== "number" || !Number.isFinite(input.id)) + throw new Error(`${domainType} "${input.key}": "id" must be a number (the domainId).`); + if (!Array.isArray(input.grants)) throw new Error(`${domainType} "${input.key}": "grants" must be an array.`); + for (const g of input.grants) { + const right = typeof g === "string" ? g : g?.right; + if (typeof right !== "string" || !right.includes(":")) + throw new Error(`${domainType} "${input.key}": each grant must be a "module:right" string or { right, scope }.`); + if (typeof g === "object" && !Array.isArray(g.scope)) + throw new Error(`${domainType} "${input.key}": scoped grant needs "scope": string[].`); + } + if (seen.has(input.key)) throw new Error(`Duplicate logical key "${input.key}" in config.`); + seen.add(input.key); + const domainKey = `${domainType}:${input.id}`; + const existingKey = seenDomains.get(domainKey); + if (existingKey) { + throw new Error( + `Duplicate permission target: ${domainType} #${input.id} is declared by both "${existingKey}" and "${input.key}". Merge their grants into one declaration.`, + ); + } + seenDomains.set(domainKey, input.key); + permissions.push({ key: input.key, domainType, domainId: input.id, grants: input.grants }); + }; // Every type emitted here MUST have an apply tier in engine/graph.ts TYPE_TIER // (locked by tests/context.test.ts), else computePlan rejects it at plan time. const ct: ConfigContext = { @@ -109,14 +185,18 @@ export function createContext(): { ct: ConfigContext; resources: DesiredResource ageGroup: define("age-group"), targetGroup: define("target-group"), relationshipType: define("relationship-type"), + groupRole: definePermission("group_role"), + groupTypeRole: definePermission("group_type_role"), }; - return { ct, resources }; + return { ct, resources, permissions }; } -/** Run a loaded config module against a fresh context and collect its resources. */ -export async function evaluateConfig(mod: ConfigModule): Promise { - const { ct, resources } = createContext(); +/** Run a loaded config module against a fresh context and collect its resources + permissions. */ +export async function evaluateConfig( + mod: ConfigModule, +): Promise<{ resources: DesiredResource[]; permissions: DesiredPermission[] }> { + const { ct, resources, permissions } = createContext(); await mod(ct); validateReferences(resources); - return resources; + return { resources, permissions }; } diff --git a/src/config/load.ts b/src/config/load.ts index b9452d9..ba12dc2 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -7,10 +7,11 @@ * declared resources. */ import { access } from "node:fs/promises"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { createJiti } from "jiti"; import { evaluateConfig, type ConfigModule } from "./context.js"; import type { DesiredResource } from "../engine/types.js"; +import type { DesiredPermission } from "../permissions/types.js"; export const DEFAULT_CONFIG_PATH = "ct.config.ts"; @@ -18,8 +19,13 @@ export function resolveConfigPath(explicit?: string, env: NodeJS.ProcessEnv = pr return explicit?.trim() || env.CT_CONFIG?.trim() || DEFAULT_CONFIG_PATH; } -export async function loadConfig(path: string): Promise { +export async function loadConfig( + path: string, +): Promise<{ resources: DesiredResource[]; permissions: DesiredPermission[]; configDir: string }> { const resolved = resolve(path); + // Directory of the config file, so relative `{ ref }` ruleset paths resolve against the config + // (not wherever `ct` happens to be invoked). Threaded through buildPlan → foldSynthetic. + const configDir = dirname(resolved); // Surface a friendly message rather than jiti's raw ERR_MODULE_NOT_FOUND stack. try { await access(resolved); @@ -34,5 +40,5 @@ export async function loadConfig(path: string): Promise { if (typeof mod !== "function") { throw new Error(`Config ${path} must default-export a function (ct) => { ... }.`); } - return evaluateConfig(mod); + return { ...(await evaluateConfig(mod)), configDir }; } diff --git a/src/config/query.ts b/src/config/query.ts new file mode 100644 index 0000000..505809a --- /dev/null +++ b/src/config/query.ts @@ -0,0 +1,44 @@ +/** + * Typed query builder for dynamic-group rulesets (Phase 2). Emits a JSONLogic + * 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. + * + * `churchQuery` wraps a filter in the same envelope shape ChurchTools itself + * returns (see tests/fixtures/dynamic/ruleset-683.get.json, ruleset-2022.get.json, + * ruleset-1092.get.json): `{ method: "ChurchQuery", params: { + * groupBy, filter, primaryEntityAlias, responseFields } }` — no `description` + * here; that field lives on the ruleset object, not inside `query`. Defaults + * are the observed common case (grouping/returning by `person.id`); override + * via `opts` for a query keyed on a different primary entity. + */ +export interface QueryNode { + [op: string]: unknown; +} + +export const q = { + and: (...n: QueryNode[]): QueryNode => ({ and: n }), + or: (...n: QueryNode[]): QueryNode => ({ or: n }), + not: (n: QueryNode): QueryNode => ({ "!": [n] }), + var: (name: string): QueryNode => ({ var: name }), + eq: (varName: string, value: unknown): QueryNode => ({ "==": [{ var: varName }, value] }), + oneof: (varName: string, values: unknown[]): QueryNode => ({ oneof: [{ var: varName }, values] }), + isnull: (varName: string): QueryNode => ({ isnull: [{ var: varName }] }), +}; + +/** Wrap a JSONLogic filter in the ChurchQuery envelope the ruleset `query` field expects. + * Shape matches real production rulesets exactly: { method, params } — no `description` here + * (`description` is a sibling field on the ruleset object, not inside `query`). */ +export function churchQuery( + filter: QueryNode, + opts: { primaryEntityAlias?: string; responseFields?: string[]; groupBy?: string[] } = {}, +): Record { + return { + method: "ChurchQuery", + params: { + groupBy: opts.groupBy ?? ["person.id"], + filter, + primaryEntityAlias: opts.primaryEntityAlias ?? "person", + responseFields: opts.responseFields ?? ["person.id", "person.firstName", "person.lastName"], + }, + }; +} diff --git a/src/engine/build.ts b/src/engine/build.ts index f2db63b..bd08c35 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -1,8 +1,9 @@ /** * Shared plan building: fetch the actual ChurchTools values of every managed - * resource, fold group hierarchy into a `parents` set-field, and diff against - * the desired config + state. Used by both `ct plan` and `ct apply`, so apply - * fetches exactly once (its `actual` map is reused for the backup). + * resource, fold synthetic sub-resource fields (group hierarchy's `parents`, …) + * into the diff, and diff against the desired config + state. Used by both + * `ct plan` and `ct apply`, so apply fetches exactly once (its `actual` map is + * reused for the backup). */ import type { CtClient } from "../api/ctClient.js"; import { CtApiError } from "../api/ctClient.js"; @@ -10,7 +11,7 @@ import type { State } from "../state/state.js"; import type { DesiredResource, Plan } from "./types.js"; import { RESOURCES } from "../resources/registry.js"; import { computePlan } from "./plan.js"; -import { parentIdsByGroupId, applyHierarchy, type HierarchyEntry } from "./hierarchy.js"; +import { foldSynthetic } from "./synthetic.js"; import { mapConcurrent } from "../util/concurrency.js"; import { warn } from "../ui.js"; @@ -23,10 +24,16 @@ export interface BuildResult { fetchErrors: string[]; } +export interface BuildOptions { + /** Directory of the config file — `{ ref }` ruleset paths resolve relative to it (not the cwd). */ + configDir?: string; +} + export async function buildPlan( client: Pick, state: State, desired: DesiredResource[], + opts: BuildOptions = {}, ): Promise { // Keyed by logical key (globally unique), not CT id (unique only within a type — the Mainz campus is id 0). const actual = new Map>(); @@ -56,24 +63,9 @@ export async function buildPlan( } }); - // Group hierarchy: one bulk call, folded into each opted-in group's `parents` set-field. - let parentIds = new Map(); - let hierarchyOk = true; - const hasManagedGroups = Object.values(state.resources).some((m) => m.type === "group"); - if (hasManagedGroups) { - try { - const raw = await client.get("/groups/hierarchies"); - parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - fetchErrors.push(`group hierarchies: ${message}`); - warn(`Failed to fetch group hierarchies: ${message}`); - hierarchyOk = false; - } - } - // On a hierarchy-fetch failure, leave `parents` undiffed rather than folding an empty map - // (which would fabricate spurious "add all parents" changes). The plan is flagged INCOMPLETE. - const desiredWithHierarchy = hierarchyOk ? applyHierarchy(desired, state, actual, parentIds) : desired; - const plan = computePlan(desiredWithHierarchy, state, actual, { unresolved }); + // Synthetic sub-resource fields (parents, dynamic, …) fold into the diff on both sides. + const folded = await foldSynthetic({ client, state, desired, actual, configDir: opts.configDir }); + fetchErrors.push(...folded.errors); + const plan = computePlan(folded.desired, state, actual, { unresolved }); return { plan, actual, fetchErrors }; } diff --git a/src/engine/dynamic.ts b/src/engine/dynamic.ts new file mode 100644 index 0000000..6d6c664 --- /dev/null +++ b/src/engine/dynamic.ts @@ -0,0 +1,94 @@ +/** + * Normalizer for dynamic-group rulesets. CT returns rulesets with cosmetic + * labels, inconsistent int/string leaf types, read-only timestamps, and (on + * write) a `dynamicGroupRuleSet` envelope. Normalizing both the desired and + * actual sides to one canonical form is what keeps drift real, not spurious. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { DynamicStatus } from "./types.js"; + +const READ_ONLY_KEYS = new Set(["dynamicGroupUpdateStarted", "dynamicGroupUpdateFinished"]); + +/** Recursively unwrap `dterm: [label, expr]` cosmetic wrappers to their `expr`. */ +export function stripCosmeticLabels(node: unknown): unknown { + if (Array.isArray(node)) return node.map(stripCosmeticLabels); + if (node && typeof node === "object") { + const obj = node as Record; + if (Array.isArray(obj.dterm) && obj.dterm.length === 2 && Object.keys(obj).length === 1) { + return stripCosmeticLabels(obj.dterm[1]); // keep the expression, drop the label + } + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) out[k] = stripCosmeticLabels(v); + return out; + } + return node; +} + +/** Coerce numeric-string leaves to numbers (CT is int/string-inconsistent for `var` values). */ +export function coerceScalars(node: unknown): unknown { + if (Array.isArray(node)) return node.map(coerceScalars); + if (node && typeof node === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(node as Record)) out[k] = coerceScalars(v); + return out; + } + if (typeof node === "string" && /^-?\d+$/.test(node)) return Number.parseInt(node, 10); + return node; +} + +function dropReadOnly(rule: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(rule)) if (!READ_ONLY_KEYS.has(k)) out[k] = v; + return out; +} + +/** 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] + let obj = (r ?? {}) as Record; + if (obj.dynamicGroupRuleSet && typeof obj.dynamicGroupRuleSet === "object") { + obj = obj.dynamicGroupRuleSet as Record; // unwrap the PUT envelope + } + const base = dropReadOnly(obj); + // Cosmetic dterm labels and int/string id inconsistencies live inside `query`. Normalize only + // that subtree, so a RuleSet-level string field that looks numeric (a "2024" description/shorty) + // is never silently retyped to a number and corrupted on write-back. + if (base.query !== undefined) { + base.query = coerceScalars(stripCosmeticLabels(base.query)); + } + return base; +} + +export interface NormalizedDynamic { status: DynamicStatus; ruleset: Record } + +export function normalizeDynamic(spec: { status: DynamicStatus; ruleset: unknown }): NormalizedDynamic { + return { status: spec.status, ruleset: normalizeRuleset(spec.ruleset) }; +} + +/** + * Resolve a `{ ref: "./file.json" }` ruleset to its JSON contents; pass through inline rulesets. + * `ref` paths resolve relative to `baseDir` (the config file's directory), NOT the process cwd, + * 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 { + if (ruleset && typeof ruleset === "object" && typeof (ruleset as { ref?: unknown }).ref === "string") { + const ref = (ruleset as { ref: string }).ref; + const p = resolve(baseDir, ref); + const where = groupKey ? `dynamic ruleset for group "${groupKey}"` : "dynamic ruleset"; + let raw: string; + try { + raw = readFileSync(p, "utf8"); + } catch (err) { + 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}`); + } + } + return ruleset; +} diff --git a/src/engine/execute.ts b/src/engine/execute.ts index 624fcba..ffbeb6d 100644 --- a/src/engine/execute.ts +++ b/src/engine/execute.ts @@ -4,8 +4,9 @@ * touches this file. State is saved after each successful action, so a crash * mid-apply leaves a consistent, resumable state file. * - * apply NEVER deletes: delete items are recorded and skipped. Group hierarchy is - * reconciled through the parents endpoints, not the group body. + * apply NEVER deletes: delete items are recorded and skipped. Synthetic + * sub-resource fields (group hierarchy's `parents`, …) are reconciled through + * their own dedicated endpoints, not the owning resource's body — see synthetic.ts. */ import type { CtClient } from "../api/ctClient.js"; import type { State } from "../state/state.js"; @@ -13,6 +14,7 @@ import { upsert, saveState } from "../state/state.js"; import type { FieldChange, Plan } from "./types.js"; import { RESOURCES } from "../resources/registry.js"; import { assertNotPeople } from "./guard.js"; +import { isSyntheticField, syntheticField } from "./synthetic.js"; export interface ExecuteDeps { client: Pick; @@ -29,58 +31,20 @@ export interface ExecuteResult { failed?: { key: string; message: string }; } -/** The managed field snapshot after a write: base ∪ changed fields, minus the hierarchy `parents` set-field. */ -function snapshotFromChanges( - base: Record, - changes: FieldChange[], -): Record { +/** The managed field snapshot after a write: base ∪ changed fields, minus any synthetic sub-resource fields. */ +function snapshotFromChanges(base: Record, changes: FieldChange[]): Record { const snap = { ...base }; - for (const c of changes) { - if (c.field !== "parents") { - snap[c.field] = c.to; - } - } - delete snap.parents; + for (const c of changes) if (!isSyntheticField(c.field)) snap[c.field] = c.to; + for (const f of Object.keys(snap)) if (isSyntheticField(f)) delete snap[f]; return snap; } -function parentEdges(from: unknown, to: unknown): { added: string[]; removed: string[] } { - const f = new Set(Array.isArray(from) ? (from as string[]) : []); - const t = new Set(Array.isArray(to) ? (to as string[]) : []); - return { - added: [...t].filter((k) => !f.has(k)), - removed: [...f].filter((k) => !t.has(k)), - }; -} - -function resolveId(state: State, key: string): number { - const managed = state.resources[key]; - if (!managed) { - throw new Error(`Cannot resolve parent "${key}" — not under management yet.`); - } - return managed.id; -} - -async function applyParentEdges( - client: Pick, - state: State, - childId: number, - changes: FieldChange[], +async function applySyntheticFields( + client: Pick, state: State, id: number, changes: FieldChange[], ): Promise { - const change = changes.find((c) => c.field === "parents"); - if (!change) { - return; - } - const { added, removed } = parentEdges(change.from, change.to); - for (const key of added) { - const path = `/groups/${childId}/parents/${resolveId(state, key)}`; - assertNotPeople(path); - await client.request("PUT", path); - } - for (const key of removed) { - const path = `/groups/${childId}/parents/${resolveId(state, key)}`; - assertNotPeople(path); - await client.request("DELETE", path); + for (const c of changes) { + const f = syntheticField(c.field); + if (f) await f.apply({ client, state, id, change: c }); } } @@ -125,7 +89,7 @@ export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise c.field !== "parents"); + const hasFieldChange = item.changes.some((c) => !isSyntheticField(c.field)); if (hasFieldChange) { const path = spec.itemPath(id); assertNotPeople(path); @@ -142,7 +106,7 @@ export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise; + state: State; + desired: DesiredResource[]; + actual: Map>; + /** Directory of the config file, so `{ ref }` ruleset paths resolve relative to it (not the cwd). */ + configDir?: string; +} +export interface SyntheticApplyCtx { + client: Pick; + state: State; + id: number; + change: FieldChange; +} +export interface SyntheticField { + field: string; + fold(ctx: SyntheticFoldCtx): Promise<{ desired: DesiredResource[]; errors: string[] }>; + apply(ctx: SyntheticApplyCtx): Promise; +} + +function resolveId(state: State, key: string): number { + const managed = state.resources[key]; + if (!managed) throw new Error(`Cannot resolve parent "${key}" — not under management yet.`); + return managed.id; +} + +/** `parents`: many-to-many group hierarchy, reconciled per-edge. Wraps the existing hierarchy helpers. */ +const parentsField: SyntheticField = { + field: "parents", + async fold({ client, state, desired, actual }) { + const hasManagedGroups = Object.values(state.resources).some((m) => m.type === "group"); + if (!hasManagedGroups) return { desired, errors: [] }; + try { + const raw = await client.get("/groups/hierarchies"); + const parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []); + return { desired: applyHierarchy(desired, state, actual, parentIds), errors: [] }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + warn(`Failed to fetch group hierarchies: ${message}`); + // Leave `parents` undiffed rather than fabricate "add all parents" from an empty map. + return { desired, errors: [`group hierarchies: ${message}`] }; + } + }, + async apply({ client, state, id, change }) { + const from = new Set(Array.isArray(change.from) ? (change.from as string[]) : []); + const to = new Set(Array.isArray(change.to) ? (change.to as string[]) : []); + for (const key of [...to].filter((k) => !from.has(k))) { + const path = `/groups/${id}/parents/${resolveId(state, key)}`; + assertNotPeople(path); + await client.request("PUT", path); + } + for (const key of [...from].filter((k) => !to.has(k))) { + const path = `/groups/${id}/parents/${resolveId(state, key)}`; + assertNotPeople(path); + await client.request("DELETE", path); + } + }, +}; + +/** `dynamic`: dynamic-group ruleset + status, reconciled as one synthetic field. */ +const dynamicField: SyntheticField = { + field: "dynamic", + async fold({ client, state, desired, actual, configDir }) { + 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)) { + if (managed.type !== "group" || !optedIn.has(managed.key)) continue; + const a = actual.get(managed.key); + if (!a) continue; // vanished from CT → handled as a recreate by the plain plan + // The ruleset GET and the status GET have distinct failure meanings, so they get distinct + // try/catch blocks: only a ruleset 404 means "not a dynamic group". A status GET that fails + // AFTER a successful ruleset GET must NOT fabricate the "none" sentinel (that would discard a + // real ruleset and propose a spurious re-PUT) — it degrades the plan via `errors` instead. + let ruleset: Record; + try { + ruleset = await client.get>(`/dynamicgroups/${managed.id}/ruleset`); + } catch (err) { + if (err instanceof CtApiError && err.status === 404) { + // Group exists but is not (yet) a dynamic group — its ruleset 404s. Sentinel so a promote + // (desired active vs actual none) diffs as a real change and demote-to-none is a clean no-op. + a.dynamic = { status: "none", ruleset: {} }; + continue; + } + const message = err instanceof Error ? err.message : String(err); + errors.push(`dynamic ${managed.key} (#${managed.id}): ${message}`); + continue; + } + try { + const statusRes = await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${managed.id}/status`); + a.dynamic = { + status: (statusRes?.dynamicGroupStatus ?? "none") as DynamicStatus, + ruleset: normalizeRuleset(ruleset), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`dynamic ${managed.key} status (#${managed.id}): ${message}`); + } + } + 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, configDir, d.key) }) } } + : d, + ); + return { desired: augmented, errors }; + }, + async apply({ client, id, change }) { + const to = change.to as { status: DynamicStatus; ruleset: Record } | undefined; + if (!to || to.status === "none") { + assertNotPeople(`/dynamicgroups/${id}/ruleset`); + await client.request("DELETE", `/dynamicgroups/${id}/ruleset`); + assertNotPeople(`/dynamicgroups/${id}/status`); + await client.request("PUT", `/dynamicgroups/${id}/status`, { dynamicGroupStatus: "none" }); + return; + } + assertNotPeople(`/dynamicgroups/${id}/ruleset`); + await client.request("PUT", `/dynamicgroups/${id}/ruleset`, { dynamicGroupRuleSet: to.ruleset }); + assertNotPeople(`/dynamicgroups/${id}/status`); + await client.request("PUT", `/dynamicgroups/${id}/status`, { dynamicGroupStatus: to.status }); + }, +}; + +export const SYNTHETIC_FIELDS: SyntheticField[] = [parentsField, dynamicField]; + +const BY_FIELD = new Map(SYNTHETIC_FIELDS.map((f) => [f.field, f])); +export function isSyntheticField(field: string): boolean { + return BY_FIELD.has(field); +} +export function syntheticField(field: string): SyntheticField | undefined { + return BY_FIELD.get(field); +} + +/** Run every registered fold in order, threading the (immutably) augmented desired through each. */ +export async function foldSynthetic( + ctx: SyntheticFoldCtx, +): Promise<{ desired: DesiredResource[]; errors: string[] }> { + let desired = ctx.desired; + const errors: string[] = []; + for (const f of SYNTHETIC_FIELDS) { + const res = await f.fold({ ...ctx, desired }); + desired = res.desired; + errors.push(...res.errors); + } + return { desired, errors }; +} diff --git a/src/engine/types.ts b/src/engine/types.ts index d7baeb1..010bc35 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -3,6 +3,14 @@ * plan produced by diffing desired vs state vs actual. */ +export type DynamicStatus = "active" | "inactive" | "manual" | "none"; + +export interface DynamicSpec { + status: DynamicStatus; + /** RuleSet object, or a { ref: "./path.json" } reference, or a typed-query build result. */ + ruleset: unknown; +} + export interface DesiredResource { type: string; key: string; @@ -12,6 +20,8 @@ export interface DesiredResource { parent?: string; /** Managed parent group keys (hierarchy). `undefined` = hierarchy not managed for this group; `[]` = should have no managed parents. */ parents?: string[]; + /** Auto-group / dynamic-group config. Opt-in: `undefined` = not a dynamic group. Only valid on a group. */ + dynamic?: DynamicSpec; /** Logical keys this resource must be applied after (includes `parent`/`parents`). */ dependsOn: string[]; /** Lifecycle flag: block `ct destroy` for this resource. Never diffed or sent to the API. */ diff --git a/src/permissions/README.md b/src/permissions/README.md new file mode 100644 index 0000000..8ca6293 --- /dev/null +++ b/src/permissions/README.md @@ -0,0 +1,43 @@ +# Permission catalog (name ↔ authId bridge) + +`catalog.json` is the static map from human-readable `module:right` names to numeric +ChurchTools `authId`s, used to author permission grants as code (issue #13). + +## Why static + +The name↔authId catalog is **not exposed by the ChurchTools REST API**. It is only +served to the permission-editor UI via the legacy endpoint: + +``` +POST /index.php?q=churchauth/ajax body: func=getMasterData +→ { data: { auth_table: { : { : { id, auth, modulename, datenfeld, bezeichnung, isRevocable, ... } } } } } +``` + +Captured 2026-07-08 from `eqrm.church.tools` (CT 3.134.0) via a browser HAR trace of that +request. It is reference data for that CT version; regenerate if the instance's CT version +changes materially. + +## Shape + +```json +{ + "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" } +} +``` + +- **key** — `":"`, the DSL vocabulary (e.g. `churchgroup:view group`). +- **authId** — numeric id sent in the `PermissionRequest` write body. +- **scopeField** — the ChurchTools data-field a scoped right applies to (`datenfeld`), or `null` + for an unscoped/global right. A non-null `scopeField` is a right that accepts a `dataId[]` scope. +- **revocable** — whether the right supports `type: "revoke"` (`isRevocable` in the source). +- **desc** — human description (`bezeichnung`). + +187 rights across 14 modules (churchcore, churchdb, churchgroup, churchcal, churchservice, +churchresource, churchcheckin, churchwiki, churchreport, finance, churchsync, …). + +## Regeneration + +Open the permission editor in the CT admin with devtools recording, export the HAR, and extract +`log.entries[].response` for the `churchauth/ajax` `func=getMasterData` POST; flatten +`data.auth_table[module][right]` to `"module:right" → { authId: id, scopeField: datenfeld, revocable: !!isRevocable, desc: bezeichnung }`. diff --git a/src/permissions/apply.ts b/src/permissions/apply.ts new file mode 100644 index 0000000..238d8e3 --- /dev/null +++ b/src/permissions/apply.ts @@ -0,0 +1,37 @@ +/** + * Apply a permission plan: PUT each new grant, DELETE each removed tuple. + * Idempotent — re-running against an unchanged instance diffs to empty and + * this issues no requests. Every path is guarded through `assertNotPeople` + * as belt-and-suspenders atop the structural-only registry. + */ +import type { CtClient } from "../api/ctClient.js"; +import { assertNotPeople } from "../engine/guard.js"; +import type { PermissionPlanItem } from "./plan.js"; +import type { GrantTuple } from "./grants.js"; + +function body(t: GrantTuple): Record { + const b: Record = { authId: t.authId, type: t.type }; + if (t.dataId.length) b.dataId = t.dataId; // omit when unscoped + return b; +} + +export async function applyPermissionPlan( + items: PermissionPlanItem[], + client: Pick, +): Promise<{ granted: number; deleted: number }> { + let granted = 0; + let 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++; + } + } + return { granted, deleted }; +} diff --git a/src/permissions/catalog.json b/src/permissions/catalog.json new file mode 100644 index 0000000..7ec1788 --- /dev/null +++ b/src/permissions/catalog.json @@ -0,0 +1,1124 @@ +{ + "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 diff --git a/src/permissions/catalog.ts b/src/permissions/catalog.ts new file mode 100644 index 0000000..e49b352 --- /dev/null +++ b/src/permissions/catalog.ts @@ -0,0 +1,29 @@ +/** + * The permission catalog: the static name→authId bridge (see src/permissions/README.md). + * The catalog is reference data captured from the instance's churchauth masterdata; it is + * NOT available via the REST API, so it is shipped as JSON. + * + * Imported (not `readFileSync`'d) so tsup/esbuild inlines it into the bundle at build time — + * `dist/index.js` needs no sibling `catalog.json` on disk, avoiding an ENOENT in the packaged + * binary (tsup does not copy non-entry assets into `dist/` on its own). + */ +import catalogData from "./catalog.json" with { type: "json" }; + +export interface CatalogEntry { authId: number; scopeField: string | null; revocable: boolean; desc: string } + +const catalog = catalogData as Record; + +export function loadCatalog(): Record { + return catalog; +} + +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 hint = near.length ? ` Did you mean one of: ${near.join(", ")}?` : ""; + throw new Error(`Unknown permission "${name}".${hint}`); + } + return entry; +} diff --git a/src/permissions/grants.ts b/src/permissions/grants.ts new file mode 100644 index 0000000..2e6655d --- /dev/null +++ b/src/permissions/grants.ts @@ -0,0 +1,43 @@ +/** + * Grant tuples and set reconciliation. A grant's identity is (authId, sorted dataId, type). + * Actuals exclude the self-re-adding system baseline (modifiedPid === -1) and inherited rows, + * so reconciliation owns only user-authored grants and never fights the platform. + */ +export type DomainType = "group_role" | "group_type_role"; + +export interface GrantTuple { authId: number; dataId: number[]; type: "grant" | "revoke" } +export interface RawPermission { + authId: number; dataId: number | null; type: "grant" | "revoke"; domainId: number; + isInherited?: boolean; meta?: { modifiedPid?: number }; +} + +export function tupleKey(t: { authId: number; dataId: number[]; type: string }): string { + return `${t.type}:${t.authId}:${[...t.dataId].sort((a, b) => a - b).join(",")}`; +} + +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 + 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[]; preserved: GrantTuple[] } + +export function diffGrants(desired: GrantTuple[], actual: GrantTuple[]): 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 + // unmanaged: keep them out of the diff and surface them as an informational `preserved` note. + const managedActual = actual.filter((t) => t.type === "grant"); + const preserved = actual.filter((t) => t.type !== "grant"); + 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 }; +} diff --git a/src/permissions/plan.ts b/src/permissions/plan.ts new file mode 100644 index 0000000..5d94616 --- /dev/null +++ b/src/permissions/plan.ts @@ -0,0 +1,71 @@ +/** + * The permission plan: resolve desired grants to (authId, dataId) tuples, + * bulk-fetch actuals per distinct domainType, filter to managed domainIds + * (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 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 type { DesiredPermission } from "./types.js"; + +export interface PermissionPlanItem { key: string; domainType: DomainType; domainId: number; diff: GrantDiff } + +/** + * Fan out each grant to (authId, dataId) tuples. ChurchTools reads a scoped grant back as + * ONE ROW PER dataId with a scalar `dataId` (see `normalizeActual`), so a desired tuple with + * `dataId.length >= 2` can never equal any actual tuple and would churn forever. To match the + * scalar read shape, a scoped grant `{right, scope:[a,b]}` becomes TWO single-dataId tuples, + * not one two-element tuple. + */ +export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[] { + return p.grants.flatMap((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 (typeof g === "string") { + // A scoped right declared as a bare string would emit `dataId: []` — a silent GLOBAL grant. + // Refuse it: a scoped right must be declared as `{ right, scope: [...] }` so the scope is explicit. + if (entry.scopeField != null) { + throw new Error( + `${p.domainType} "${p.key}": "${name}" is a scoped right (scopeField "${entry.scopeField}") and must be declared as { right: "${name}", scope: [...] } — a bare string would grant it globally.`, + ); + } + 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.`); + } + return resolveScope(g.scope, state).map((id) => ({ authId: entry.authId, dataId: [id], type: "grant" as const })); + }); +} + +export async function buildPermissionPlan( + client: Pick, state: State, permissions: DesiredPermission[], +): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[] }> { + const items: PermissionPlanItem[] = []; + const fetchErrors: string[] = []; + // one bulk fetch per distinct domainType + const byType = new Map(); + for (const dt of new Set(permissions.map((p) => p.domainType))) { + try { + byType.set(dt, await client.get(`/permissions/${dt}`)); + } catch (err) { + const message = err instanceof CtApiError ? `${err.status}` : (err as Error).message; + fetchErrors.push(`permissions ${dt}: ${message}`); + byType.set(dt, null); + } + } + for (const p of permissions) { + const all = byType.get(p.domainType); + if (all == null) continue; // fetch failed for this domainType — recorded above + const actual = normalizeActual(all.filter((r) => r.domainId === p.domainId)); + items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state), actual) }); + } + return { items, fetchErrors }; +} diff --git a/src/permissions/render.ts b/src/permissions/render.ts new file mode 100644 index 0000000..fb1e9be --- /dev/null +++ b/src/permissions/render.ts @@ -0,0 +1,51 @@ +/** + * Human-readable, Terraform-style rendering of a permission plan. + * Mirrors `src/engine/render.ts`'s picocolors conventions. + */ +import pc from "picocolors"; +import type { PermissionPlanItem } from "./plan.js"; +import type { GrantTuple } from "./grants.js"; + +function fmtTuple(t: GrantTuple): string { + const scope = t.dataId.length ? ` scope=[${t.dataId.join(",")}]` : ""; + return `authId=${t.authId}${scope} (${t.type})`; +} + +export function renderPermissionPlan(items: PermissionPlanItem[]): string { + const changed = items.filter((i) => i.diff.toPut.length > 0 || i.diff.toDelete.length > 0); + + 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.)` + : ""; + return pc.green(`No permission changes. Desired grants match ChurchTools.${suffix}`); + } + + const lines: string[] = []; + let totalGrant = 0; + let totalRevoke = 0; + + for (const item of changed) { + const grantCount = item.diff.toPut.length; + const revokeCount = item.diff.toDelete.length; + totalGrant += grantCount; + totalRevoke += revokeCount; + lines.push( + ` ${item.domainType} #${item.domainId} (${item.key}): ${pc.green(`+${grantCount} grant(s)`)}, ${pc.red(`-${revokeCount} remove(s)`)}`, + ); + for (const t of item.diff.toPut) { + lines.push(` ${pc.green("+")} ${fmtTuple(t)}`); + } + for (const t of item.diff.toDelete) { + lines.push(` ${pc.red("-")} ${fmtTuple(t)}`); + } + 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.`)); + return lines.join("\n"); +} diff --git a/src/permissions/scope.ts b/src/permissions/scope.ts new file mode 100644 index 0000000..cf788db --- /dev/null +++ b/src/permissions/scope.ts @@ -0,0 +1,14 @@ +import type { State } from "../state/state.js"; + +/** Resolve scope logical keys to sorted group dataIds. MVP: scope keys must be managed groups. */ +export function resolveScope(scopeKeys: string[], state: State): number[] { + const ids: 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.`); + } + ids.push(m.id); + } + return ids.sort((a, b) => a - b); +} diff --git a/src/permissions/types.ts b/src/permissions/types.ts new file mode 100644 index 0000000..f4cb7b0 --- /dev/null +++ b/src/permissions/types.ts @@ -0,0 +1,16 @@ +/** + * Desired-state shape for permission declarations (`ct.groupRole` / + * `ct.groupTypeRole` in the config DSL). These reconcile through their own + * subsystem (see `src/permissions/grants.ts`), not the group engine, so they + * are collected as a separate list from `DesiredResource[]`. + */ +import type { DomainType } from "./grants.js"; + +export type Grant = string | { right: string; scope: string[] }; + +export interface DesiredPermission { + key: string; + domainType: DomainType; + domainId: number; + grants: Grant[]; +} diff --git a/tests/apply-refresh-command.test.ts b/tests/apply-refresh-command.test.ts new file mode 100644 index 0000000..05e859d --- /dev/null +++ b/tests/apply-refresh-command.test.ts @@ -0,0 +1,117 @@ +/** + * Command-level gating for `ct apply --refresh`: mocks the whole apply + * pipeline (session, buildPlan, executePlan, loadConfig) so we can prove the + * `--refresh` flag is what decides whether the per-group refresh runs — not + * just that the extracted helper (tests/apply-refresh.test.ts) is correct in + * isolation. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Plan } from "../src/engine/types.js"; + +interface Call { + method: string; + path: string; +} + +const calls: Call[] = []; +const requestMock = vi.fn(async (method: string, path: string) => { + calls.push({ method, path }); + if (path === "/dynamicgroups/42/refresh") { + return [{ created: 1, updated: 2, deleted: 0 }]; + } + return {}; +}); + +const plan: Plan = { + items: [ + { + type: "group", + key: "dyn_a", + id: 42, + action: "update", + changes: [{ field: "dynamic", from: undefined, to: { status: "active", ruleset: {} } }], + }, + ], +}; + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client: { request: requestMock }, me: { id: 1 } })), +})); + +vi.mock("../src/config/load.js", () => ({ + DEFAULT_CONFIG_PATH: "ct.config.ts", + resolveConfigPath: (explicit?: string) => explicit ?? "ct.config.ts", + loadConfig: vi.fn(async () => ({ resources: [], permissions: [] })), +})); + +vi.mock("../src/engine/build.js", () => ({ + buildPlan: vi.fn(async () => ({ plan, actual: new Map(), fetchErrors: [] })), +})); + +vi.mock("../src/engine/execute.js", () => ({ + executePlan: vi.fn(async () => ({ created: [], updated: ["dyn_a"], skippedDeletes: [] })), +})); + +vi.mock("../src/engine/backup.js", () => ({ + writeBackup: vi.fn(async () => "backup.json"), +})); + +const { applyCommand } = await import("../src/commands/apply.js"); +const { loadState, saveState, emptyState } = await import("../src/state/state.js"); + +const statePath = join(tmpdir(), `ct-cli-apply-refresh-${process.pid}.json`); +const HOST = "https://eqrm.church.tools"; + +async function runApply(args: string[]): Promise { + await applyCommand().parseAsync(args, { from: "user" }); +} + +const originalHost = process.env.CT_HOST; + +beforeEach(async () => { + calls.length = 0; + requestMock.mockClear(); + process.env.CT_HOST = HOST; + const state = emptyState(HOST); + state.resources.dyn_a = { + type: "group", + id: 42, + key: "dyn_a", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; + await saveState(statePath, state); +}); + +afterEach(async () => { + if (originalHost === undefined) { + delete process.env.CT_HOST; + } else { + process.env.CT_HOST = originalHost; + } + await rm(statePath, { force: true }); +}); + +describe("ct apply --refresh (command-level gating)", () => { + it("with --refresh: POSTs /dynamicgroups/{id}/refresh once for the changed dynamic group", async () => { + await runApply(["--state", statePath, "--auto-approve", "--refresh"]); + const refreshCalls = calls.filter((c) => c.path.startsWith("/dynamicgroups/")); + expect(refreshCalls).toEqual([{ method: "POST", path: "/dynamicgroups/42/refresh" }]); + const state = await loadState(statePath, HOST); + expect(state.resources.dyn_a?.id).toBe(42); + }); + + it("never calls the all-groups /dynamicgroups/refresh endpoint", async () => { + await runApply(["--state", statePath, "--auto-approve", "--refresh"]); + expect(calls.some((c) => c.path === "/dynamicgroups/refresh")).toBe(false); + }); + + it("without --refresh: no dynamicgroups refresh call at all", async () => { + await runApply(["--state", statePath, "--auto-approve"]); + expect(calls.some((c) => c.path.startsWith("/dynamicgroups/"))).toBe(false); + }); +}); diff --git a/tests/apply-refresh.test.ts b/tests/apply-refresh.test.ts new file mode 100644 index 0000000..dd387eb --- /dev/null +++ b/tests/apply-refresh.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from "vitest"; +import { refreshChangedDynamicGroups } from "../src/commands/apply.js"; +import { emptyState, type State } from "../src/state/state.js"; +import type { Plan } from "../src/engine/types.js"; + +interface Call { + method: string; + path: string; + body?: unknown; +} + +function recorder(responses: Record = {}) { + const calls: Call[] = []; + const client = { + request: async (method: string, path: string, body?: unknown): Promise => { + calls.push({ method, path, body }); + const key = `${method} ${path}`; + return (responses[key] ?? {}) as T; + }, + }; + return { client, calls }; +} + +function stateWith(entries: Record): State { + const state = emptyState("h"); + for (const [key, id] of Object.entries(entries)) { + state.resources[key] = { + type: "group", + id, + key, + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; + } + return state; +} + +describe("refreshChangedDynamicGroups", () => { + it("POSTs /dynamicgroups/{id}/refresh once for each item whose changes include a dynamic field", async () => { + const state = stateWith({ dyn_a: 42, dyn_b: 43, plain: 44 }); + const plan: Plan = { + items: [ + { + type: "group", + key: "dyn_a", + id: 42, + action: "update", + changes: [{ field: "dynamic", from: undefined, to: { status: "active", ruleset: {} } }], + }, + { + type: "group", + key: "dyn_b", + id: 43, + action: "update", + changes: [ + { field: "name", from: "old", to: "new" }, + { field: "dynamic", from: undefined, to: { status: "active", ruleset: {} } }, + ], + }, + { + type: "group", + key: "plain", + id: 44, + action: "update", + changes: [{ field: "name", from: "old", to: "new" }], + }, + { type: "group", key: "noop", id: 45, action: "no-op", changes: [] }, + { type: "group", key: "gone", id: 46, action: "delete", changes: [] }, + ], + }; + const { client, calls } = recorder({ + "POST /dynamicgroups/42/refresh": [{ created: 1, updated: 2, deleted: 0 }], + "POST /dynamicgroups/43/refresh": [{ created: 0, updated: 1, deleted: 1 }], + }); + + await refreshChangedDynamicGroups(plan, state, client); + + 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 }); + }); + + it("never calls the all-groups /dynamicgroups/refresh endpoint", async () => { + const state = stateWith({ dyn_a: 42 }); + const plan: Plan = { + items: [ + { + type: "group", + key: "dyn_a", + id: 42, + action: "update", + changes: [{ field: "dynamic", from: undefined, to: { status: "active", ruleset: {} } }], + }, + ], + }; + const { client, calls } = recorder(); + await refreshChangedDynamicGroups(plan, state, client); + expect(calls.some((c) => c.path === "/dynamicgroups/refresh")).toBe(false); + }); + + it("does nothing when no plan item changes the dynamic field", async () => { + const state = stateWith({ plain: 44 }); + const plan: Plan = { + items: [ + { + type: "group", + key: "plain", + id: 44, + action: "update", + changes: [{ field: "name", from: "old", to: "new" }], + }, + ], + }; + const { client, calls } = recorder(); + await refreshChangedDynamicGroups(plan, state, client); + expect(calls).toHaveLength(0); + }); + + it("skips the refresh POST for a dynamic change that demotes the group to status:none", async () => { + const state = stateWith({ dyn_a: 42, dyn_b: 43 }); + const plan: Plan = { + items: [ + { + type: "group", + key: "dyn_a", + id: 42, + action: "update", + changes: [{ field: "dynamic", from: { status: "active", ruleset: {} }, to: { status: "none", ruleset: {} } }], + }, + { + type: "group", + key: "dyn_b", + id: 43, + action: "update", + changes: [{ field: "dynamic", from: undefined, to: { status: "active", ruleset: {} } }], + }, + ], + }; + const { client, calls } = recorder({ + "POST /dynamicgroups/43/refresh": [{ created: 1, updated: 0, deleted: 0 }], + }); + await refreshChangedDynamicGroups(plan, state, client); + const refreshCalls = calls.filter((c) => c.path.startsWith("/dynamicgroups/")); + expect(refreshCalls).toEqual([{ method: "POST", path: "/dynamicgroups/43/refresh", body: undefined }]); + }); + + it("one group's refresh POST failing does not prevent the next group's refresh", async () => { + const state = stateWith({ dyn_a: 42, dyn_b: 43 }); + const plan: Plan = { + items: [ + { + type: "group", + key: "dyn_a", + id: 42, + action: "update", + changes: [{ field: "dynamic", from: undefined, to: { status: "active", ruleset: {} } }], + }, + { + type: "group", + key: "dyn_b", + id: 43, + action: "update", + changes: [{ field: "dynamic", from: undefined, to: { status: "active", ruleset: {} } }], + }, + ], + }; + const calls: Call[] = []; + const client = { + request: async (method: string, path: string, body?: unknown): Promise => { + calls.push({ method, path, body }); + if (path === "/dynamicgroups/42/refresh") throw new Error("boom"); + return [{ created: 1, updated: 0, deleted: 0 }] as T; + }, + }; + await expect(refreshChangedDynamicGroups(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 }); + }); + + it("skips a changed-dynamic item whose id is not yet resolvable in state (explicit undefined check, not truthiness)", async () => { + // id 0 is a legitimate CT id — make sure it is NOT treated as "missing". + const state = stateWith({ dyn_zero: 0 }); + const plan: Plan = { + items: [ + { + type: "group", + key: "dyn_zero", + id: 0, + action: "update", + changes: [{ field: "dynamic", from: undefined, to: { status: "active", ruleset: {} } }], + }, + { + type: "group", + key: "not_in_state", + id: null, + action: "create", + changes: [{ field: "dynamic", from: undefined, to: { status: "active", ruleset: {} } }], + }, + ], + }; + const { client, calls } = recorder({ "POST /dynamicgroups/0/refresh": [{ created: 0, updated: 0, deleted: 0 }] }); + await refreshChangedDynamicGroups(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 new file mode 100644 index 0000000..2130a0e --- /dev/null +++ b/tests/blueprint.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { evaluateConfig, type ConfigContext } from "../src/config/context.js"; +import { orderKeys } from "../src/engine/graph.js"; + +// A blueprint instantiated across two campuses — the core #7 guarantee. +const blueprint = (ct: ConfigContext): void => { + for (const c of ["mainz", "berlin"]) { + ct.campus({ key: c, name: `Campus ${c}`, shorty: c.slice(0, 3) }); + ct.group({ key: `${c}_lead`, name: `${c} lead`, groupTypeId: 2, parents: [] }); + ct.group({ key: `${c}_team`, name: `${c} team`, groupTypeId: 2, parents: [`${c}_lead`] }); + } +}; + +describe("campus blueprint", () => { + it("instantiates the full structure for every campus", async () => { + const { resources } = await evaluateConfig(blueprint); + const keys = resources.map((r) => r.key).sort(); + expect(keys).toEqual(["berlin", "berlin_lead", "berlin_team", "mainz", "mainz_lead", "mainz_team"]); + }); + + it("produces a correctly ordered plan: campuses before groups, parents before children", async () => { + const { resources } = await evaluateConfig(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}_lead`)).toBeLessThan(pos(`${c}_team`)); // parent before child (intra-tier dependency) + } + }); + + it("rejects a blueprint whose managed parent is undeclared (typo guard)", async () => { + const broken = (ct: ConfigContext) => { + ct.group({ key: "g", name: "g", groupTypeId: 2, parents: ["missing"] }); + }; + await expect(evaluateConfig(broken)).rejects.toThrow(/not declared/i); + }); +}); diff --git a/tests/context.test.ts b/tests/context.test.ts index 36a67de..3d0dcce 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { createContext, evaluateConfig } from "../src/config/context.js"; +import { createContext, evaluateConfig, type ConfigContext } from "../src/config/context.js"; import { isKnownType } from "../src/engine/graph.js"; describe("config context", () => { @@ -113,7 +113,7 @@ describe("config context", () => { }); it("evaluateConfig runs a module against a fresh context (blueprints + loops)", async () => { - const resources = await evaluateConfig((ct) => { + const { resources } = await evaluateConfig((ct) => { for (const c of ["mainz", "berlin"]) { ct.campus({ key: c, name: c }); ct.group({ key: `${c}_kids`, name: `${c} kids`, parent: c }); @@ -123,9 +123,44 @@ describe("config context", () => { }); }); +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: {} } }, + }); + 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 + }); + + 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, + ); + }); + + 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); + }); + + it("rejects a null dynamic block with a clean error", async () => { + const { ct } = createContext(); + expect(() => ct.group({ key: "g", name: "G", dynamic: null } as never)) + .toThrow(/dynamic/i); + }); +}); + describe("preventDestroy lifecycle flag", () => { it("is carried on the resource but kept out of managed fields", async () => { - const resources = await evaluateConfig((ct) => { + const { resources } = await evaluateConfig((ct) => { ct.group({ key: "kids_lead", name: "Kids Leitung", preventDestroy: true }); }); const group = resources.find((r) => r.key === "kids_lead")!; @@ -134,9 +169,56 @@ describe("preventDestroy lifecycle flag", () => { }); it("defaults to undefined when not declared", async () => { - const resources = await evaluateConfig((ct) => { + const { resources } = await evaluateConfig((ct) => { ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); }); expect(resources[0]!.preventDestroy).toBeUndefined(); }); }); + +describe("permission declarations", () => { + it("collects groupRole / groupTypeRole with validated grants", async () => { + const mod = (ct: ConfigContext) => { + 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} + expect(permissions).toHaveLength(2); + expect(permissions[0]).toMatchObject({ key: "leiter_tpl", domainType: "group_type_role", domainId: 8 }); + 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: ConfigContext) => ct.groupRole({ key: "x", id: "nope", grants: [] } as never)), + ).rejects.toThrow(/id.*number/i); + await expect( + evaluateConfig((ct: ConfigContext) => ct.groupRole({ key: "x", id: 1, grants: [""] })), + ).rejects.toThrow(/grant/i); + }); + + it("rejects a non-finite id (NaN)", async () => { + await expect( + evaluateConfig((ct: ConfigContext) => ct.groupRole({ key: "x", id: NaN, grants: [] })), + ).rejects.toThrow(/id.*number/i); + }); + + it("rejects two declarations targeting the same (domainType, domainId)", async () => { + await expect( + evaluateConfig((ct: ConfigContext) => { + ct.groupTypeRole({ key: "leiter_tpl", id: 8, grants: ["churchgroup:view group"] }); + ct.groupTypeRole({ key: "x", id: 8, grants: ["churchdb:view group members"] }); + }), + ).rejects.toThrow(/Duplicate permission target.*group_type_role #8.*"leiter_tpl".*"x"/s); + }); + + it("allows the same domainId across different domainTypes (no false-positive dedup)", async () => { + const { permissions } = await evaluateConfig((ct: ConfigContext) => { + ct.groupTypeRole({ key: "a", id: 8, grants: ["churchgroup:view group"] }); + ct.groupRole({ key: "b", id: 8, grants: ["churchgroup:view group"] }); + }); + expect(permissions).toHaveLength(2); + }); +}); diff --git a/tests/dynamic.integration.test.ts b/tests/dynamic.integration.test.ts new file mode 100644 index 0000000..c14da88 --- /dev/null +++ b/tests/dynamic.integration.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { normalizeRuleset } from "../src/engine/dynamic.js"; +import { authedSession } from "../src/api/session.js"; + +const live = process.env.CT_LIVE === "1"; +const GID = Number(process.env.CT_DYNAMIC_FIXTURE_GID ?? "0"); // the group id from Task 1 + +describe.runIf(live)("dynamic round-trip (live)", () => { + it("read → normalize → write-back is a no-op (drift-free)", async () => { + const { client } = await authedSession(); + const before = await client.get>(`/dynamicgroups/${GID}/ruleset`); + const normalized = normalizeRuleset(before); + await client.request("PUT", `/dynamicgroups/${GID}/ruleset`, { dynamicGroupRuleSet: normalized }); + const after = await client.get>(`/dynamicgroups/${GID}/ruleset`); + expect(normalizeRuleset(after)).toEqual(normalized); // writing back the normalized form does not drift + }); + + it("the committed fixture matches what the instance still returns", async () => { + const { client } = await authedSession(); + const raw = JSON.parse(readFileSync("tests/fixtures/dynamic/ruleset.get.json", "utf8")); + const fixture = raw.data ?? raw; + const nowRuleset = await client.get>(`/dynamicgroups/${GID}/ruleset`); + expect(normalizeRuleset(nowRuleset)).toEqual(normalizeRuleset(fixture)); + }); +}); diff --git a/tests/dynamic.test.ts b/tests/dynamic.test.ts new file mode 100644 index 0000000..5b39124 --- /dev/null +++ b/tests/dynamic.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { normalizeRuleset, stripCosmeticLabels, coerceScalars, normalizeDynamic } from "../src/engine/dynamic.js"; + +describe("stripCosmeticLabels", () => { + it("unwraps a dterm with a string label to its expr, recursively", () => { + const input = { and: [{ dterm: ["Campus", { "==": [{ var: "ctgroup.campusId" }, 1] }] }] }; + 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" }] }] }; + 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] }); + }); + 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"] }); + }); +}); + +describe("normalizeRuleset", () => { + it("drops read-only timestamps and the PUT envelope, and is idempotent", () => { + const withEnvelope = { dynamicGroupRuleSet: { description: "x", dynamicGroupUpdateStarted: "t", process: {}, query: {} } }; + const once = normalizeRuleset(withEnvelope); + expect(once).not.toHaveProperty("dynamicGroupUpdateStarted"); + expect(once).not.toHaveProperty("dynamicGroupRuleSet"); + expect(normalizeRuleset(once)).toEqual(once); // idempotent: read→normalize→normalize == normalize + }); + + it("unwraps the single-element array that GET returns", () => { + const arr = [{ description: "x", process: {}, query: {} }]; + expect(normalizeRuleset(arr)).toEqual(normalizeRuleset(arr[0])); + }); + + 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 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.query).toEqual({ "==": [{ var: "ctgroup.id" }, 112] }); // filter operand coerced + }); + + it("read-then-normalize of every live fixture is stable and label-free", () => { + 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 + } + }); +}); + +describe("normalizeDynamic", () => { + it("normalizes status + ruleset together", () => { + const out = normalizeDynamic({ status: "manual", ruleset: { description: "x", query: {}, process: {} } }); + expect(out.status).toBe("manual"); + expect(out.ruleset).toHaveProperty("description", "x"); + }); +}); diff --git a/tests/examples.test.ts b/tests/examples.test.ts new file mode 100644 index 0000000..5c2c410 --- /dev/null +++ b/tests/examples.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { loadConfig } from "../src/config/load.js"; +import { resolveAuthId } from "../src/permissions/catalog.js"; + +// Make the shipped examples part of the tested surface: each must load as a valid config, and +// every permission right it declares must resolve in the catalog. An unknown right name (e.g. a +// typo like "churchgroup:edit group members") fails the suite instead of only surfacing at plan time. +const here = dirname(fileURLToPath(import.meta.url)); +const examplesDir = join(here, "..", "examples"); +const configFiles = readdirSync(examplesDir).filter((f) => f.endsWith(".config.ts")); + +describe("examples/*.config.ts", () => { + it("finds at least one example config to guard", () => { + expect(configFiles.length).toBeGreaterThan(0); + }); + + it.each(configFiles)("%s loads and every permission right resolves in the catalog", async (file) => { + const { permissions } = await loadConfig(join(examplesDir, file)); + for (const p of permissions) { + for (const g of p.grants) { + const name = typeof g === "string" ? g : g.right; + expect(() => resolveAuthId(name), `${file}: right "${name}"`).not.toThrow(); + } + } + }); +}); diff --git a/tests/fixtures/dynamic/README.md b/tests/fixtures/dynamic/README.md new file mode 100644 index 0000000..6201c72 --- /dev/null +++ b/tests/fixtures/dynamic/README.md @@ -0,0 +1,29 @@ +# Dynamic-group ruleset/status fixtures + +Captured **read-only** on 2026-07-08 from the production instance +`eqrm.church.tools` (CT 3.134.0), which has 68 real dynamic groups. No writes +were made to produce these; they are `GET` responses saved verbatim. + +## Files + +- `ruleset.get.json` — canonical fixture (copy of `ruleset-683.get.json`). +- `ruleset-683.get.json` — group #683: string **and** object `dterm` labels, nested `or`/`and`/`!`, `oneof` over string-id arrays, `isnull`, the `"true and"` operator. +- `ruleset-2022.get.json` — group #2022: `isnull`, `subgroups` (with `levelfrom`/`levelto`), `person.campusId`, `groupmember.groupMemberStatus == "active"`, mixed int/string ids. +- `ruleset-1092.get.json` — group #1092: doubly-nested `and`, two `oneof role.id` branches. +- `status.get.json` — `{ "dynamicGroupStatus": "active" }` (the shape `GET …/status` returns). + +## Shape notes (source of truth for the normalizer) + +- **`GET /dynamicgroups/{id}/ruleset` returns a single-element ARRAY `[RuleSet]`** — NOT the bare object the OpenAPI schema advertises. The normalizer must unwrap the array (take element 0). +- **`RuleSet`** = `{ description, shorty, personIdFieldName, importance, query, process }`. +- **`query`** is a ChurchQuery envelope: `{ method: "ChurchQuery", params: { groupBy, filter, primaryEntityAlias, responseFields } }`. The JSONLogic tree is in `params.filter`. +- **`dterm: [label, expr]`** wraps a subtree with a **cosmetic** label. `label` is either a plain string (`"Nur aktive Personen"`) or an object (`{ title, stereotype? }`, where `title` may be an i18n key like `group.automatic-membership.default-archived.title`). The label is never evaluated → strip it, keep `expr`. +- **int/string inconsistency is pervasive:** the same logical value appears as both `1` and `"1"`; `oneof` id arrays are sometimes `[1]` and sometimes `["112","8"]`. The normalizer coerces numeric strings to numbers before diffing. +- **operators seen:** `and`, `or`, `!`, `==`, `oneof`, `isnull`, `isnotnull`, `subgroups`, `"true and"`. +- **`process`** = `{ groupOnly, queryResultOnly, groupAndQueryResult }` (any may be `{}`). Each maps a member status (`active` | `to_delete` | `none` | …) → `{ handleMembership: { groupMemberStatus, groupTypeRoleId? } }`. + +## Round-trip (write) tests + +The write-side round-trip test (plan Task 6) is gated behind `CT_LIVE=1` and a +`CT_DYNAMIC_FIXTURE_GID` and must target a **dev** instance — **never run it +against this production login.** diff --git a/tests/fixtures/dynamic/ruleset-1092.get.json b/tests/fixtures/dynamic/ruleset-1092.get.json new file mode 100644 index 0000000..0b02638 --- /dev/null +++ b/tests/fixtures/dynamic/ruleset-1092.get.json @@ -0,0 +1,169 @@ +[ + { + "description": "Automatische Mitgliedschaft für Gruppe #1092", + "shorty": "Autom. Mitgliedschaft Gruppe #1092", + "personIdFieldName": "person.id", + "importance": 0, + "query": { + "method": "ChurchQuery", + "params": { + "groupBy": [ + "person.id" + ], + "filter": { + "and": [ + { + "and": [ + { + "==": [ + { + "var": "person.isArchived" + }, + 0 + ] + } + ] + }, + { + "or": [ + { + "dterm": [ + { + "stereotype": [ + "groupmembership" + ], + "title": "Mitgliedschaft in einer Gruppe" + }, + { + "and": [ + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.groupStatusId" + }, + [ + 1 + ] + ] + }, + { + "oneof": [ + { + "var": "ctgroup.id" + }, + [ + "112", + "8" + ] + ] + } + ] + }, + { + "and": [ + { + "oneof": [ + { + "var": "role.id" + }, + [ + "84", + "85", + "17", + "16" + ] + ] + } + ] + } + ] + } + ] + }, + { + "dterm": [ + { + "stereotype": [ + "groupmembership" + ], + "title": "Mitgliedschaft in einer Gruppe" + }, + { + "and": [ + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.groupStatusId" + }, + [ + 1 + ] + ] + }, + { + "oneof": [ + { + "var": "ctgroup.id" + }, + [ + "1246" + ] + ] + } + ] + }, + { + "and": [ + { + "oneof": [ + { + "var": "role.id" + }, + [ + "16", + "17" + ] + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "primaryEntityAlias": "person", + "responseFields": [ + "person.id", + "person.firstName", + "person.lastName" + ] + } + }, + "process": { + "groupOnly": { + "active": { + "handleMembership": { + "groupMemberStatus": "none" + } + } + }, + "queryResultOnly": { + "none": { + "handleMembership": { + "groupMemberStatus": "active", + "groupTypeRoleId": 66 + } + } + }, + "groupAndQueryResult": {} + } + } +] diff --git a/tests/fixtures/dynamic/ruleset-2022.get.json b/tests/fixtures/dynamic/ruleset-2022.get.json new file mode 100644 index 0000000..e79e157 --- /dev/null +++ b/tests/fixtures/dynamic/ruleset-2022.get.json @@ -0,0 +1,170 @@ +[ + { + "description": "Automatische Mitgliedschaft für Gruppe #2022", + "shorty": "Autom. Mitgliedschaft Gruppe #2022", + "personIdFieldName": "person.id", + "importance": 0, + "query": { + "method": "ChurchQuery", + "params": { + "groupBy": [ + "person.id" + ], + "filter": { + "and": [ + { + "and": [ + { + "==": [ + { + "var": "person.isArchived" + }, + 0 + ] + }, + { + "isnull": [ + { + "var": "person.dateOfDeath" + } + ] + }, + { + "==": [ + { + "var": "person.campusId" + }, + 0 + ] + } + ] + }, + { + "dterm": [ + { + "stereotype": [ + "groupmembership" + ], + "title": "Mitgliedschaft in einer Gruppe" + }, + { + "and": [ + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.groupStatusId" + }, + [ + 1 + ] + ] + }, + { + "==": [ + { + "var": "ctgroup.groupTypeId" + }, + 1 + ] + }, + { + "dterm": [ + { + "title": "Ist Untergruppe von" + }, + { + "subgroups": [ + { + "levelfrom": 1, + "levelto": 999 + }, + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.id" + }, + [ + "1869" + ] + ] + } + ] + } + ] + } + ] + }, + { + "==": [ + { + "var": "ctgroup.campusId" + }, + 0 + ] + } + ] + }, + { + "and": [ + { + "oneof": [ + { + "var": "role.id" + }, + [ + "8", + "9", + "10", + "92" + ] + ] + }, + { + "==": [ + { + "var": "groupmember.groupMemberStatus" + }, + "active" + ] + } + ] + } + ] + } + ] + } + ] + }, + "primaryEntityAlias": "person", + "responseFields": [ + "person.id", + "person.firstName", + "person.lastName" + ] + } + }, + "process": { + "groupOnly": { + "active": { + "handleMembership": { + "groupMemberStatus": "none", + "groupTypeRoleId": 29 + } + } + }, + "queryResultOnly": { + "none": { + "handleMembership": { + "groupMemberStatus": "active", + "groupTypeRoleId": 29 + } + } + }, + "groupAndQueryResult": {} + } + } +] diff --git a/tests/fixtures/dynamic/ruleset-683.get.json b/tests/fixtures/dynamic/ruleset-683.get.json new file mode 100644 index 0000000..1414982 --- /dev/null +++ b/tests/fixtures/dynamic/ruleset-683.get.json @@ -0,0 +1,198 @@ +[ + { + "description": "Automatische Mitgliedschaft für Gruppe #683", + "shorty": "Autom. Mitgliedschaft Gruppe #683", + "personIdFieldName": "person.id", + "importance": 0, + "query": { + "method": "ChurchQuery", + "params": { + "groupBy": [ + "person.id" + ], + "filter": { + "and": [ + { + "dterm": [ + "Mitarbeiter und Groups-Leiter", + { + "or": [ + { + "dterm": [ + { + "stereotype": [ + "groupmembership" + ], + "title": "Mitgliedschaft in einer Gruppe" + }, + { + "and": [ + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.groupStatusId" + }, + [ + 1 + ] + ] + }, + { + "oneof": [ + { + "var": "ctgroup.groupTypeId" + }, + [ + 2, + 12, + 16, + 19, + 1 + ] + ] + }, + { + "oneof": [ + { + "var": "ctgroup.campusId" + }, + [ + 0, + 9, + 17, + 18, + 23, + 26, + 29, + 32, + 33 + ] + ] + } + ] + }, + { + "and": [ + { + "oneof": [ + { + "var": "role.id" + }, + [ + "15", + "81", + "16", + "84", + "17", + "85", + "127", + "9", + "73", + "132", + "103", + "106", + "130" + ] + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "dterm": [ + "Keine Funktionsuser zählen", + { + "!": [ + { + "dterm": [ + { + "stereotype": [ + "groupmembership" + ], + "title": "Mitgliedschaft in einer Gruppe" + }, + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.id" + }, + [ + "1993" + ] + ] + }, + { + "true and": [] + } + ] + } + ] + } + ] + } + ] + }, + { + "dterm": [ + "Nur aktive Personen", + { + "and": [ + { + "==": [ + { + "var": "person.isArchived" + }, + 0 + ] + }, + { + "isnull": [ + { + "var": "person.dateOfDeath" + } + ] + } + ] + } + ] + } + ] + }, + "primaryEntityAlias": "person", + "responseFields": [ + "person.id", + "person.firstName", + "person.lastName" + ] + } + }, + "process": { + "groupOnly": { + "active": { + "handleMembership": { + "groupMemberStatus": "none" + } + } + }, + "queryResultOnly": { + "none": { + "handleMembership": { + "groupMemberStatus": "active" + } + } + }, + "groupAndQueryResult": {} + } + } +] diff --git a/tests/fixtures/dynamic/ruleset.get.json b/tests/fixtures/dynamic/ruleset.get.json new file mode 100644 index 0000000..1414982 --- /dev/null +++ b/tests/fixtures/dynamic/ruleset.get.json @@ -0,0 +1,198 @@ +[ + { + "description": "Automatische Mitgliedschaft für Gruppe #683", + "shorty": "Autom. Mitgliedschaft Gruppe #683", + "personIdFieldName": "person.id", + "importance": 0, + "query": { + "method": "ChurchQuery", + "params": { + "groupBy": [ + "person.id" + ], + "filter": { + "and": [ + { + "dterm": [ + "Mitarbeiter und Groups-Leiter", + { + "or": [ + { + "dterm": [ + { + "stereotype": [ + "groupmembership" + ], + "title": "Mitgliedschaft in einer Gruppe" + }, + { + "and": [ + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.groupStatusId" + }, + [ + 1 + ] + ] + }, + { + "oneof": [ + { + "var": "ctgroup.groupTypeId" + }, + [ + 2, + 12, + 16, + 19, + 1 + ] + ] + }, + { + "oneof": [ + { + "var": "ctgroup.campusId" + }, + [ + 0, + 9, + 17, + 18, + 23, + 26, + 29, + 32, + 33 + ] + ] + } + ] + }, + { + "and": [ + { + "oneof": [ + { + "var": "role.id" + }, + [ + "15", + "81", + "16", + "84", + "17", + "85", + "127", + "9", + "73", + "132", + "103", + "106", + "130" + ] + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "dterm": [ + "Keine Funktionsuser zählen", + { + "!": [ + { + "dterm": [ + { + "stereotype": [ + "groupmembership" + ], + "title": "Mitgliedschaft in einer Gruppe" + }, + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.id" + }, + [ + "1993" + ] + ] + }, + { + "true and": [] + } + ] + } + ] + } + ] + } + ] + }, + { + "dterm": [ + "Nur aktive Personen", + { + "and": [ + { + "==": [ + { + "var": "person.isArchived" + }, + 0 + ] + }, + { + "isnull": [ + { + "var": "person.dateOfDeath" + } + ] + } + ] + } + ] + } + ] + }, + "primaryEntityAlias": "person", + "responseFields": [ + "person.id", + "person.firstName", + "person.lastName" + ] + } + }, + "process": { + "groupOnly": { + "active": { + "handleMembership": { + "groupMemberStatus": "none" + } + } + }, + "queryResultOnly": { + "none": { + "handleMembership": { + "groupMemberStatus": "active" + } + } + }, + "groupAndQueryResult": {} + } + } +] diff --git a/tests/fixtures/dynamic/status.get.json b/tests/fixtures/dynamic/status.get.json new file mode 100644 index 0000000..ca08cd7 --- /dev/null +++ b/tests/fixtures/dynamic/status.get.json @@ -0,0 +1,3 @@ +{ + "dynamicGroupStatus": "active" +} diff --git a/tests/load.test.ts b/tests/load.test.ts index 9efc2c9..097c372 100644 --- a/tests/load.test.ts +++ b/tests/load.test.ts @@ -7,9 +7,10 @@ const here = dirname(fileURLToPath(import.meta.url)); describe("loadConfig", () => { it("loads a .ts config that default-exports a function", async () => { - const resources = await loadConfig(join(here, "fixtures/sample.config.ts")); + const { resources, permissions } = await loadConfig(join(here, "fixtures/sample.config.ts")); expect(resources.map((r) => r.key)).toEqual(["mainz", "kids_lead"]); expect(resources[1]).toMatchObject({ type: "group", parent: "mainz", dependsOn: ["mainz"] }); + expect(permissions).toEqual([]); }); it("rejects a config that does not default-export a function", async () => { diff --git a/tests/permission-apply.test.ts b/tests/permission-apply.test.ts new file mode 100644 index 0000000..32400f0 --- /dev/null +++ b/tests/permission-apply.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from "vitest"; +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" }], + preserved: [], + }, + }], { 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" }); + }); +}); diff --git a/tests/permission-catalog.test.ts b/tests/permission-catalog.test.ts new file mode 100644 index 0000000..8a28a19 --- /dev/null +++ b/tests/permission-catalog.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { resolveAuthId, loadCatalog } from "../src/permissions/catalog.js"; + +describe("permission catalog", () => { + it("resolves a known global right to its authId", () => { + const e = resolveAuthId("churchgroup:view"); + expect(e.authId).toBe(1101); + expect(e.scopeField).toBeNull(); + }); + it("resolves a scoped right and exposes its scopeField", () => { + const e = resolveAuthId("churchgroup:view group"); + expect(e.authId).toBe(1104); + 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); + }); + it("loads the whole catalog (187 rights)", () => { + expect(Object.keys(loadCatalog()).length).toBeGreaterThanOrEqual(180); + }); +}); diff --git a/tests/permission-grants.test.ts b/tests/permission-grants.test.ts new file mode 100644 index 0000000..b21774d --- /dev/null +++ b/tests/permission-grants.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { normalizeActual, diffGrants, tupleKey } from "../src/permissions/grants.js"; + +describe("normalizeActual", () => { + it("coerces scalar dataId to a sorted array and drops baseline + inherited rows", () => { + const rows = [ + { 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 + ]; + expect(normalizeActual(rows)).toEqual([ + { authId: 1104, dataId: [3], type: "grant" }, + { authId: 1101, dataId: [], type: "grant" }, + ]); + }); +}); + +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 + ]; + const actual = [ + { authId: 1104, dataId: [3, 7], type: "grant" as const }, // same tuple, different order + { 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" })]); + expect(d.toDelete.map(tupleKey)).toEqual([tupleKey({ authId: 2000, dataId: [], type: "grant" })]); + expect(d.preserved).toEqual([]); + }); + + it("never deletes a pre-existing revoke (deny) row — surfaces it as preserved instead", () => { + // desiredTuples only ever emits type:"grant", so a revoke row has no desired counterpart. + // 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 + ]; + const d = diffGrants(desired, actual); + expect(d.toPut).toEqual([]); + expect(d.toDelete).toEqual([]); // the revoke row is NOT deleted + expect(d.preserved).toEqual([{ authId: 1105, dataId: [7], type: "revoke" }]); + }); +}); diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts new file mode 100644 index 0000000..eb2d112 --- /dev/null +++ b/tests/permission-plan.test.ts @@ -0,0 +1,84 @@ +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" }, + 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); + expect(tuples).toEqual([ + { authId: 1113, dataId: [], type: "grant" }, + { authId: 1104, dataId: [42], type: "grant" }, + ]); + }); + + 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), + ).toThrow(/is a scoped right.*must be declared as \{ right/is); + }); + 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 + }); + + 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); + // resolveScope sorts resolved dataIds ascending (7 < 42), independent of scope-key order. + expect(tuples).toEqual([ + { authId: 1104, dataId: [7], type: "grant" }, + { authId: 1104, dataId: [42], type: "grant" }, + ]); + expect(tuples.every((t) => t.dataId.length <= 1)).toBe(true); + }); + + 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), + ).toThrow(/not a scoped right/); + }); +}); + +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"] }]); + expect(fetchErrors).toEqual([]); + 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"] }, + ]}]); + expect(fetchErrors).toEqual([]); + expect(items[0]?.diff.toPut).toEqual([]); + expect(items[0]?.diff.toDelete).toEqual([]); + }); +}); diff --git a/tests/permission-scope.test.ts b/tests/permission-scope.test.ts new file mode 100644 index 0000000..155f7bc --- /dev/null +++ b/tests/permission-scope.test.ts @@ -0,0 +1,17 @@ +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" }, +}}; + +describe("resolveScope", () => { + it("maps managed group keys to sorted ids", () => { + expect(resolveScope(["other", "kids_area"], state)).toEqual([7, 42]); + }); + it("throws for a key that is not a managed group", () => { + expect(() => resolveScope(["nope"], state)).toThrow(/scope key "nope"/i); + }); +}); diff --git a/tests/permission.integration.test.ts b/tests/permission.integration.test.ts new file mode 100644 index 0000000..6361086 --- /dev/null +++ b/tests/permission.integration.test.ts @@ -0,0 +1,112 @@ +/** + * Gated, opt-in live round-trip for the permissions engine (Task 7). + * + * Read-only block (CT_LIVE=1): read a `group_type_role`'s current grants from + * a live instance, reconstruct a config that declares exactly those + * user-authored grants, and assert `buildPermissionPlan` diffs to a no-op + * (empty toPut/toDelete) — i.e. read → diff is drift-free. This block issues + * GET requests only; it never PUTs or DELETEs. + * + * Write block (CT_LIVE=1 AND CT_LIVE_WRITE=1, plus an explicit host-match + * guard): PUTs and DELETEs a single throwaway grant to check apply + * idempotency. Doubly gated so it can never fire against production by + * accident — it does not run in this repo's default CI or dev-machine state. + */ +import { describe, it, expect } from "vitest"; +import { authedSession } from "../src/api/session.js"; +import { buildPermissionPlan } from "../src/permissions/plan.js"; +import { applyPermissionPlan } from "../src/permissions/apply.js"; +import { normalizeActual, type RawPermission } from "../src/permissions/grants.js"; +import { loadCatalog } from "../src/permissions/catalog.js"; +import type { State, ManagedResource } from "../src/state/state.js"; +import type { DesiredPermission, Grant } from "../src/permissions/types.js"; + +const live = process.env.CT_LIVE === "1"; +const liveWrite = process.env.CT_LIVE_WRITE === "1"; +const DOMAIN_ID = Number(process.env.CT_PERM_FIXTURE_ID ?? "0"); // a disposable group_type_role domainId + +/** authId → catalog name, for reversing actual grants back into declarable rights. */ +function reverseCatalog(): Map { + const byAuthId = new Map(); + for (const [name, entry] of Object.entries(loadCatalog())) { + if (!byAuthId.has(entry.authId)) byAuthId.set(entry.authId, name); + } + return byAuthId; +} + +/** Build a synthetic State that maps every scoped group dataId to its own logical key. */ +function stateForDataIds(dataIds: number[]): State { + const resources: Record = {}; + for (const id of dataIds) { + const key = `fixture_group_${id}`; + resources[key] = { type: "group", id, key, fields: {}, adoptedAt: "t", updatedAt: "t" }; + } + return { version: 1, host: "dev-fixture", resources }; +} + +describe.runIf(live)("permission round-trip (live, read-only)", () => { + it("read → diff against the domain's current grants is a no-op (drift-free)", async () => { + const { client } = await authedSession(); + + const all = await client.get("/permissions/group_type_role"); + const rows = all.filter((r) => r.domainId === DOMAIN_ID); + const actual = normalizeActual(rows); + + const nameByAuthId = reverseCatalog(); + const allDataIds = [...new Set(actual.flatMap((t) => t.dataId))]; + const state = stateForDataIds(allDataIds); + + const grants: Grant[] = actual.map((t) => { + const name = nameByAuthId.get(t.authId); + if (!name) throw new Error(`No catalog entry for authId ${t.authId} — cannot round-trip this grant.`); + if (t.dataId.length === 0) return name; + return { right: name, scope: t.dataId.map((id) => `fixture_group_${id}`) }; + }); + + 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]); + + expect(fetchErrors).toEqual([]); + expect(items[0]?.diff.toPut).toEqual([]); + expect(items[0]?.diff.toDelete).toEqual([]); + }); +}); + +// Doubly gated: requires CT_LIVE=1 (outer describe.runIf) AND CT_LIVE_WRITE=1 +// AND an explicit host-match confirmation, so this can never fire against the +// dev machine's current (production) login. It does not run here. +describe.runIf(live && liveWrite)("permission round-trip (live, write) — apply idempotency", () => { + it("PUT then DELETE a throwaway grant, twice, is idempotent", async () => { + const { client } = await authedSession(); + + const expectedHost = process.env.CT_LIVE_WRITE_HOST?.trim(); + if (!expectedHost || client.host !== expectedHost) { + throw new Error( + "CT_LIVE_WRITE requires CT_LIVE_WRITE_HOST to be set and to exactly match the authenticated " + + "host, as a non-production confirmation guard. Refusing to write.", + ); + } + + const throwaway: DesiredPermission = { + key: "fixture-write", + domainType: "group_type_role", + domainId: DOMAIN_ID, + grants: [], // operator must fill in a single disposable right before enabling CT_LIVE_WRITE + }; + const state: State = { version: 1, host: expectedHost, resources: {} }; + + const before = await buildPermissionPlan(client, state, [throwaway]); + await applyPermissionPlan(before.items, client); + const after = await buildPermissionPlan(client, state, [throwaway]); + expect(after.items[0]?.diff.toPut).toEqual([]); + expect(after.items[0]?.diff.toDelete).toEqual([]); + + // clean up: re-diff and re-apply should still be a no-op (idempotent apply) + await applyPermissionPlan(after.items, client); + const again = await buildPermissionPlan(client, state, [throwaway]); + expect(again.items[0]?.diff.toPut).toEqual([]); + expect(again.items[0]?.diff.toDelete).toEqual([]); + }); +}); diff --git a/tests/query.test.ts b/tests/query.test.ts new file mode 100644 index 0000000..3e6aee5 --- /dev/null +++ b/tests/query.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { q, churchQuery } from "../src/config/query.js"; +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] }, + ] }); + }); + + it("oneof and isnull", () => { + 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("q.not array-wraps the operand, matching the real ruleset's `!` shape", () => { + const operand = q.isnull("person.dateOfDeath"); + expect(q.not(operand)).toEqual({ "!": [{ isnull: [{ var: "person.dateOfDeath" }] }] }); + }); + + it("q.or", () => { + const a = q.eq("ctgroup.campusId", 1); + const b = q.eq("person.isArchived", false); + expect(q.or(a, b)).toEqual({ or: [a, b] }); + }); + + it("churchQuery wraps the filter in the real ChurchQuery envelope shape", () => { + const cq = churchQuery(q.eq("ctgroup.campusId", 1)); + expect(cq).toEqual({ + method: "ChurchQuery", + params: { + groupBy: ["person.id"], + filter: { "==": [{ var: "ctgroup.campusId" }, 1] }, + primaryEntityAlias: "person", + responseFields: ["person.id", "person.firstName", "person.lastName"], + }, + }); + }); + + it("churchQuery allows overriding envelope defaults", () => { + const cq = churchQuery(q.isnull("person.dateOfDeath"), { + groupBy: ["person.id", "ctgroup.id"], + primaryEntityAlias: "ctgroup", + responseFields: ["ctgroup.id"], + }); + expect(cq).toEqual({ + method: "ChurchQuery", + params: { + groupBy: ["person.id", "ctgroup.id"], + filter: { isnull: [{ var: "person.dateOfDeath" }] }, + primaryEntityAlias: "ctgroup", + responseFields: ["ctgroup.id"], + }, + }); + }); + + 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)) }; + expect(normalizeRuleset(normalizeRuleset(ruleset))).toEqual(normalizeRuleset(ruleset)); + }); + + it("matches the shape of a real production ruleset's query envelope keys", () => { + const cq = churchQuery(q.eq("ctgroup.campusId", 1)); + expect(Object.keys(cq).sort()).toEqual(["method", "params"]); + expect(Object.keys(cq.params as Record).sort()).toEqual([ + "filter", + "groupBy", + "primaryEntityAlias", + "responseFields", + ]); + }); +}); diff --git a/tests/synthetic-dynamic.test.ts b/tests/synthetic-dynamic.test.ts new file mode 100644 index 0000000..e2b9f19 --- /dev/null +++ b/tests/synthetic-dynamic.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SYNTHETIC_FIELDS, syntheticField, foldSynthetic } from "../src/engine/synthetic.js"; +import { normalizeDynamic, resolveRulesetRef } from "../src/engine/dynamic.js"; +import { loadConfig } from "../src/config/load.js"; +import { CtApiError } from "../src/api/ctClient.js"; +import type { State } from "../src/state/state.js"; +import type { DesiredResource } from "../src/engine/types.js"; +import type { CtClient } from "../src/api/ctClient.js"; + +const dynamicField = () => syntheticField("dynamic")!; +const getClient = (client: unknown) => client as unknown as Pick; + +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 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: {} } } }, + ]; + 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: {} } }); + }); + + 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 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: {} } } }, + ]; + 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: {} }); + }); + + it("demote-to-none is a no-op against the 404 sentinel", () => { + expect(normalizeDynamic({ status: "none", ruleset: {} })).toEqual({ status: "none", ruleset: {} }); + }); + + it("propagates a status-GET failure as an error rather than fabricating the 'none' sentinel", async () => { + // 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 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: {} } } }, + ]; + 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\)/); + expect(actual.get("g")).not.toHaveProperty("dynamic"); // NOT clobbered with the sentinel + }); + + 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 actual = new Map>([["g", { name: "G" }]]); + const desired: DesiredResource[] = [{ type: "group", key: "g", fields: { name: "G" }, dependsOn: [] }]; + const client = { get: vi.fn() }; + await dynamicField().fold({ client: client as unknown as Pick, state, desired, actual }); + expect(client.get).not.toHaveBeenCalled(); + expect(actual.get("g")).not.toHaveProperty("dynamic"); + }); +}); + +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 } as unknown as Pick, 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 } 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" }); + }); +}); + +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: {} }); + }); + + it("passes an inline ruleset through unchanged", () => { + expect(resolveRulesetRef({ description: "inline" }, "/nowhere")).toEqual({ description: "inline" }); + }); + + 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); + }); + + it("throws a clear error when the ref file is not valid JSON", () => { + const dir = mkdtempSync(join(tmpdir(), "ct-ref-")); + writeFileSync(join(dir, "bad.json"), "{ not json"); + expect(() => resolveRulesetRef({ ref: "./bad.json" }, dir, "g")).toThrow(/not valid JSON/i); + }); +}); + +describe("dynamic { ref } ruleset — resolved relative to the config file", () => { + const mkConfig = (rulesetLiteral: string): string => { + const dir = mkdtempSync(join(tmpdir(), "ct-cfg-")); + writeFileSync( + join(dir, "ct.config.ts"), + `export default (ct) => { ct.group({ key: "g", name: "G", groupTypeId: 1, ` + + `dynamic: { status: "manual", ruleset: ${rulesetLiteral} } }); };`, + ); + 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" }) }); + + 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: {} })); + 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" } }); + }); + + 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); + }); +}); diff --git a/tests/synthetic.test.ts b/tests/synthetic.test.ts new file mode 100644 index 0000000..ec3842d --- /dev/null +++ b/tests/synthetic.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { isSyntheticField, SYNTHETIC_FIELDS, foldSynthetic } from "../src/engine/synthetic.js"; +import type { State } from "../src/state/state.js"; +import type { DesiredResource } from "../src/engine/types.js"; + +describe("synthetic-field registry", () => { + it("recognises registered pseudo-fields and nothing else", () => { + expect(isSyntheticField("parents")).toBe(true); + expect(SYNTHETIC_FIELDS.some((f) => f.field === "parents")).toBe(true); + expect(isSyntheticField("name")).toBe(false); + }); + + it("parents fold folds managed hierarchy into desired + actual", async () => { + const state: State = { + 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", + }, + }, + }; + const actual = new Map>([ + ["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 (): Promise => + [ + { groupId: 1311, parents: [8], children: [] }, + { groupId: 8, children: [1311] }, + ] as unknown as T, + }; + const out = await foldSynthetic({ client, state, desired, actual }); + expect(out.errors).toEqual([]); + expect(actual.get("child")?.parents).toEqual(["parent"]); + expect(out.desired[0]?.fields.parents).toEqual(["parent"]); + }); +});