@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.
- package/dist/pmcp.mjs +14 -0
- package/dist/src/commands.mjs +98 -0
- package/dist/src/config.mjs +197 -0
- package/dist/src/errors.mjs +173 -0
- package/dist/src/main.mjs +2300 -0
- package/dist/src/plan.mjs +828 -0
- package/dist/src/render.mjs +215 -0
- package/package.json +34 -0
|
@@ -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}}$`);
|