@ahrzb/personal-mcp-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,828 @@
1
+ /**
2
+ * cli/src/plan.ts — the pure diff planner behind `pmcp diff` / `pmcp apply` (§9).
3
+ *
4
+ * This module OWNS the YAML config language and everything about interpreting it:
5
+ * the document shape and its defaults (kind: tunnel, auth: headers,
6
+ * forward_identity: false, archived: false, name: slug, redact/redact_results: {},
7
+ * log_bodies by kind — tunnel true, proxy false, §15), the `role:approval`
8
+ * grant suffix, every validation severity (what warns vs what hard-errors), the
9
+ * field-by-field equality that decides an update, what counts as destructive,
10
+ * and the order plan steps must execute in. It HIDES YAML from everything else:
11
+ * the server only ever sees admin-tool calls (the hub never learns YAML exists),
12
+ * and main.ts merely renders and executes the returned Plan. Everything here is
13
+ * pure — no I/O, no clock, no network — which is exactly what makes the planner
14
+ * testable as (desired, current) → plan (§16).
15
+ */
16
+
17
+ /**
18
+ * One granted role after grammar normalization: `"reader"` → allow,
19
+ * `"reader:approval"` → approval (§2, §9). Role names contain no colon, so the
20
+ * split is unambiguous. `role` is an exact name; the built-in `all` may appear
21
+ * here (grantable, never declarable).
22
+ */
23
+
24
+
25
+ /**
26
+ * One role's declaration, §20.3's wire shape: a bare pattern list means tools and nothing
27
+ * else, forever (`registry.validateRoles`'s normalization — a role that grants tools grants
28
+ * *nothing* in another family), while the per-family object names any of the three keyspaces
29
+ * (every key optional) and may sit beside a bare list in the same declaration. Kept as loose
30
+ * as the wire itself at the TYPE level — an unknown family key or a stray `all` is a semantic
31
+ * violation (`roleDeclarationProblems`), not a type error, matching the rest of this module:
32
+ * parse throws on STRUCTURE, plan reports on MEANING. Both `DesiredService.roles` and
33
+ * `CurrentService.roles` use this — a bare list normalizes to nothing here, because the
34
+ * canonical read shape a diff compares against is the SERVER's rendering (§20.3), and the
35
+ * planner would disagree with its own wire if it normalized on the way in.
36
+ */
37
+
38
+
39
+ /**
40
+ * One service as the YAML declares it, fully normalized: every default already
41
+ * applied, so two files that mean the same thing compare equal. Tunneled
42
+ * services never carry `roles` — their roles arrive at connect time and are not
43
+ * desired state (§9); the proxy-only fields (`endpoint`, `auth`,
44
+ * `forwardIdentity`, `roles`) are absent on tunnel kind. Upstream credentials
45
+ * never appear here — the YAML declares only the `auth` mode.
46
+ */
47
+
48
+
49
+
50
+
51
+
52
+
53
+    
54
+
55
+    
56
+
57
+    
58
+
59
+
60
+
61
+
62
+  
63
+
64
+
65
+    
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+ /**
75
+ * One service account as declared: grants keyed by service slug. Desired state
76
+ * is total — a (account, service) pair absent from `grants` means "no grants",
77
+ * and the planner will clear it (§9).
78
+ */
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+ /**
87
+ * The whole parsed file — authoritative desired state for one namespace. Users
88
+ * and tokens are deliberately absent: secrets and humans are imperative-only
89
+ * and never live in this file (§9).
90
+ */
91
+
92
+
93
+
94
+
95
+
96
+ /**
97
+ * The diff-relevant projection of one service_list row. Runtime facts (online/
98
+ * offline, OAuth connection state, last seen) are deliberately absent — they
99
+ * are status, not desired state, and must never influence a plan. `builtin`
100
+ * marks the virtual `pmcp` row, which the planner never plans against.
101
+ */
102
+
103
+
104
+
105
+
106
+
107
+
108
+
109
+    
110
+
111
+
112
+
113
+
114
+
115
+
116
+
117
+
118
+    
119
+
120
+
121
+
122
+
123
+
124
+
125
+
126
+ /**
127
+ * One account_list row with its grants inline — §8 pins that account_list
128
+ * returns them, so the full current-state read is exactly two calls and there
129
+ * is no separate grant-read tool.
130
+ */
131
+
132
+
133
+
134
+
135
+
136
+
137
+
138
+ /**
139
+ * Everything the planner is allowed to know about the server: one service_list
140
+ * plus one account_list, nothing else (§8). Built by main.ts from those reads;
141
+ * this module never performs them.
142
+ */
143
+
144
+
145
+
146
+
147
+
148
+ /**
149
+ * One executable unit of a plan: exactly one admin-tool call, ready to forward
150
+ * verbatim — apply is a fold of adminCall over steps, with no interpretation
151
+ * left to the executor. Archive transitions are their own steps
152
+ * (service_archive / service_unarchive), mirroring §8's tool split.
153
+ * `destructive` marks steps that irreversibly discard something — service and
154
+ * account deletes (cascade grants, delete tokens) and a service_update carrying
155
+ * an `auth` mode flip (wipes stored upstream credentials, §8) — and is what
156
+ * apply's confirmation flags. `summary` is the one human line diff prints.
157
+ */
158
+
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+
167
+ /**
168
+ * The planner's whole answer. `steps` is valid to execute strictly in order —
169
+ * deletes first (freeing slugs), then creates, then updates and archive/
170
+ * unarchive transitions, then grant_set replacements — so every reference
171
+ * exists by the time it is used. `warnings` accompany an applicable plan. A
172
+ * non-empty `errors` means the file is invalid and the plan MUST NOT be
173
+ * applied; steps are still computed best-effort so diff can show everything
174
+ * at once.
175
+ */
176
+
177
+
178
+
179
+
180
+
181
+
182
+ /**
183
+ * Normalize a YAML.parse'd document into DesiredConfig: apply every default and
184
+ * split the `role:approval` grant suffix. Structural invalidity — wrong types,
185
+ * or any unrecognized key, so a typo like `rols:` fails loudly instead of
186
+ * silently planning a role wipe — throws with the offending path in the
187
+ * message. Semantic validation (reserved slugs, dual modes, undeclared roles,
188
+ * kind changes) is planChanges' job, so diff reports every problem in one pass.
189
+ * Pure; never reads files.
190
+ */
191
+ export function parseDesired(doc ) {
192
+ // deps: none
193
+ const root = asMap(doc, "(root)");
194
+ reject(root, ["services", "service_accounts"], "(root)");
195
+ const services = asMap(root.services, "services");
196
+ const accounts = asMap(root.service_accounts, "service_accounts");
197
+ return {
198
+ services: Object.keys(services).map((slug) => parseService(slug, services[slug])),
199
+ serviceAccounts: Object.keys(accounts).map((slug) => parseAccount(slug, accounts[slug])),
200
+ };
201
+ }
202
+
203
+ /** Every key the service grammar knows, split by the kind that may carry it (§9). */
204
+ const COMMON_SERVICE_KEYS = ["kind", "name", "description", "archived", "redact", "redact_results", "log_bodies"];
205
+ const PROXY_ONLY_KEYS = ["endpoint", "auth", "forward_identity", "roles", "capabilities"];
206
+
207
+ /** One `services:` entry, defaults applied. Structural problems throw with the path. */
208
+ function parseService(slug , value ) {
209
+ const path = `services.${slug}`;
210
+ const fields = asMap(value, path);
211
+ const kind = pick(fields.kind, ["tunnel", "proxy"], `${path}.kind`) ?? "tunnel";
212
+ // A proxy-only key on a tunneled service is a lie about the hub's role surface, not a
213
+ // harmless extra — so the misplacement throws exactly like an unknown key would.
214
+ reject(fields, kind === "proxy" ? [...COMMON_SERVICE_KEYS, ...PROXY_ONLY_KEYS] : COMMON_SERVICE_KEYS, path);
215
+ const common = {
216
+ slug,
217
+ kind,
218
+ name: text(fields.name, `${path}.name`) ?? slug,
219
+ description: text(fields.description, `${path}.description`) ?? "",
220
+ archived: flag(fields.archived, `${path}.archived`) ?? false,
221
+ redact: pathMap(fields.redact, `${path}.redact`),
222
+ redactResults: pathMap(fields.redact_results, `${path}.redact_results`),
223
+ logBodies: flag(fields.log_bodies, `${path}.log_bodies`) ?? kind === "tunnel",
224
+ };
225
+ if (kind === "tunnel") return common;
226
+ const endpoint = text(fields.endpoint, `${path}.endpoint`);
227
+ // A proxied service with no forwarding target claims a hub capability that does not
228
+ // exist; the hub's own op requires it too.
229
+ if (endpoint === undefined) throw new TypeError(`${path}.endpoint is required for a proxied service`);
230
+ // §20.2: absent means tools only, decided by the hub — no default is invented here, or
231
+ // every file written before this key existed would diff against the server.
232
+ const capabilities =
233
+ fields.capabilities === undefined ? undefined : strings(fields.capabilities, `${path}.capabilities`);
234
+ return {
235
+ ...common,
236
+ endpoint,
237
+ auth: pick(fields.auth, ["headers", "oauth"], `${path}.auth`) ?? "headers",
238
+ forwardIdentity: flag(fields.forward_identity, `${path}.forward_identity`) ?? false,
239
+ roles: roleDeclarationMap(fields.roles, `${path}.roles`),
240
+ ...(capabilities === undefined ? {} : { capabilities }),
241
+ };
242
+ }
243
+
244
+ /**
245
+ * The `roles:` field of a proxied service (§20.3): each role is a bare pattern list (tools,
246
+ * unchanged forever) or a per-family object — every key optional, and the two spellings may
247
+ * sit side by side in one declaration. Parsed VERBATIM: nothing is normalized here, because
248
+ * the canonical read shape a diff compares against is the server's rendering, and a planner
249
+ * that normalized on the way in would disagree with the wire it diffs against. Structural
250
+ * problems (a role that is neither a list nor a mapping, a family value that is not a list
251
+ * of strings) throw with the offending path, same as every other grammar rule; an unknown
252
+ * family name and every pattern-grammar rule are `planChanges`' job
253
+ * (`roleDeclarationProblems`), so a bad regex in one role does not stop `diff` from
254
+ * reporting every other problem in the file in one pass.
255
+ */
256
+ function roleDeclarationMap(value , path ) {
257
+ const roles = asMap(value, path);
258
+ return Object.fromEntries(
259
+ Object.keys(roles).map((role) => {
260
+ const declared = roles[role];
261
+ const rolePath = `${path}.${role}`;
262
+ if (Array.isArray(declared)) return [role, strings(declared, rolePath)];
263
+ const families = asMap(declared, rolePath);
264
+ return [
265
+ role,
266
+ Object.fromEntries(Object.keys(families).map((family) => [family, strings(families[family], `${rolePath}.${family}`)])),
267
+ ];
268
+ }),
269
+ );
270
+ }
271
+
272
+ /** One `service_accounts:` entry, with every grant string split into role and mode. */
273
+ function parseAccount(slug , value ) {
274
+ const path = `service_accounts.${slug}`;
275
+ const fields = asMap(value, path);
276
+ reject(fields, ["name", "description", "grants"], path);
277
+ const grants = asMap(fields.grants, `${path}.grants`);
278
+ return {
279
+ slug,
280
+ name: text(fields.name, `${path}.name`) ?? slug,
281
+ description: text(fields.description, `${path}.description`) ?? "",
282
+ grants: Object.fromEntries(
283
+ Object.keys(grants).map((service) => [
284
+ service,
285
+ strings(grants[service], `${path}.grants.${service}`).map((grant) =>
286
+ parseGrant(grant, `${path}.grants.${service}`),
287
+ ),
288
+ ]),
289
+ ),
290
+ };
291
+ }
292
+
293
+ /**
294
+ * `reader` → allow, `reader:approval` → approval. Anything else with a colon throws:
295
+ * treating an unrecognized suffix as allow would turn a one-character typo into a silent
296
+ * privilege escalation.
297
+ */
298
+ function parseGrant(grant , path ) {
299
+ const colon = grant.indexOf(":");
300
+ if (colon === -1) return { role: grant, mode: "allow" };
301
+ if (grant.slice(colon + 1) === "approval") return { role: grant.slice(0, colon), mode: "approval" };
302
+ throw new TypeError(`${path}: "${grant}" — the only grant suffix is ":approval"`);
303
+ }
304
+
305
+ // ── the parse-time type checks, each naming the path it refused ────────────────────────
306
+
307
+ function asMap(value , path ) {
308
+ // `key:` with nothing under it parses as null and means "all defaults".
309
+ if (value === undefined || value === null) return {};
310
+ if (typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${path} must be a mapping`);
311
+ return value ;
312
+ }
313
+
314
+ /** Unknown keys are refused rather than ignored — a `rols:` typo must not plan a role wipe. */
315
+ function reject(fields , allowed , path ) {
316
+ for (const key of Object.keys(fields)) {
317
+ if (!allowed.includes(key)) throw new TypeError(`${path}.${key} is not a key of this grammar`);
318
+ }
319
+ }
320
+
321
+ function text(value , path ) {
322
+ if (value === undefined || value === null) return undefined;
323
+ if (typeof value !== "string") throw new TypeError(`${path} must be a string`);
324
+ return value;
325
+ }
326
+
327
+ function flag(value , path ) {
328
+ if (value === undefined || value === null) return undefined;
329
+ if (typeof value !== "boolean") throw new TypeError(`${path} must be true or false`);
330
+ return value;
331
+ }
332
+
333
+ function pick (value , values , path ) {
334
+ const chosen = text(value, path);
335
+ if (chosen === undefined) return undefined;
336
+ if (!values.includes(chosen )) throw new TypeError(`${path} must be one of ${values.join(", ")}`);
337
+ return chosen ;
338
+ }
339
+
340
+ function strings(value , path ) {
341
+ if (!Array.isArray(value)) throw new TypeError(`${path} must be a list of strings`);
342
+ for (const entry of value) {
343
+ if (typeof entry !== "string") throw new TypeError(`${path} must be a list of strings`);
344
+ }
345
+ return value ;
346
+ }
347
+
348
+ /** The `name → [string]` shape shared by redact, redact_results, and proxy roles. */
349
+ function pathMap(value , path ) {
350
+ const map = asMap(value, path);
351
+ return Object.fromEntries(Object.keys(map).map((key) => [key, strings(map[key], `${path}.${key}`)]));
352
+ }
353
+
354
+ /**
355
+ * The diff: desired + current → Plan. Pure and total — semantic problems land
356
+ * in the Plan, never as throws. Absence deletes (§9): services and accounts on
357
+ * the server but missing from the file get delete steps, and a (account,
358
+ * service) grant pair missing from the file plans a grant_set with an empty
359
+ * role list. Warns: a grant naming a role a *tunneled* service hasn't declared
360
+ * yet (the file may legitimately be ahead of the first connection; the built-in
361
+ * `all` is exempt). Hard errors: the same on a *proxied* service (its roles
362
+ * live in this very file); a `redact` / `redact_results` key that does not compile as a
363
+ * pattern, on either kind (a mask that matches no tool masks nothing, §7); the reserved
364
+ * `pmcp` slug anywhere — as a service key
365
+ * or inside a grants block (`builtin` rows are likewise excluded from the
366
+ * delete computation); the same role granted in both modes for one (account,
367
+ * service); and a kind change on an existing slug (kind is immutable, §8 — the
368
+ * planner never invents the delete-and-recreate the file didn't ask for).
369
+ */
370
+ export function planChanges(desired , current ) {
371
+ // deps: none
372
+ const errors = [];
373
+ const warnings = [];
374
+ const deletes = [];
375
+ const creates = [];
376
+ const updates = [];
377
+ const grants = [];
378
+
379
+ const onServer = new Map(current.services.filter((row) => !row.builtin).map((row) => [row.slug, row]));
380
+ const accountsOnServer = new Map(current.accounts.map((row) => [row.slug, row]));
381
+ /**
382
+ * Every slug the file NAMES, valid or not — what the delete computation must not touch,
383
+ * and the difference between "the file deletes this service" and "the file names it and
384
+ * the planner refused it", which are opposite instructions to the operator reading a diff.
385
+ */
386
+ const named = new Set ();
387
+ /** The subset the planner will actually emit steps for. */
388
+ const plannable = new Map ();
389
+
390
+ for (const service of desired.services) {
391
+ named.add(service.slug);
392
+ const problems = serviceProblems(service, onServer.get(service.slug));
393
+ if (problems.length > 0) {
394
+ errors.push(...problems);
395
+ continue;
396
+ }
397
+ plannable.set(service.slug, service);
398
+ }
399
+
400
+ const wantedAccounts = new Map(desired.serviceAccounts.map((account) => [account.slug, account]));
401
+
402
+ // ── phase 1: deletes, freeing slugs before anything claims them ─────────────────────
403
+ for (const slug of sorted(onServer.keys())) {
404
+ if (named.has(slug)) continue;
405
+ deletes.push({
406
+ tool: "service_delete",
407
+ args: { slug },
408
+ summary: `delete service ${slug} (grants cascade, tokens deleted)`,
409
+ destructive: true,
410
+ });
411
+ }
412
+ for (const slug of sorted(accountsOnServer.keys())) {
413
+ if (wantedAccounts.has(slug)) continue;
414
+ deletes.push({
415
+ tool: "account_delete",
416
+ args: { slug },
417
+ summary: `delete service account ${slug} (grants cascade, tokens deleted)`,
418
+ destructive: true,
419
+ });
420
+ }
421
+
422
+ // ── phase 2: creates ────────────────────────────────────────────────────────────────
423
+ for (const slug of sorted(plannable.keys())) {
424
+ if (onServer.has(slug)) continue;
425
+ const service = plannable.get(slug) ;
426
+ creates.push({
427
+ tool: "service_create",
428
+ // `archived` is deliberately absent: service_create has no such property and
429
+ // rejects additionalProperties — parking is its own step below.
430
+ args: { slug, kind: service.kind, ...wireFields(service) },
431
+ summary: `create ${service.kind} service ${slug}`,
432
+ destructive: false,
433
+ });
434
+ }
435
+ for (const slug of sorted(wantedAccounts.keys())) {
436
+ if (accountsOnServer.has(slug)) continue;
437
+ const account = wantedAccounts.get(slug) ;
438
+ creates.push({
439
+ tool: "account_create",
440
+ args: { slug, name: account.name, description: account.description },
441
+ summary: `create service account ${slug}`,
442
+ destructive: false,
443
+ });
444
+ }
445
+
446
+ // ── phase 3: updates and archive transitions ────────────────────────────────────────
447
+ for (const slug of sorted(plannable.keys())) {
448
+ const service = plannable.get(slug) ;
449
+ const existing = onServer.get(slug);
450
+ if (existing !== undefined) {
451
+ const changed = changedFields(service, existing);
452
+ if (Object.keys(changed).length > 0) {
453
+ const flipped = changed.auth !== undefined;
454
+ updates.push({
455
+ tool: "service_update",
456
+ args: { slug, ...changed },
457
+ summary: `update ${slug}: ${Object.keys(changed).join(", ")}${
458
+ flipped ? " — wipes the stored upstream credentials" : ""
459
+ }`,
460
+ destructive: flipped,
461
+ });
462
+ }
463
+ }
464
+ const wasArchived = existing?.archived ?? false;
465
+ if (service.archived === wasArchived) continue;
466
+ updates.push({
467
+ tool: service.archived ? "service_archive" : "service_unarchive",
468
+ args: { slug },
469
+ summary: `${service.archived ? "archive" : "unarchive"} ${slug}`,
470
+ destructive: false,
471
+ });
472
+ }
473
+
474
+ // ── phase 4: grant_set, every (account, service) pair the file states ────────────────
475
+ for (const slug of sorted(wantedAccounts.keys())) {
476
+ const account = wantedAccounts.get(slug) ;
477
+ const held = accountsOnServer.get(slug)?.grants ?? {};
478
+ for (const service of sorted(Object.keys(account.grants))) {
479
+ const wanted = account.grants[service];
480
+ const problems = grantProblems(service, wanted, plannable.get(service), onServer.get(service), named.has(service));
481
+ errors.push(...problems.errors);
482
+ warnings.push(...problems.warnings);
483
+ if (problems.errors.length > 0) continue;
484
+ if (!plannable.has(service)) {
485
+ // Three states, not two: the file NAMES this service but the planner refused it —
486
+ // its own error is already above, and calling that a delete would send the operator
487
+ // to add back a service that is right there under a bad slug.
488
+ if (named.has(service)) continue;
489
+ // A pair naming a service this very plan deletes would be a step with nothing to
490
+ // land on: the delete cascades the grants anyway, so it is dropped, loudly.
491
+ if (onServer.has(service)) warnings.push(`${slug} → ${service}: the file deletes this service; its grants cascade`);
492
+ continue;
493
+ }
494
+ if (sameRoles(wanted, held[service] ?? [])) continue;
495
+ grants.push(grantStep(slug, service, wanted));
496
+ }
497
+ // Absence in the file is desired state: a pair the SERVER holds and the file omits is
498
+ // replaced with the empty set, scoped to pairs that actually exist.
499
+ for (const service of sorted(Object.keys(held))) {
500
+ if (account.grants[service] !== undefined || !plannable.has(service)) continue;
501
+ grants.push(grantStep(slug, service, []));
502
+ }
503
+ }
504
+
505
+ return { steps: [...deletes, ...creates, ...updates, ...grants], warnings, errors };
506
+ }
507
+
508
+ /** One grant_set step, in the op's wire spelling: a flat list with `:approval` re-joined. */
509
+ function grantStep(account , service , roles ) {
510
+ const wire = roles.map((grant) => (grant.mode === "approval" ? `${grant.role}:approval` : grant.role));
511
+ return {
512
+ tool: "grant_set",
513
+ args: { account, service, roles: wire },
514
+ summary: `grant ${account} → ${service}: ${wire.length === 0 ? "(none)" : wire.join(", ")}`,
515
+ destructive: false,
516
+ };
517
+ }
518
+
519
+ /** Everything that makes one `services:` entry unplannable, all of it at once (§8, §9). */
520
+ function serviceProblems(service , existing ) {
521
+ const problems = [];
522
+ const path = `services.${service.slug}`;
523
+ if (service.slug === RESERVED_SLUG) problems.push(`${path}: the \`${RESERVED_SLUG}\` slug is reserved`);
524
+ else if (!SLUG_PATTERN.test(service.slug)) {
525
+ problems.push(`${path}: a slug is [a-z0-9-] — an underscore makes \`<slug>_<tool>\` ambiguous`);
526
+ }
527
+ if (existing !== undefined && existing.kind !== service.kind) {
528
+ problems.push(`${path}: kind is immutable (${existing.kind} on the server, ${service.kind} in the file)`);
529
+ }
530
+ if (service.kind === "proxy") problems.push(...roleDeclarationProblems(path, service.roles ?? {}));
531
+ problems.push(...redactKeyProblems(`${path}.redact`, service.redact));
532
+ problems.push(...redactKeyProblems(`${path}.redact_results`, service.redactResults));
533
+ return problems;
534
+ }
535
+
536
+ /**
537
+ * The redaction maps' keys are tool names or patterns in the same language `roles:` uses
538
+ * (§7) — on EITHER kind, since redaction is not proxy-only — so they get the same compile
539
+ * check, and for a sharper reason: a key that compiles nowhere matches no tool, so the file
540
+ * reads as masking a password that the hub then persists in full into the approval record
541
+ * and the audit bodies (§7, §15). Refusing here is what makes `pmcp apply` fail on the
542
+ * operator's terminal instead of in an audit row. The message names the service and the
543
+ * offending key and nothing else from the file — a diff is printed where others can read it.
544
+ */
545
+ function redactKeyProblems(path , map ) {
546
+ return Object.keys(map)
547
+ .filter((key) => !compiles(key))
548
+ .map((key) => `${path}: "${key}" does not compile`);
549
+ }
550
+
551
+ /**
552
+ * §20.3's three keyspaces — the only family keys a per-family role object may carry.
553
+ * EXPORTED for the same reason the caps below are: this is a second copy of the server's
554
+ * `registry.ROLE_FAMILIES`, and §9 forbids the planner importing it, so
555
+ * `server/test/worker/contracts.test.ts` locks the two by name. Without that lock, a family
556
+ * added on the server ships a whole green suite while `pmcp diff` hard-errors on a legal
557
+ * file with `"x" is not a role family`.
558
+ */
559
+ export const ROLE_FAMILIES = ["tools", "prompts", "resources"] ;
560
+
561
+ /**
562
+ * `hub/register`'s validation, extended to §20.3's per-family shape and applied to a
563
+ * proxied service's config-declared roles (§6, §8). It is deliberately a SECOND
564
+ * implementation of `server/src/registry.ts`'s validateRoles — §9 keeps the planner free of
565
+ * any server import — so the caps below are exported and locked to `server/src/limits.ts`
566
+ * in the parity suite; see them. A bare pattern list is judged as the tools family; a
567
+ * per-family object is judged family by family, and a key outside the three keyspaces above
568
+ * is a violation of its own. The two size caps apply PER FAMILY LIST, never summed across a
569
+ * role — a role at the cap in all three families is legal.
570
+ */
571
+ function roleDeclarationProblems(path , roles ) {
572
+ const problems = [];
573
+ for (const [role, declared] of Object.entries(roles)) {
574
+ if (role === BUILTIN_ROLE) problems.push(`${path}.roles.${role}: \`${BUILTIN_ROLE}\` is the built-in, never declarable`);
575
+ else if (!ROLE_NAME_PATTERN.test(role)) {
576
+ problems.push(`${path}.roles.${role}: a role name is [a-z0-9_-]{1,${ROLE_NAME_MAX_LENGTH}}`);
577
+ }
578
+ for (const [family, patterns] of Object.entries(familiesOf(declared))) {
579
+ const familyPath = `${path}.roles.${role}.${family}`;
580
+ if (!(ROLE_FAMILIES ).includes(family)) {
581
+ problems.push(`${familyPath}: "${family}" is not a role family — tools, prompts, or resources`);
582
+ continue;
583
+ }
584
+ if (patterns.length > ROLE_PATTERNS_MAX) problems.push(`${familyPath}: at most ${ROLE_PATTERNS_MAX} patterns`);
585
+ for (const pattern of patterns) {
586
+ if (pattern.length > ROLE_PATTERN_MAX_LENGTH) {
587
+ problems.push(`${familyPath}: a pattern is at most ${ROLE_PATTERN_MAX_LENGTH} characters`);
588
+ continue;
589
+ }
590
+ if (!compiles(pattern)) problems.push(`${familyPath}: "${pattern}" does not compile`);
591
+ }
592
+ }
593
+ }
594
+ return problems;
595
+ }
596
+
597
+ /** The one compile decision, shared by the `roles:` block and the redaction keys above. */
598
+ function compiles(pattern ) {
599
+ try {
600
+ new RegExp(`^(?:${pattern === "*" ? ".*" : pattern})$`);
601
+ return true;
602
+ } catch {
603
+ return false;
604
+ }
605
+ }
606
+
607
+ /**
608
+ * The severity of one (account, service) grant list — §9's warn/error split. `declaredIn`
609
+ * is the service only if the planner accepted it; `namedInFile` is the third state that
610
+ * keeps "the file names this service under an invalid slug" from being reported as "no
611
+ * such service".
612
+ */
613
+ function grantProblems(
614
+ service ,
615
+ wanted ,
616
+ declaredIn ,
617
+ onServer ,
618
+ namedInFile ,
619
+ ) {
620
+ const errors = [];
621
+ const warnings = [];
622
+ if (service === RESERVED_SLUG) {
623
+ errors.push(`grants.${service}: the \`${RESERVED_SLUG}\` slug is reserved`);
624
+ return { errors, warnings };
625
+ }
626
+ // The file names it and the planner refused it: serviceProblems already reported why, and
627
+ // a second finding about its grants would only compete with the real fix.
628
+ if (declaredIn === undefined && namedInFile) return { errors, warnings };
629
+ if (declaredIn === undefined && onServer === undefined) {
630
+ errors.push(`grants.${service}: no such service in the file or on the server`);
631
+ return { errors, warnings };
632
+ }
633
+ const modes = new Map ();
634
+ for (const grant of wanted) {
635
+ const seen = modes.get(grant.role) ?? new Set ();
636
+ seen.add(grant.mode);
637
+ modes.set(grant.role, seen);
638
+ if (seen.size > 1) errors.push(`grants.${service}: ${grant.role} is granted in both modes`);
639
+ }
640
+ const kind = declaredIn?.kind ?? onServer?.kind;
641
+ // A proxied service's roles live in this very file, so an undeclared one can never
642
+ // become declared later; a tunneled service's arrive at connect time, so the file is
643
+ // merely ahead of the first connection.
644
+ const declared = kind === "proxy" ? Object.keys(declaredIn?.roles ?? {}) : Object.keys(onServer?.roles ?? {});
645
+ for (const grant of wanted) {
646
+ if (grant.role === BUILTIN_ROLE || declared.includes(grant.role)) continue;
647
+ const message = `grants.${service}: role "${grant.role}" is not declared`;
648
+ if (kind === "proxy") errors.push(message);
649
+ else warnings.push(message);
650
+ }
651
+ return { errors, warnings };
652
+ }
653
+
654
+ /** The service fields as service_create takes them — snake_case, kind-appropriate. */
655
+ function wireFields(service ) {
656
+ return {
657
+ name: service.name,
658
+ description: service.description,
659
+ redact: service.redact,
660
+ redact_results: service.redactResults,
661
+ log_bodies: service.logBodies,
662
+ ...(service.kind === "proxy"
663
+ ? {
664
+ endpoint: service.endpoint,
665
+ auth: service.auth,
666
+ forward_identity: service.forwardIdentity,
667
+ roles: service.roles ?? {},
668
+ ...(service.capabilities === undefined ? {} : { capabilities: service.capabilities }),
669
+ }
670
+ : {}),
671
+ };
672
+ }
673
+
674
+ /**
675
+ * The fields that differ, in the op's wire spelling. `archived` is never among them (it
676
+ * has its own ops) and a tunneled service's `roles` are never compared — they arrive at
677
+ * connect time and are not desired state (§9).
678
+ *
679
+ * `capabilities` is decided AFTER the loop rather than inside it, because both halves of
680
+ * its rule sit outside what the loop can express (§9, 2026-08-27). The comparison is a SET
681
+ * with absent ≡ `["tools"]`, so a reordered list is not a change — and, more importantly,
682
+ * an omitted key is not "leave it alone" but a desired value of its own: desired state is
683
+ * total, so deleting the line from the file must plan the default back, and the loop only
684
+ * ever visits keys the file actually produced.
685
+ */
686
+ function changedFields(service , existing ) {
687
+ const { capabilities: _capabilities, ...wire } = wireFields(service);
688
+ const server = {
689
+ name: existing.name,
690
+ description: existing.description,
691
+ redact: existing.redact,
692
+ redact_results: existing.redactResults,
693
+ log_bodies: existing.logBodies,
694
+ endpoint: existing.endpoint,
695
+ auth: existing.auth,
696
+ forward_identity: existing.forwardIdentity,
697
+ roles: existing.roles,
698
+ };
699
+ const changed = {};
700
+ for (const [key, value] of Object.entries(wire)) {
701
+ // `roles` compares by MEANING, not by spelling (§20.3): a bare list and its equivalent
702
+ // `{tools:[...]}` are the same grant, so they must plan nothing, while a different
703
+ // FAMILY key under the identical patterns is a real change. Every other field compares
704
+ // structurally as before.
705
+ const same =
706
+ key === "roles"
707
+ ? deepEqual(canonicalRoles(value ), canonicalRoles((server.roles ) ?? {}))
708
+ : deepEqual(value, server[key]);
709
+ if (!same) changed[key] = value;
710
+ }
711
+ if (
712
+ service.kind === "proxy" &&
713
+ !deepEqual(canonicalCapabilities(service.capabilities), canonicalCapabilities(existing.capabilities))
714
+ ) {
715
+ // The file's own spelling when it wrote one; the default spelled OUT when it did not,
716
+ // because `service_update` has no "unset" and `["tools"]` is what absent means anyway —
717
+ // so the next run reads back a value that canonicalizes equal and plans nothing.
718
+ changed.capabilities = service.capabilities ?? [...DEFAULT_CAPABILITIES];
719
+ }
720
+ return changed;
721
+ }
722
+
723
+ /**
724
+ * §20.3's bare-list ≡ `{tools: [...]}` equivalence, spelled ONCE for this module — the
725
+ * validation above and the comparison below both read it here, so a change to the
726
+ * equivalence (a fourth family, a different empty rule) has one site, not two 150 lines
727
+ * apart. The object arm is handed back as it stands, unknown keys included: judging those
728
+ * is `roleDeclarationProblems`' job and hiding them here would make an invalid declaration
729
+ * look clean.
730
+ */
731
+ function familiesOf(declared ) {
732
+ return Array.isArray(declared) ? { tools: declared } : declared;
733
+ }
734
+
735
+ /**
736
+ * §20.3's normalization, for COMPARISON only: a bare pattern list IS `{tools: [...]}`, and
737
+ * a family declared EMPTY is a family not declared. Never used to build a plan step's args
738
+ * — those stay verbatim, because §20.3 pins the wire as the canonical form and a rewritten
739
+ * one would disagree with what the server actually stores — only to decide whether two
740
+ * declarations mean the same thing regardless of which spelling wrote them down.
741
+ *
742
+ * Both halves are needed because the server's canonical READ (registry.canonicalRoles)
743
+ * collapses a role to a bare list whenever every non-tools family is empty OR absent: a
744
+ * file writing `docs: {tools: [publish], prompts: []}` reads back as `['publish']`, and
745
+ * `docs: {}` reads back as `[]`. Dropping empties makes `[]`, `{}`, `{tools: []}` and
746
+ * `{tools: [], prompts: []}` ONE value on both sides, so the planner no longer has to know
747
+ * which shape the server happened to render. Without it those files replan `service_update`
748
+ * on every run — `pmcp diff` never comes back clean and `pmcp apply` never converges, which
749
+ * is the exact outcome this function exists to prevent.
750
+ */
751
+ function canonicalRoles(decl ) {
752
+ return Object.fromEntries(
753
+ Object.entries(decl).map(([role, declared]) => [
754
+ role,
755
+ Object.fromEntries(Object.entries(familiesOf(declared)).filter(([, patterns]) => patterns.length > 0)),
756
+ ]),
757
+ );
758
+ }
759
+
760
+ /**
761
+ * §20.2's `capabilities`, canonicalized for COMPARISON — the planner's one spelling of §9's
762
+ * rule, beside `canonicalRoles` and for the same reason `familiesOf` is spelled once: an
763
+ * equivalence with two sites is an equivalence that will disagree with itself.
764
+ *
765
+ * Two halves, both load-bearing. ABSENT IS `["tools"]`: the hub advertises tools for a
766
+ * proxied service that declared nothing, so a file omitting the key and a server storing the
767
+ * default are the same desired state and must plan nothing — otherwise every file written
768
+ * before the key existed diffs against the server on the first run after it lands. And it is
769
+ * a SET: the declaration names WHICH families the scoped handshake advertises, so order and
770
+ * repetition carry no meaning and diffing on them would be diffing on typing.
771
+ *
772
+ * Exported because it is the readable statement of that rule, not because a second caller
773
+ * exists — `changedFields` is the only one, and a second would be the drift this prevents.
774
+ */
775
+ export function canonicalCapabilities(declared ) {
776
+ // deps: none
777
+ return sorted(new Set(declared ?? DEFAULT_CAPABILITIES));
778
+ }
779
+
780
+ /** §20.2's default advertisement, and therefore what an absent `capabilities:` MEANS: a
781
+ * proxied service the hub was never told anything about serves tools. */
782
+ const DEFAULT_CAPABILITIES = ["tools"];
783
+
784
+ /** Two grant lists as the same set, order and spelling normalized. */
785
+ function sameRoles(a , b ) {
786
+ const key = (grants ) =>
787
+ grants.map((grant) => `${grant.role}:${grant.mode}`).sort().join(",");
788
+ return key(a) === key(b);
789
+ }
790
+
791
+ function sorted(values ) {
792
+ return [...values].sort();
793
+ }
794
+
795
+ /** Structural equality over the JSON the config language is made of. */
796
+ function deepEqual(a , b ) {
797
+ return canonical(a) === canonical(b);
798
+ }
799
+
800
+ function canonical(value ) {
801
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
802
+ if (typeof value === "object" && value !== null) {
803
+ const entries = Object.entries(value )
804
+ .filter(([, member]) => member !== undefined)
805
+ .sort(([left], [right]) => (left < right ? -1 : 1));
806
+ return `{${entries.map(([key, member]) => `${JSON.stringify(key)}:${canonical(member)}`).join(",")}}`;
807
+ }
808
+ return JSON.stringify(value) ?? "null";
809
+ }
810
+
811
+ /** The reserved slug and the two charsets §6/§8 pin — spelled once. */
812
+ const RESERVED_SLUG = "pmcp";
813
+ const BUILTIN_ROLE = "all";
814
+ const SLUG_PATTERN = /^[a-z0-9-]+$/;
815
+
816
+ /**
817
+ * The role caps, EXPORTED because they are a second copy of the server's rule and a copy
818
+ * needs a lock: §9 forbids plan.ts importing from the server (the planner is pure and the
819
+ * hub never learns YAML exists), so `server/test/worker/contracts.test.ts` reads these by
820
+ * name beside `server/src/limits.ts`'s and fails when the two drift. Without that case the
821
+ * planner would keep calling a file valid that `pmcp apply` then dies on server-side —
822
+ * after the destructive delete phase has already run. The name charset is derived from the
823
+ * cap rather than baked into the pattern, so there is one number per rule here too.
824
+ */
825
+ export const ROLE_NAME_MAX_LENGTH = 64;
826
+ export const ROLE_PATTERN_MAX_LENGTH = 128;
827
+ export const ROLE_PATTERNS_MAX = 64;
828
+ const ROLE_NAME_PATTERN = new RegExp(`^[a-z0-9_-]{1,${ROLE_NAME_MAX_LENGTH}}$`);