@appstrate/afps-shared 0.8.0 → 0.9.1

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/README.md CHANGED
@@ -35,6 +35,7 @@ import each module by subpath.
35
35
  | `./file-field` | File-field parsing helpers. |
36
36
  | `./token-usage` | Token-usage accounting shapes. |
37
37
  | `./backoff` | Retry backoff computation. |
38
+ | `./jsonpath` | The single-value RFC 9535 JSONPath subset every integration-manifest path field is read with (`identity_claims`, login-engine selectors). |
38
39
 
39
40
  ```ts
40
41
  import { guardedFetch } from "@appstrate/afps-shared/guarded-fetch";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appstrate/afps-shared",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Zero-dependency AFPS helpers shared by @appstrate/core and @appstrate/afps-runtime (companion-file checks, semver resolution, SRI integrity, credential templates, delivery.http projection)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -51,7 +51,8 @@
51
51
  "./unzip-bounded": "./src/unzip-bounded.ts",
52
52
  "./archive-prefix": "./src/archive-prefix.ts",
53
53
  "./backoff": "./src/backoff.ts",
54
- "./mime": "./src/mime.ts"
54
+ "./mime": "./src/mime.ts",
55
+ "./jsonpath": "./src/jsonpath.ts"
55
56
  },
56
57
  "dependencies": {
57
58
  "@types/semver": "^7.8.0",
@@ -1,7 +1,11 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright 2026 Appstrate
3
3
 
4
- import { MCP_TOOL_NAME_MAX_LENGTH, MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH } from "./mcp-naming.ts";
4
+ import {
5
+ fnv1a64Hex,
6
+ MCP_TOOL_NAME_MAX_LENGTH,
7
+ MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH,
8
+ } from "./mcp-naming.ts";
5
9
 
6
10
  /** Canonical unprefixed name of the credential-injecting API tool. */
7
11
  export const API_CALL_TOOL_NAME = "api_call";
@@ -36,7 +40,7 @@ const API_TOOL_AUTH_HASH_HEX_LENGTH = API_TOOL_AUTH_TOKEN_LENGTH - 2;
36
40
  */
37
41
  export function apiToolAuthToken(authKey: string): string {
38
42
  if (authKey.length <= API_TOOL_RAW_AUTH_KEY_MAX_LENGTH) return authKey;
39
- return `h0${fnv1a64(authKey).slice(0, API_TOOL_AUTH_HASH_HEX_LENGTH)}`;
43
+ return `h0${fnv1a64Hex(authKey).slice(0, API_TOOL_AUTH_HASH_HEX_LENGTH)}`;
40
44
  }
41
45
 
42
46
  /** Throw when two distinct auth keys collapse onto the same bounded token. */
@@ -54,17 +58,6 @@ export function assertUniqueApiToolAuthTokens(authKeys: readonly string[]): void
54
58
  }
55
59
  }
56
60
 
57
- function fnv1a64(value: string): string {
58
- let hash = 0xcbf29ce484222325n;
59
- const prime = 0x100000001b3n;
60
- const mask = 0xffffffffffffffffn;
61
- for (const byte of new TextEncoder().encode(value)) {
62
- hash ^= BigInt(byte);
63
- hash = (hash * prime) & mask;
64
- }
65
- return hash.toString(16).padStart(16, "0");
66
- }
67
-
68
61
  /** Derive the unprefixed api_call name for one auth surface. */
69
62
  export function apiCallToolNameForAuth(authKey: string, multiAuth: boolean): string {
70
63
  return multiAuth ? `${API_CALL_TOOL_NAME}__${apiToolAuthToken(authKey)}` : API_CALL_TOOL_NAME;
@@ -76,6 +76,27 @@ export function companionFilesFromRecord(files: Record<string, Uint8Array>): Com
76
76
  };
77
77
  }
78
78
 
79
+ /**
80
+ * Candidate spellings for a manifest-declared archive path, in order.
81
+ *
82
+ * A manifest may write `./server.js` or `server.js`; zip entries are stored
83
+ * without the `./` prefix. Only the leading `./` is normalised — `..`
84
+ * segments and absolute paths are NOT resolved, so this cannot widen the
85
+ * lookup beyond the archive root.
86
+ */
87
+ function archivePathCandidates(declared: string): string[] {
88
+ const stripped = declared.startsWith("./") ? declared.slice(2) : declared;
89
+ return stripped === declared ? [declared] : [declared, stripped];
90
+ }
91
+
92
+ /** First candidate spelling of `declared` present in `files`, else null. */
93
+ function resolveArchivePath(files: CompanionFileSource, declared: string): string | null {
94
+ for (const candidate of archivePathCandidates(declared)) {
95
+ if (files.has(candidate)) return candidate;
96
+ }
97
+ return null;
98
+ }
99
+
79
100
  /**
80
101
  * Validate companion-file presence per AFPS §3.3 / §3.4 for the given
81
102
  * package type. Returns the first violation encountered, or `null` when
@@ -84,9 +105,8 @@ export function companionFilesFromRecord(files: Record<string, Uint8Array>): Com
84
105
  * The check is intentionally minimal and presence-focused:
85
106
  * - `agent` → `prompt.md` present at root, non-empty bytes (§3.2).
86
107
  * - `skill` → `SKILL.md` present at root, with a YAML frontmatter `name`
87
- * (§3.3). Missing `description` is tolerated here: this function is the
88
- * LOADER-side gate. The stricter producer-side rule lives in
89
- * {@link checkSkillMarkdown}.
108
+ * (§3.3). Missing `description` is tolerated — this is the LOADER gate;
109
+ * the producer rule is {@link checkSkillMarkdown}.
90
110
  * - `mcp-server` → file at `manifest.server.entry_point` present in the
91
111
  * archive (§3.4 "self-contained — every runtime dep bundled").
92
112
  * - `integration` → no required companion (§3.5).
@@ -130,13 +150,6 @@ export function checkCompanionFiles(
130
150
  path: "SKILL.md",
131
151
  };
132
152
  }
133
- // LENIENT on purpose, and deliberately NOT the shared parser — see
134
- // {@link checkSkillMarkdown}. This function runs on the LOADER side too
135
- // (`extractRootFromAfps` → the run launcher's package catalog), where the
136
- // archive is an already-published, immutable artifact. Its acceptance set
137
- // must never shrink: anything it rejects is an agent that stops running
138
- // for a defect nobody can fix in place. `hasFrontmatterName` is therefore
139
- // frozen — the permissive substring probe, unchanged.
140
153
  if (!hasFrontmatterName(new TextDecoder().decode(bytes))) {
141
154
  return {
142
155
  reason: "SKILL_MISSING_FRONTMATTER_NAME",
@@ -159,7 +172,14 @@ export function checkCompanionFiles(
159
172
  message: "mcp-server manifest must declare server.entry_point",
160
173
  };
161
174
  }
162
- if (!files.has(entryPoint)) {
175
+ // MCPB manifests conventionally write the entry point relative-explicit
176
+ // (`./server.js`), while archive entries are stored flat (`server.js`).
177
+ // Both spellings name the same file, so resolve either — an exact
178
+ // `has(entryPoint)` rejected every package in this repo's own
179
+ // `system-packages/` tree when imported through `POST /api/packages/import`,
180
+ // while the on-disk system-package loader accepted them. One archive, two
181
+ // verdicts, depending on which door it came through.
182
+ if (!resolveArchivePath(files, entryPoint)) {
163
183
  return {
164
184
  reason: "MCP_SERVER_MISSING_ENTRY_POINT",
165
185
  message: `mcp-server archive missing server.entry_point payload: ${entryPoint}`,
@@ -185,21 +205,10 @@ function isEffectivelyEmpty(bytes: Uint8Array): boolean {
185
205
  }
186
206
 
187
207
  /**
188
- * LOADER-side name probe. FROZEN — byte-for-byte what it was before the
189
- * producer-side rule existed, and deliberately NOT routed through
190
- * {@link parseSkillFrontmatter}.
191
- *
192
- * The distinction that forces the duplication: this probe decides whether an
193
- * ALREADY-PUBLISHED bundle loads, and a published bundle is immutable. Any
194
- * input it used to accept and would now reject is an agent that stops running
195
- * for a defect nobody can fix in place. The shared parser reads only column-0
196
- * keys — correct for authoring, but it would newly reject ` name: triage`,
197
- * `metadata:\n name: triage` and `skill_name: triage`, all of which this
198
- * substring probe accepts and some published artifact may well contain.
199
- *
200
- * So its acceptance set may never shrink. Nothing here is to be "unified" with
201
- * the parser below: they answer different questions for different sides of the
202
- * artifact lifecycle.
208
+ * Deliberately NOT {@link parseSkillFrontmatter}: published artifacts exist
209
+ * whose frontmatter `yaml` cannot parse at all (17 in production at the time of
210
+ * writing, each an unquoted `description: … : …`) and the run launcher has to
211
+ * keep serving them, so this probe's acceptance set may never shrink.
203
212
  */
204
213
  function hasFrontmatterName(content: string): boolean {
205
214
  const fmMatch = content.match(/^---[^\S\n]*\n([\s\S]*?)\n---/);
@@ -209,114 +218,37 @@ function hasFrontmatterName(content: string): boolean {
209
218
  if (!nameMatch) return false;
210
219
  const raw = (nameMatch[1] ?? "").trim();
211
220
  if (raw.length === 0) return false;
212
- // Strip surrounding quotes, the way a YAML quoted scalar would be read.
213
221
  const unquoted = /^(['"])(.*)\1$/.exec(raw);
214
222
  const value = unquoted ? unquoted[2] : raw;
215
223
  return (value ?? "").trim().length > 0;
216
224
  }
217
225
 
218
- // ─────────────────────────────────────────────
219
- // SKILL.md YAML frontmatter — the PRODUCER-side parser
220
- // ─────────────────────────────────────────────
221
-
222
- /**
223
- * Maximum length of a skill frontmatter `name`, in Unicode CODE POINTS (Agent
224
- * Skills specification, https://agentskills.io/specification).
225
- */
226
+ /** Agent Skills bounds, in code points. */
226
227
  export const SKILL_NAME_MAX_LENGTH = 64;
227
-
228
- /**
229
- * Maximum length of a skill frontmatter `description`, in Unicode CODE POINTS
230
- * — the Agent Skills specification (https://agentskills.io/specification)
231
- * counts characters, and a code point is what "character" means there.
232
- *
233
- * NOT a parity constant: Pi measures `description.length`, i.e. UTF-16 units,
234
- * and only emits a warning past the bound rather than dropping the skill. The
235
- * platform enforces the spec instead, because the artifact it mints is
236
- * immutable and other consumers (Codex, Claude Code) are not so forgiving.
237
- */
238
228
  export const SKILL_DESCRIPTION_MAX_LENGTH = 1024;
239
229
 
240
- /**
241
- * Agent Skills `name` rule: lowercase `a-z`, `0-9` and `-` only, no leading or
242
- * trailing hyphen, no consecutive hyphens. Length is checked separately
243
- * against {@link SKILL_NAME_MAX_LENGTH} so the violation message can name the
244
- * bound.
245
- *
246
- * NOT the same namespace as a package id. A package id is `@scope/name`
247
- * validated by `SLUG_PATTERN` (`@appstrate/core/naming`) — unbounded in
248
- * length and tolerant of `--`. The frontmatter `name` is the BARE skill slug
249
- * an agent runtime addresses (`triage`, never `@acme/triage`), and it is the
250
- * Agent Skills rule that governs it. The two are deliberately different and
251
- * neither validator may be substituted for the other.
252
- */
230
+ /** The bare slug an agent runtime addresses — NOT a `@scope/name` package id. */
253
231
  const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
254
232
 
255
- /** Length in Unicode code points — `"🙂".length` is 2, this returns 1. */
256
233
  function codePointLength(value: string): number {
257
234
  return [...value].length;
258
235
  }
259
236
 
260
- /** Does `name` satisfy the Agent Skills `name` rule (shape AND length)? */
261
237
  export function isValidSkillName(name: string): boolean {
262
238
  return codePointLength(name) <= SKILL_NAME_MAX_LENGTH && SKILL_NAME_PATTERN.test(name);
263
239
  }
264
240
 
265
- /** Parsed `SKILL.md` YAML frontmatter. */
241
+ /** Parsed `SKILL.md` frontmatter. Never throws; a parse failure is `error`. */
266
242
  export interface SkillFrontmatter {
267
- /** Whether a closed `--- … ---` frontmatter block was found. */
268
243
  found: boolean;
269
- /**
270
- * True when the document opens with `---` but never closes the block. Told
271
- * apart from "no frontmatter at all" so the author gets the actual fault.
272
- */
244
+ /** Opens with `---` but never closes the block. */
273
245
  unterminated: boolean;
274
- /**
275
- * Why the block could not be read as `{ name, description }` — a YAML syntax
276
- * error, a document that is not a mapping, or a field that is not a string.
277
- * `null` when the block parsed cleanly. Never thrown: `extractSkillMeta`'s
278
- * contract is to degrade to empty fields plus a warning.
279
- */
280
246
  error: string | null;
281
- /** Frontmatter `name`, or `""` when absent/blank. */
282
247
  name: string;
283
- /** Frontmatter `description`, or `""` when absent/blank. */
284
248
  description: string;
285
249
  }
286
250
 
287
- /**
288
- * Parse the `name` / `description` of a `SKILL.md` YAML frontmatter block.
289
- *
290
- * PARITY WITH THE CONSUMER IS THE POINT. The runtime that actually loads a
291
- * skill — `@earendil-works/pi-coding-agent`, `dist/utils/frontmatter.js` —
292
- * normalises newlines, requires the document to start with `---`, cuts the
293
- * block at the first `\n---`, and hands the slice to `yaml`'s `parse`. This
294
- * function does the same, against the same library at the same major, so the
295
- * gate cannot accept a document the consumer then fails to PARSE.
296
- *
297
- * Parsing is where parity is exact. The RULES are not symmetric, on purpose:
298
- * Pi only WARNS on a name or description that breaks the Agent Skills spec
299
- * (and measures the description in UTF-16 units, so `"🙂".length` is 2), while
300
- * this gate refuses it. The bounds below therefore follow the SPEC — which
301
- * says characters, hence code points — not Pi's warning threshold. Being
302
- * stricter than the consumer is safe: it costs an author one edit. Being
303
- * looser is not: it mints an immutable artifact the consumer drops.
304
- *
305
- * The hand-rolled scanner this replaces got that wrong in both directions: it
306
- * accepted `description: a: b` and `name:x`, which `yaml` refuses outright,
307
- * and it had to re-implement block scalars, quoted escapes, comments,
308
- * continuation lines and duplicate detection — each an opportunity to diverge
309
- * from a spec the library already implements. `uniqueKeys` and `strict` are
310
- * passed explicitly rather than left to their defaults so a future default
311
- * change cannot loosen the gate in silence.
312
- *
313
- * NOTHING is normalised away that the runtime keeps — a leading BOM above all.
314
- * Pi tests `normalized.startsWith("---")`, which a BOM defeats, so it reads no
315
- * frontmatter at all and drops the skill. Stripping the BOM here would make
316
- * this function read a name and description the runtime never sees, which is
317
- * exactly the divergence it exists to prevent; {@link checkSkillMarkdown}
318
- * refuses such a file instead.
319
- */
251
+ /** Parses the way the skill runtime does, so the gate cannot accept what it fails to read. */
320
252
  export function parseSkillFrontmatter(content: string): SkillFrontmatter {
321
253
  const empty = { found: false, unterminated: false, error: null, name: "", description: "" };
322
254
 
@@ -338,8 +270,6 @@ export function parseSkillFrontmatter(content: string): SkillFrontmatter {
338
270
  };
339
271
  }
340
272
 
341
- // An empty block yields `null`; the runtime coerces that to `{}` and so do
342
- // we, which makes it a missing NAME rather than a malformed document.
343
273
  const mapping = parsed ?? {};
344
274
  if (typeof mapping !== "object" || Array.isArray(mapping)) {
345
275
  return {
@@ -358,15 +288,7 @@ export function parseSkillFrontmatter(content: string): SkillFrontmatter {
358
288
  return { found: true, unterminated: false, error: null, name, description };
359
289
  }
360
290
 
361
- /**
362
- * Read one frontmatter field as a trimmed string.
363
- *
364
- * An ABSENT key and an empty YAML scalar (`description:`, which parses to
365
- * `null`, as does an explicit `description: null`) are indistinguishable once
366
- * parsed and both mean "not provided" — they yield `""` so the caller reports
367
- * the field as MISSING. Any other non-string (a number, a boolean, a list, a
368
- * nested mapping) is a malformed document, not a missing field.
369
- */
291
+ /** An absent key and an empty scalar both mean "not provided" and yield `""`. */
370
292
  function readStringField(record: Record<string, unknown>, key: string): string | { error: string } {
371
293
  const value = record[key];
372
294
  if (value === undefined || value === null) return "";
@@ -384,73 +306,37 @@ function describeType(value: unknown): string {
384
306
  return `a ${typeof value}`;
385
307
  }
386
308
 
387
- /** Does the text begin with a UTF-8 byte-order mark? */
388
309
  function startsWithBom(content: string): boolean {
389
310
  return content.charCodeAt(0) === 0xfeff;
390
311
  }
391
312
 
392
- /**
393
- * Decode `SKILL.md` bytes for {@link checkSkillMarkdown}.
394
- *
395
- * `ignoreBOM: true` is the whole point, and its name is backwards: it means
396
- * "do not CONSUME the BOM", i.e. keep U+FEFF as a character. A default
397
- * `TextDecoder` silently swallows it, so a write path that decoded stored or
398
- * archived bytes the ordinary way would hand the gate a BOM-free string, pass
399
- * it, and freeze bytes the runtime cannot read. Every write path that starts
400
- * from bytes rather than from a request body decodes through THIS function.
401
- */
313
+ /** `ignoreBOM: true` reads backwards: it means "do not CONSUME the BOM". */
402
314
  export function decodeSkillMarkdown(bytes: Uint8Array): string {
403
315
  return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
404
316
  }
405
317
 
406
- /** First line of an error message — YAML errors carry a multi-line excerpt. */
407
318
  function firstLine(err: unknown): string {
408
319
  const message = err instanceof Error ? err.message : String(err);
409
320
  return message.split("\n")[0]!.trim();
410
321
  }
411
322
 
412
- // ─────────────────────────────────────────────
413
- // SKILL.md — the PRODUCER-side gate
414
- // ─────────────────────────────────────────────
415
-
416
323
  /**
417
- * Validate a `SKILL.md`'s frontmatter against the FULL AFPS §3.3 rule, as a
418
- * producer must: a `name` conforming to the Agent Skills specification
419
- * (https://agentskills.io/specification) and a non-empty `description` of at
420
- * most {@link SKILL_DESCRIPTION_MAX_LENGTH} code points. Returns the first
421
- * violation, or `null`.
422
- *
423
- * WHY THIS IS SEPARATE FROM {@link checkCompanionFiles}. That function runs on
424
- * both sides of the artifact lifecycle, and the loader side reads
425
- * already-published, immutable bundles: a skill published before this rule
426
- * existed must keep loading, or every run of an agent that depends on it fails
427
- * at launch for a defect nobody can now fix in place. So the rule is applied
428
- * at the moment content is AUTHORED — editor create, draft save, publish,
429
- * restore, fork, and the ROOT of every import — and never at load. AFPS spells
430
- * both fields SHOULD; the platform mints these artifacts and holds itself to
431
- * MUST.
432
- *
433
- * Takes the decoded text, not a file source: every write path already has the
434
- * `SKILL.md` in hand (a request body, a draft column, a ZIP entry), and
435
- * "is SKILL.md present at all" is `checkCompanionFiles`'s question.
324
+ * The producer-side AFPS §3.3 gate: an Agent Skills `name`
325
+ * (https://agentskills.io/specification) plus a non-empty bounded `description`.
436
326
  */
437
327
  export function checkSkillMarkdown(content: string): CompanionFileViolation | null {
438
- // FIRST, because everything below reads a document the runtime cannot: Pi's
439
- // `parseFrontmatter` tests `startsWith("---")`, which a BOM defeats, so it
440
- // reads an empty frontmatter and `loadSkillFromFile` returns `skill: null` —
441
- // the skill is silently dropped at run time. The platform's own loader is
442
- // more forgiving (it decodes archive bytes through a default `TextDecoder`,
443
- // which eats the BOM), so nothing downstream would have complained: the
444
- // version would be minted, immutable, and simply never load in the agent.
445
- //
446
- // Rejected rather than stripped: silently rewriting an author's bytes would
447
- // make the artifact disagree with the file they wrote.
328
+ // Rejected rather than stripped, and it stays rejected now that Pi >= 0.85
329
+ // strips a BOM itself: a minted version is immutable and has to load on every
330
+ // runtime image the platform ships, including the 0.84.x ones that test
331
+ // startsWith("---"), read no frontmatter behind a BOM and drop the skill.
332
+ // The platform's own loader eats it either way, so without this check the
333
+ // version would be minted and simply never load on those images.
448
334
  if (startsWithBom(content)) {
449
335
  return {
450
336
  reason: "SKILL_INVALID_FRONTMATTER",
451
337
  message:
452
338
  "skill SKILL.md starts with a byte-order mark (U+FEFF); remove it — " +
453
- "the runtime cannot read frontmatter behind a BOM",
339
+ "older runtimes read no frontmatter behind a BOM and drop the skill",
454
340
  path: "SKILL.md",
455
341
  };
456
342
  }
@@ -506,18 +392,8 @@ export function checkSkillMarkdown(content: string): CompanionFileViolation | nu
506
392
  };
507
393
  }
508
394
 
509
- // CONTAINMENT: what this gate accepts must be a SUBSET of what the loader
510
- // accepts. The two read the frontmatter differently on purpose — a real YAML
511
- // parser here, a frozen substring probe there — and YAML is the more
512
- // permissive of the two: `name:\n triage`, `name : triage` and a
513
- // BOM-prefixed document are all valid YAML that `hasFrontmatterName` cannot
514
- // see. Without this check, create and publish would mint an IMMUTABLE version
515
- // the run launcher then refuses to load — the one failure mode this whole
516
- // split exists to prevent, and unfixable once published.
517
- //
518
- // Stated as a check rather than by loosening the loader, because the loader's
519
- // acceptance set may never shrink, and rather than by tightening the parser,
520
- // because the parser must keep matching the runtime.
395
+ // Containment: `name:\n triage` is valid YAML the loader's probe cannot
396
+ // read, so accepting it would mint a version the run launcher cannot load.
521
397
  if (!hasFrontmatterName(content)) {
522
398
  return {
523
399
  reason: "SKILL_INVALID_FRONTMATTER_NAME",
@@ -4,10 +4,7 @@
4
4
  /**
5
5
  * Canonical `{$credential.<field>}` value-template renderer — the SINGLE
6
6
  * source of truth. Consumers import this module directly; core no longer
7
- * publishes a `./credential-template` subpath (removed in core 6.0.0). The
8
- * only importer today is `apps/api/src/services/integration-manifest-helpers.ts`,
9
- * which re-exports it pre-bound to `emptyAs: "null"` for
10
- * `integration-spawn-resolver.ts`.
7
+ * publishes a `./credential-template` subpath (removed in core 6.0.0).
11
8
  *
12
9
  * AFPS `delivery.http` / `delivery.env` / `delivery.files` value templates
13
10
  * reference an auth's decrypted credential bag via the `{$credential.<field>}`
@@ -54,3 +51,30 @@ export function renderCredentialTemplate(
54
51
  if (opts.emptyAs === "null") return rendered.length === 0 ? null : rendered;
55
52
  return rendered;
56
53
  }
54
+
55
+ /** Field names referenced by `{$credential.<name>}` placeholders, in order, deduplicated. */
56
+ export function credentialTemplateRefs(template: string): string[] {
57
+ return [...new Set(Array.from(template.matchAll(CREDENTIAL_REF), (m) => m[1]!))];
58
+ }
59
+
60
+ /** A rendered value may only be a literal host label run or port digits, never dots alone. */
61
+ const AUTHORITY_VALUE = /^(?!\.+$)[A-Za-z0-9.-]+$/;
62
+
63
+ /**
64
+ * Render `authorized_uris` for one connection (#1458). A templated pattern is DROPPED when a
65
+ * referenced field fails {@link AUTHORITY_VALUE}, so a value cannot add a wildcard, a separator
66
+ * or another host. Import validation confines placeholders to the host and port.
67
+ */
68
+ export function renderAuthorizedUris(
69
+ patterns: readonly string[],
70
+ fields: Readonly<Record<string, string>>,
71
+ ): string[] {
72
+ return patterns.flatMap((pattern) => {
73
+ const refs = credentialTemplateRefs(pattern);
74
+ const renderable = refs.every((ref) => {
75
+ const value = fields[ref];
76
+ return typeof value === "string" && AUTHORITY_VALUE.test(value);
77
+ });
78
+ return renderable ? [renderCredentialTemplate(pattern, fields)] : [];
79
+ });
80
+ }
@@ -0,0 +1,161 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * The JSONPath dialect of AFPS integration manifests — a single-value RFC 9535
5
+ * subset: `$`, `.name` (member-name-shorthand), `['name']` / `["name"]` (RFC
6
+ * 9535 string literals and escapes), `[0]` / `[-1]` (RFC 9535 `int`). Anything
7
+ * else throws {@link JsonPathSyntaxError} rather than silently missing.
8
+ */
9
+
10
+ export class JsonPathSyntaxError extends Error {
11
+ override readonly name = "JsonPathSyntaxError";
12
+ }
13
+
14
+ type JsonPathSegment = string | number;
15
+
16
+ const SIMPLE_ESCAPES: Record<string, string> = {
17
+ b: "\b",
18
+ f: "\f",
19
+ n: "\n",
20
+ r: "\r",
21
+ t: "\t",
22
+ "/": "/",
23
+ "\\": "\\",
24
+ };
25
+
26
+ const isDigit = (ch: string) => ch >= "0" && ch <= "9";
27
+ const isNameFirst = (ch: string) =>
28
+ (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_" || ch.charCodeAt(0) >= 0x80;
29
+ const isBlank = (ch: string | undefined) => ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
30
+
31
+ /** Tokenize `path` into member names and array indices. Throws on anything outside the subset. */
32
+ export function parseJsonPath(path: string): JsonPathSegment[] {
33
+ const fail = (at: number, what: string): never => {
34
+ throw new JsonPathSyntaxError(`jsonpath '${path}' at offset ${at}: ${what}`);
35
+ };
36
+ if (path[0] !== "$") fail(0, "must start with '$'");
37
+
38
+ const segments: JsonPathSegment[] = [];
39
+ let i = 1;
40
+ const skipBlank = () => {
41
+ while (isBlank(path[i])) i++;
42
+ };
43
+
44
+ const readString = (quote: string): string => {
45
+ const start = i;
46
+ i++;
47
+ let out = "";
48
+ while (i < path.length) {
49
+ const ch = path[i]!;
50
+ if (ch === quote) {
51
+ i++;
52
+ return out;
53
+ }
54
+ if (ch.charCodeAt(0) < 0x20) fail(i, "unescaped control character in string");
55
+ if (ch !== "\\") {
56
+ out += ch;
57
+ i++;
58
+ continue;
59
+ }
60
+ const esc = path[i + 1];
61
+ if (esc === quote) out += quote;
62
+ else if (esc !== undefined && esc in SIMPLE_ESCAPES) out += SIMPLE_ESCAPES[esc];
63
+ else if (esc === "u") {
64
+ const unit = readHex4(i + 2);
65
+ if (unit >= 0xdc00 && unit <= 0xdfff) fail(i, "lone low surrogate escape");
66
+ if (unit >= 0xd800 && unit <= 0xdbff) {
67
+ const low = path.startsWith("\\u", i + 6) ? readHex4(i + 8) : -1;
68
+ if (low < 0xdc00 || low > 0xdfff)
69
+ fail(i, "high surrogate escape without a low surrogate");
70
+ out += String.fromCharCode(unit, low);
71
+ i += 12;
72
+ continue;
73
+ }
74
+ out += String.fromCharCode(unit);
75
+ i += 6;
76
+ continue;
77
+ } else fail(i, `invalid escape '\\${esc ?? ""}'`);
78
+ i += 2;
79
+ }
80
+ return fail(start, "unterminated string");
81
+ };
82
+
83
+ const readHex4 = (at: number): number => {
84
+ const hex = path.slice(at, at + 4);
85
+ if (!/^[0-9A-Fa-f]{4}$/.test(hex)) fail(at, "\\u must be followed by 4 hex digits");
86
+ return parseInt(hex, 16);
87
+ };
88
+
89
+ const readInt = (): number => {
90
+ const start = i;
91
+ if (path[i] === "-") i++;
92
+ const digitsAt = i;
93
+ while (isDigit(path[i] ?? "")) i++;
94
+ const text = path.slice(start, i);
95
+ if (i === digitsAt) fail(start, "expected an index");
96
+ if (text === "-0" || (path[digitsAt] === "0" && i - digitsAt > 1)) {
97
+ fail(start, `index '${text}' is not an RFC 9535 int (no leading zeros, no -0)`);
98
+ }
99
+ const n = Number(text);
100
+ if (!Number.isSafeInteger(n)) fail(start, `index '${text}' is out of range`);
101
+ return n;
102
+ };
103
+
104
+ while (i < path.length) {
105
+ const ch = path[i]!;
106
+ if (ch === ".") {
107
+ i++;
108
+ const start = i;
109
+ const first = path[i];
110
+ if (first === ".") fail(start, "recursive descent is not supported");
111
+ if (first === "*") fail(start, "a wildcard is not supported");
112
+ if (first !== undefined && isDigit(first)) {
113
+ fail(
114
+ start,
115
+ "a member name cannot start with a digit — write an index as [0] or a member as ['0']",
116
+ );
117
+ }
118
+ if (first === undefined || !isNameFirst(first)) fail(start, "expected a member name");
119
+ while (i < path.length && (isNameFirst(path[i]!) || isDigit(path[i]!))) i++;
120
+ segments.push(path.slice(start, i));
121
+ } else if (ch === "[") {
122
+ i++;
123
+ skipBlank();
124
+ const sel = path[i];
125
+ if (sel === "'" || sel === '"') segments.push(readString(sel));
126
+ else if (sel === "-" || (sel !== undefined && isDigit(sel))) segments.push(readInt());
127
+ else if (sel === undefined) fail(i, "unterminated '['");
128
+ else
129
+ fail(i, `unsupported selector '${sel}' (wildcards, filters and slices are not supported)`);
130
+ skipBlank();
131
+ if (path[i] === ",") fail(i, "a union of selectors is not supported");
132
+ if (path[i] === ":") fail(i, "a slice is not supported");
133
+ if (path[i] !== "]") fail(i, path[i] === undefined ? "unterminated '['" : "expected ']'");
134
+ i++;
135
+ } else {
136
+ fail(i, `unexpected character '${ch}'`);
137
+ }
138
+ }
139
+ return segments;
140
+ }
141
+
142
+ /**
143
+ * Evaluate `path` against `root`. Returns `undefined` when the path is valid
144
+ * but selects nothing; throws {@link JsonPathSyntaxError} when it is invalid.
145
+ * Members are own properties only, so `$.constructor` cannot reach a prototype.
146
+ */
147
+ export function evaluateJsonPath(root: unknown, path: string): unknown {
148
+ let cur: unknown = root;
149
+ for (const seg of parseJsonPath(path)) {
150
+ if (cur === null || typeof cur !== "object") return undefined;
151
+ if (typeof seg === "number") {
152
+ if (!Array.isArray(cur)) return undefined;
153
+ cur = cur[seg < 0 ? cur.length + seg : seg];
154
+ } else {
155
+ // Not `Object.hasOwn`: the web app compiles this leaf against ES2020.
156
+ if (Array.isArray(cur) || !Object.prototype.hasOwnProperty.call(cur, seg)) return undefined;
157
+ cur = (cur as Record<string, unknown>)[seg];
158
+ }
159
+ }
160
+ return cur;
161
+ }
package/src/mcp-naming.ts CHANGED
@@ -13,7 +13,6 @@ export const MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH = 20;
13
13
  * snake-case namespace capped before collision suffixing.
14
14
  */
15
15
  export function normaliseMcpToolNamespace(raw: string): string {
16
- if (typeof raw !== "string") return "";
17
16
  const out = trimUnderscores(
18
17
  raw
19
18
  .replace(/^@/, "")
@@ -24,18 +23,61 @@ export function normaliseMcpToolNamespace(raw: string): string {
24
23
  }
25
24
 
26
25
  /**
27
- * Canonicalise an untrusted upstream tool body before adding our namespace.
28
- * An upstream namespace is stripped so `drive__api-call` becomes `api_call`,
29
- * matching McpHost's outward naming contract.
26
+ * Exposed tool-name grammar: `{snake_namespace}__{body}`, the body in the LLM
27
+ * providers' `^[a-zA-Z0-9_-]{1,64}$` alphabet (56-char ceiling: re-prefix headroom).
28
+ */
29
+ const MCP_TOOL_NAME_PATTERN = /^[a-z0-9][a-z0-9_]*__[A-Za-z0-9_-]+$/;
30
+
31
+ export function isValidMcpToolName(name: string): boolean {
32
+ return name.length <= MCP_TOOL_NAME_MAX_LENGTH && MCP_TOOL_NAME_PATTERN.test(name);
33
+ }
34
+
35
+ /**
36
+ * Map an untrusted upstream tool name onto the body alphabet: each rejected
37
+ * code point becomes `_`; nothing else changes.
30
38
  */
31
39
  export function normaliseMcpToolBody(raw: string): string {
32
- if (typeof raw !== "string") return "";
33
- let out = trimUnderscores(raw.replace(/[^a-zA-Z0-9_]+/g, "_").toLowerCase());
34
- const separator = out.indexOf("__");
35
- if (separator >= 0 && separator < out.length - 2) {
36
- out = out.slice(separator + 2);
40
+ return raw.replace(/[^A-Za-z0-9_-]/gu, "_");
41
+ }
42
+
43
+ const MCP_TOOL_HASH_LENGTH = 8;
44
+
45
+ /**
46
+ * Exposed name for an untrusted upstream tool: `{namespace}__{body}` when it
47
+ * fits and is free, else the body cut to fit plus a hash of the ORIGINAL
48
+ * upstream name, so two names that normalise to the same body (`a.b`, `a_b`)
49
+ * still get distinct names. Which of them keeps the plain form depends on
50
+ * registration order: the first one registered. One salted re-hash on
51
+ * collision, then it throws.
52
+ */
53
+ export function allocateMcpToolName(
54
+ namespace: string,
55
+ upstreamName: string,
56
+ taken: (name: string) => boolean,
57
+ ): string {
58
+ const body = normaliseMcpToolBody(upstreamName);
59
+ const plain = `${namespace}__${body}`;
60
+ if (isValidMcpToolName(plain) && !taken(plain)) return plain;
61
+ const budget = MCP_TOOL_NAME_MAX_LENGTH - namespace.length - 2 - MCP_TOOL_HASH_LENGTH - 1;
62
+ const head = body.slice(0, Math.max(0, budget));
63
+ for (const seed of [upstreamName, `${upstreamName}\0`]) {
64
+ const hash = fnv1a64Hex(seed).slice(0, MCP_TOOL_HASH_LENGTH);
65
+ const candidate = head ? `${namespace}__${head}_${hash}` : `${namespace}__${hash}`;
66
+ if (!taken(candidate)) return candidate;
67
+ }
68
+ throw new Error(`MCP tool name ${JSON.stringify(upstreamName)} collides after re-hashing`);
69
+ }
70
+
71
+ /** 64-bit FNV-1a over the UTF-8 bytes, as 16 lowercase hex digits. */
72
+ export function fnv1a64Hex(value: string): string {
73
+ let hash = 0xcbf29ce484222325n;
74
+ const prime = 0x100000001b3n;
75
+ const mask = 0xffffffffffffffffn;
76
+ for (const byte of new TextEncoder().encode(value)) {
77
+ hash ^= BigInt(byte);
78
+ hash = (hash * prime) & mask;
37
79
  }
38
- return out;
80
+ return hash.toString(16).padStart(16, "0");
39
81
  }
40
82
 
41
83
  /** Trim underscore runs in linear time without a backtracking expression. */