From f8a68be0e6f293e9675e2eda4583eb4b751b49ae Mon Sep 17 00:00:00 2001 From: Alex Ezell Date: Tue, 13 Oct 2026 14:12:11 -0500 Subject: [PATCH 1/7] Add the contributor CLI: route and expectation ports, probe runner, scaffolding scripts/lib/routes.mjs and scripts/lib/expect.mjs port the gateway's route matcher (OAuthProvider.path_allowed?/3, AccessPolicy prefix matching, Connector.safe_path?/1) and expectation evaluator (Connector.Expect) from sprites-api. v1/vectors.json is the parity contract both sides run; its cases come from sprites-api's route and expectation tests. check-connectors.mjs now also asserts the probe and every example are forwarded routes, every expect list is well-formed and path_param compiles. The template's probe path was not in its own allowed_paths; it is now. scripts/probe.mjs sends a connector's probe like the gateway does and can record a redacted fixture; scripts/new.mjs scaffolds a connector from the template. CI runs the node --test suite, which exercises the probe runner against a local http.createServer stub. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/validate.yml | 5 +- CONTRIBUTING.md | 44 +- README.md | 7 +- scripts/check-connectors.mjs | 6 +- scripts/lib/expect.mjs | 174 ++ scripts/lib/manifest.mjs | 63 + scripts/lib/routes.mjs | 130 ++ scripts/new.mjs | 62 + scripts/probe.mjs | 193 ++ scripts/test/expect.test.mjs | 45 + scripts/test/manifest.test.mjs | 56 + scripts/test/new.test.mjs | 58 + scripts/test/probe.test.mjs | 178 ++ scripts/test/routes.test.mjs | 36 + template/connector/connector.json | 3 +- v1/vectors.json | 3131 +++++++++++++++++++++++++++++ 16 files changed, 4181 insertions(+), 10 deletions(-) create mode 100644 scripts/lib/expect.mjs create mode 100644 scripts/lib/manifest.mjs create mode 100644 scripts/lib/routes.mjs create mode 100755 scripts/new.mjs create mode 100755 scripts/probe.mjs create mode 100644 scripts/test/expect.test.mjs create mode 100644 scripts/test/manifest.test.mjs create mode 100644 scripts/test/new.test.mjs create mode 100644 scripts/test/probe.test.mjs create mode 100644 scripts/test/routes.test.mjs create mode 100644 v1/vectors.json diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index bcfd552..289d2e8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -17,5 +17,8 @@ jobs: run: >- npx --yes ajv-cli@5 validate --spec=draft2020 --strict=false -s v1/schema.json -d "connectors/*/connector.json" - - name: Directories, icons and marks + -d template/connector/connector.json + - name: Directories, icons, marks, routes and expectations run: node scripts/check-connectors.mjs + - name: Scripts and parity vectors + run: node --test "scripts/test/*.test.mjs" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22e4385..6f0a341 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,8 +32,9 @@ These are enforced by the format, not just by review. ## Steps -1. Copy `template/connector` to `connectors//`. `` is - `[a-z][a-z0-9_]*` and must equal `"name"` in the manifest. +1. Run `node scripts/new.mjs `, which copies `template/connector` to + `connectors//` and sets the names. `` is `[a-z][a-z0-9_]*` + and must equal `"name"` in the manifest. 2. Fill in `connector.json`. Every field is described in `v1/schema.json`; the eleven committed connectors show each one in use. The probe must be the cheapest request that proves the key works, and must be safe to run @@ -44,9 +45,9 @@ These are enforced by the format, not just by review. inside either file must start with `-`. Record where the icon came from and under what licence in the pull request. 4. Run the checks locally (see below) until they are clean. -5. Run the probe against the real API with your own key and confirm the - assertions hold, for example with curl and the `expect` list in hand. Do - not commit your key or any response that contains it. +5. Run the probe against the real API with your own key (see Checks) and + confirm every assertion holds. Do not commit your key or any response + that contains it; `--write-fixture` redacts the recording for you. 6. Open a pull request. Say whether you work for the vendor (`owner: vendor`) or not (`owner: community`), and link the API docs for the probe and every forwarded route. @@ -66,14 +67,45 @@ These are enforced by the format, not just by review. ## Checks +Everything runs on Node 22 with no dependencies to install. + ```sh node scripts/check-connectors.mjs # all connectors node scripts/check-connectors.mjs exa # one npx --yes ajv-cli@5 validate --spec=draft2020 --strict=false \ -s v1/schema.json -d "connectors/*/connector.json" +node --test "scripts/test/*.test.mjs" # the scripts and the parity vectors +``` + +`check-connectors.mjs` covers what the schema cannot: the directory and +manifest names agree, the icons are safe and the mark is one ink, the probe +and every example are routes `allowed_paths` forwards (the gateway's route +matcher is ported in `scripts/lib/routes.mjs`), every `expect` list in the +probe and its `errors` rules is well-formed, and `path_param` compiles. +CI runs all three on every pull request. + +### Probe + +```sh +EXAMPLE_API_KEY=... node scripts/probe.mjs example --key-env EXAMPLE_API_KEY ``` -CI runs both on every pull request. +sends the manifest's probe exactly as the gateway does (auth header, +`test_request_headers`, no redirects, no retries, `receive_timeout`, default +10 s), prints each `expect` assertion's result and, for a non-200 response, +the reason the `errors` rules and the 401 default give it. The key is read +from the named environment variable and never printed. Add `--write-fixture` +to record the response as `connectors//fixtures/probe_.json` +with every string containing the key and every credential header removed; +check the file before committing it. Fixtures are optional; when present +they show reviewers the response shape the assertions were written against. + +### Parity + +`v1/vectors.json` is the contract between sprites-api and these scripts for +route matching and probe expectations: both run every case in it. A change +to those semantics lands in sprites-api first and is mirrored here with its +vector. ## Fields that are ours to set diff --git a/README.md b/README.md index 9d492fe..bb6cd20 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,18 @@ connectors/exa/ connector.json the manifest (schema: v1/schema.json) icon.svg brand mark, as the vendor draws it mark.svg the same mark in one ink (currentColor) + fixtures/ optional recorded probe responses (probe_.json), + written by scripts/probe.mjs --write-fixture and redacted ``` - [CONTRIBUTING.md](CONTRIBUTING.md): how to write, check and submit one. - [v1/schema.json](v1/schema.json): the JSON Schema of the manifest format. Editors that honour `$schema` validate and autocomplete `connector.json` against it; `.vscode/settings.json` maps the local file. -- [template/connector](template/connector): a starting point for a new one. +- [template/connector](template/connector): a starting point for a new one; + `node scripts/new.mjs ` copies it into place. +- [v1/vectors.json](v1/vectors.json): the parity contract for route matching + and probe expectations, run by both sprites-api and the scripts here. The eleven connectors committed first (AgentMail, Blotato, Deepgram, Era, Exa, Firecrawl, Fireworks, LemonSlice, Linkup, Mnemom, Parasail) are exports of diff --git a/scripts/check-connectors.mjs b/scripts/check-connectors.mjs index 5ccc0c4..2978796 100644 --- a/scripts/check-connectors.mjs +++ b/scripts/check-connectors.mjs @@ -2,10 +2,13 @@ // Checks every connector directory (or the ones named on the command line) // for the things the JSON Schema cannot express: directory and manifest // names agree, referenced SVG files exist and are safe to inline, the mark -// is one ink, and no stray files are committed. Schema validation itself is +// is one ink, no stray files are committed, the probe and every example are +// routes the manifest forwards, expectations are well-formed and path_param +// compiles (see scripts/lib/manifest.mjs). Schema validation itself is // ajv-cli (see CONTRIBUTING.md); both run in CI. import { readdirSync, readFileSync, statSync, existsSync } from "node:fs"; import { join, basename } from "node:path"; +import { manifestProblems } from "./lib/manifest.mjs"; const root = new URL("..", import.meta.url).pathname; const connectorsDir = join(root, "connectors"); @@ -90,6 +93,7 @@ for (const name of names) { } if (manifest.presentation && manifest.presentation.mark_svg === undefined && existsSync(join(dir, "mark.svg"))) fail(name, "mark.svg exists but presentation.mark_svg does not reference it"); + for (const problem of manifestProblems(manifest)) fail(name, problem); } if (failures) { diff --git a/scripts/lib/expect.mjs b/scripts/lib/expect.mjs new file mode 100644 index 0000000..4bc5921 --- /dev/null +++ b/scripts/lib/expect.mjs @@ -0,0 +1,174 @@ +// Port of `Sprites.Gateway.Connector.Expect` in sprites-api, plus the manifest +// decoding of `[path, check, ...args]` arrays from +// `Sprites.Gateway.Connector.Manifest`. v1/vectors.json is the parity contract; +// keep this in step with the Elixir rather than improving it on its own. +// +// An expectation is `[path, check, ...args]`. `path` is a dotted key path; a +// `[]` suffix on a segment applies the rest of the path to every element of +// the list found there (an empty list passes vacuously, a non-list counts as +// missing). A missing key or a null value is "missing". Every expectation must +// hold, and only a JSON object can satisfy a list of expectations. +// +// Checks: present, absent, map, list, nonempty_list, boolean, integer, number, +// string, text (non-empty string), eq v, in [v], min n (>=), gt n (>), +// between lo hi (inclusive), prefix s (starts with s and is longer), +// contains s. + +export const TYPE_CHECKS = new Set([ + "present", + "absent", + "map", + "list", + "nonempty_list", + "boolean", + "integer", + "number", + "string", + "text", +]); +export const PARAM_CHECKS = new Set(["eq", "in", "min", "gt", "prefix", "contains"]); + +const SEGMENT = /^[^.[\]]+(\[\])?$/; + +const isMap = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const isNumber = (v) => typeof v === "number" && Number.isFinite(v); +// JSON.parse cannot tell `1` from `1.0` the way Elixir's decoder does, so an +// integral float counts as an integer here (see the note in v1/vectors.json). +const isInteger = (v) => Number.isInteger(v); + +// Elixir `==` on decoded JSON: structural equality, key order ignored. +export function deepEqual(a, b) { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) + return a.length === b.length && a.every((v, i) => deepEqual(v, b[i])); + if (isMap(a) && isMap(b)) { + const ka = Object.keys(a); + const kb = Object.keys(b); + return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k])); + } + return false; +} + +// Returns the reason an expectation is malformed, or null when it is a valid +// `[path, check, ...args]` array by the manifest loader's and `Expect.validate!/1`'s +// rules. +export function problem(expectation) { + if (!Array.isArray(expectation) || expectation.length < 2) return "must be [path, check, ...]"; + const [path, check, ...args] = expectation; + if (typeof path !== "string" || typeof check !== "string") return "must be [path, check, ...]"; + const segments = path.split("."); + if (!segments.every((s) => SEGMENT.test(s))) return `path must be dotted keys with optional []: ${path}`; + if (TYPE_CHECKS.has(check)) return args.length === 0 ? null : `${check} takes no argument`; + if (check === "between") { + if (args.length !== 2) return "between takes lo and hi"; + const [lo, hi] = args; + return isNumber(lo) && isNumber(hi) && lo <= hi ? null : "between needs numbers lo <= hi"; + } + if (!PARAM_CHECKS.has(check)) return `unknown check: ${check}`; + if (args.length !== 1) return `${check} takes one argument`; + const [arg] = args; + switch (check) { + case "eq": + return null; + case "in": + return Array.isArray(arg) ? null : "in needs a list of values"; + case "min": + case "gt": + return isNumber(arg) ? null : `${check} needs a number`; + case "prefix": + case "contains": + return typeof arg === "string" && arg !== "" ? null : `${check} needs a non-empty string`; + default: + return `unknown check: ${check}`; + } +} + +// Throws on the first malformed expectation; returns the list otherwise. +export function validate(expectations) { + if (!Array.isArray(expectations)) throw new TypeError("expectations must be a list of [path, check, ...]"); + for (const expectation of expectations) { + const why = problem(expectation); + if (why) throw new TypeError(`${why}: ${JSON.stringify(expectation)}`); + } + return expectations; +} + +const MISSING = Symbol("missing"); + +// Resolves a path to one result per leaf it addresses: the value, or MISSING +// for a missing key or null. Each `[]` fans out over the list's elements +// independently; a non-list where `[]` expects one is a single MISSING. +function resolve(segments, value) { + if (segments.length === 0) return [value]; + if (!isMap(value)) return [MISSING]; + const [segment, ...rest] = segments; + if (segment.endsWith("[]")) { + const key = segment.slice(0, -2); + const list = Object.hasOwn(value, key) ? value[key] : undefined; + return Array.isArray(list) ? list.flatMap((element) => resolve(rest, element)) : [MISSING]; + } + const next = Object.hasOwn(value, segment) ? value[segment] : undefined; + return next === undefined || next === null ? [MISSING] : resolve(rest, next); +} + +function check(name, args, value) { + switch (name) { + case "present": + return value !== null && value !== undefined; + case "map": + return isMap(value); + case "list": + return Array.isArray(value); + case "nonempty_list": + return Array.isArray(value) && value.length > 0; + case "boolean": + return typeof value === "boolean"; + case "integer": + return isInteger(value); + case "number": + return isNumber(value); + case "string": + return typeof value === "string"; + case "text": + return typeof value === "string" && value !== ""; + case "eq": + return deepEqual(value, args[0]); + case "in": + return args[0].some((candidate) => deepEqual(value, candidate)); + case "min": + return isNumber(value) && value >= args[0]; + case "gt": + return isNumber(value) && value > args[0]; + case "between": + return isNumber(value) && value >= args[0] && value <= args[1]; + case "prefix": + return typeof value === "string" && value.startsWith(args[0]) && value !== args[0]; + case "contains": + return typeof value === "string" && value.includes(args[0]); + default: + throw new TypeError(`unknown check: ${name}`); + } +} + +// Whether one expectation holds against a JSON object body. Every leaf must +// satisfy the check; an empty fan-out passes vacuously. +export function holds(expectation, body) { + const [path, name, ...args] = expectation; + const results = resolve(path.split("."), body); + if (name === "absent") return results.every((r) => r === MISSING); + return results.every((r) => r !== MISSING && check(name, args, r)); +} + +// True when `body` is a JSON object satisfying every expectation. +export function ok(expectations, body) { + if (!Array.isArray(expectations)) throw new TypeError("expectations must be a list"); + if (!isMap(body)) return false; + return expectations.every((expectation) => holds(expectation, body)); +} + +// One result per expectation, for reporting: `{expectation, ok}`. When the +// body is not a JSON object every expectation fails, as in `ok`. +export function evaluate(expectations, body) { + const object = isMap(body); + return expectations.map((expectation) => ({ expectation, ok: object && holds(expectation, body) })); +} diff --git a/scripts/lib/manifest.mjs b/scripts/lib/manifest.mjs new file mode 100644 index 0000000..9051bf0 --- /dev/null +++ b/scripts/lib/manifest.mjs @@ -0,0 +1,63 @@ +// Manifest checks the JSON Schema cannot express, shared by +// scripts/check-connectors.mjs and the unit tests: the probe and every +// example must be routes the manifest forwards, expectations must be +// well-formed, and path_param must compile. Returns problems as strings; +// an empty list means the manifest passes. +import { compilePathParam, pathAllowed } from "./routes.mjs"; +import { problem as expectationProblem } from "./expect.mjs"; + +// The gateway matches routes on the path alone; a probe or example may carry +// a query string for the request. +export function stripQuery(path) { + const at = path.indexOf("?"); + return at === -1 ? path : path.slice(0, at); +} + +function expectProblems(at, expectations) { + if (expectations === undefined) return []; + if (!Array.isArray(expectations) || expectations.length === 0) + return [`${at} must be a non-empty list of [path, check, ...]`]; + const out = []; + expectations.forEach((expectation, i) => { + const why = expectationProblem(expectation); + if (why) out.push(`${at}[${i}] ${why}: ${JSON.stringify(expectation)}`); + }); + return out; +} + +export function manifestProblems(manifest) { + const problems = []; + const routes = Array.isArray(manifest.allowed_paths) ? manifest.allowed_paths : []; + let pathParam; + if (manifest.path_param !== undefined) { + try { + pathParam = compilePathParam(manifest.path_param); + } catch (error) { + problems.push(`path_param does not compile as a JS regex: ${error.message}`); + } + } + + const probe = manifest.probe ?? {}; + problems.push(...expectProblems("probe.expect", probe.expect)); + (Array.isArray(probe.errors) ? probe.errors : []).forEach((rule, i) => + problems.push(...expectProblems(`probe.errors[${i}].expect`, rule?.expect)), + ); + + const allowed = (method, path) => { + if (manifest.path_param !== undefined && !pathParam) return true; // reported above + try { + return pathAllowed(routes, method, stripQuery(path), { pathParam }); + } catch (error) { + problems.push(`allowed_paths cannot be matched: ${error.message}`); + return true; + } + }; + if (typeof probe.path === "string" && !allowed(probe.method ?? "GET", probe.path)) + problems.push(`probe ${probe.method ?? "GET"} ${probe.path} is not allowed by allowed_paths`); + (Array.isArray(manifest.examples) ? manifest.examples : []).forEach((example, i) => { + const method = example?.method ?? "GET"; + if (typeof example?.path === "string" && !allowed(method, example.path)) + problems.push(`examples[${i}] ${method} ${example.path} is not allowed by allowed_paths`); + }); + return problems; +} diff --git a/scripts/lib/routes.mjs b/scripts/lib/routes.mjs new file mode 100644 index 0000000..456840d --- /dev/null +++ b/scripts/lib/routes.mjs @@ -0,0 +1,130 @@ +// Port of the gateway's route matcher: `Sprites.Gateway.OAuthProvider.path_allowed?/3` +// and its helpers, plus `Sprites.Gateway.AccessPolicy.path_matches_any?/2` and +// `Sprites.Gateway.Connector.safe_path?/1` in sprites-api. v1/vectors.json is +// the parity contract between the two; keep this file in step with the Elixir +// rather than improving it on its own. +// +// A manifest's `allowed_paths` entry is either a bare pattern string, which +// allows every method, or `{methods, pattern}`, which allows only the listed +// upper-case methods. A pattern is one of: +// +// - an exact path: `/v1/models` matches only `/v1/models`; +// - a prefix ending in `*`: `/v1/*` matches every path starting with `/v1/` +// (so `/v1/` and `/v1/x`, but not `/v1`); +// - a path with `:name` segments: each matches one segment of +// `[A-Za-z0-9_-]+` (or the manifest's `path_param` regex), never a segment +// that another route uses literally at that position when that route's +// earlier segments also fit the path, and a trailing `*` after a parameter +// matches any non-empty remainder. Such paths must also pass `safePath`. + +export const DEFAULT_PATH_PARAM = "\\A[A-Za-z0-9_-]+\\z"; + +// `\A` and `\z` are PCRE anchors with no JS spelling; the manifest requires +// them, so translate the two anchors and compile the rest as-is. JS and PCRE +// agree on the character classes, quantifiers and groups a path parameter +// needs. Throws (like `Regex.compile/1`) when the source is not a regex. +export function compilePathParam(source) { + if (typeof source !== "string") throw new TypeError("path_param must be a string"); + if (!source.startsWith("\\A") || !source.endsWith("\\z")) + throw new SyntaxError("path_param must be anchored with \\A and \\z"); + return new RegExp(`^(?:${source.slice(2, -2)})$`); +} + +// True when a gateway path has no encoded separators or dots, backslashes, +// dot segments or empty segments (`Sprites.Gateway.Connector.safe_path?/1`). +export function safePath(path) { + return ( + !/%(?:2f|5c|2e|25)/i.test(path) && + !path.includes("\\") && + !path.includes("//") && + path.split("/").filter((s) => s !== "").every((s) => s !== "." && s !== "..") + ); +} + +// `Sprites.Gateway.AccessPolicy.path_matches_pattern?/2`: exact match, or a +// prefix match for a trailing `*`. A legacy `.*` suffix is also a prefix +// match, with backslash escapes stripped from the prefix. +export function accessPolicyMatches(path, pattern) { + if (pattern.endsWith(".*")) return path.startsWith(pattern.slice(0, -2).replaceAll("\\", "")); + if (pattern.endsWith("*")) return path.startsWith(pattern.slice(0, -1)); + return path === pattern; +} + +function normalizeRoute(entry) { + if (typeof entry === "string") return { methods: null, pattern: entry }; + if (entry && typeof entry === "object" && Array.isArray(entry.methods) && typeof entry.pattern === "string") + return { methods: entry.methods, pattern: entry.pattern }; + throw new TypeError(`allowed_paths entry must be a pattern or {methods, pattern}: ${JSON.stringify(entry)}`); +} + +// Whether `routes` (a manifest's `allowed_paths`) forward `method` `path`. +// `method` is an upper-case string such as "DELETE" (any case is accepted and +// upper-cased); `null` matches any method. `options.pathParam` is the manifest's +// `path_param` source, or a compiled RegExp. An empty route list allows +// everything, as in the Elixir; the manifest format forbids it. +export function pathAllowed(routes, method, path, options = {}) { + if (typeof path !== "string") throw new TypeError("path must be a string"); + const entries = routes.map(normalizeRoute); + if (entries.length === 0) return true; + const params = { + regex: + options.pathParam instanceof RegExp + ? options.pathParam + : compilePathParam(options.pathParam ?? DEFAULT_PATH_PARAM), + routes: entries.map((e) => e.pattern.split("/")), + }; + const wanted = method == null ? null : String(method).toUpperCase(); + return entries.some( + ({ methods, pattern }) => + (wanted === null || methods === null || methods.includes(wanted)) && + patternAllows(pattern, path, params), + ); +} + +function patternAllows(pattern, path, params) { + if (pattern.includes("/:")) return paramRouteAllows(pattern.split("/"), path, params); + return accessPolicyMatches(path, pattern); +} + +function paramRouteAllows(patternSegments, path, params) { + const pathSegments = path.split("/"); + return safePath(path) && segmentsMatch(patternSegments, pathSegments, 0, pathSegments, params); +} + +// Like a prefix pattern, `*` needs the separator before it: `/a/:id/*` +// matches `/a/x/` and `/a/x/y`, not `/a/x`. +function segmentsMatch(pattern, path, index, all, params) { + if (pattern.length === 1 && pattern[0] === "*" && path.length >= 1) return true; + if (pattern.length === 0) return path.length === 0; + if (path.length === 0) return false; + const [head, ...restPattern] = pattern; + const [segment, ...restPath] = path; + if (head.startsWith(":")) { + return ( + params.regex.test(segment) && + !reserved(params.routes, all, index, segment, params.regex) && + segmentsMatch(restPattern, restPath, index + 1, all, params) + ); + } + return head === segment && segmentsMatch(restPattern, restPath, index + 1, all, params); +} + +// A segment is reserved when another route uses it literally at this +// position and that route's earlier segments also fit the path, so +// `/jobs/cancel` competes with `/jobs/:id` but `/agents/:id/state` does not +// reach `/trust/advisories/:slug`. +function reserved(routes, pathSegments, index, segment, regex) { + return routes.some( + (route) => + route[index] === segment && prefixFits(route.slice(0, index), pathSegments.slice(0, index), regex), + ); +} + +function prefixFits(routePrefix, pathPrefix, regex) { + return ( + routePrefix.length === pathPrefix.length && + routePrefix.every((literal, i) => + literal.startsWith(":") ? regex.test(pathPrefix[i]) : literal === pathPrefix[i], + ) + ); +} diff --git a/scripts/new.mjs b/scripts/new.mjs new file mode 100755 index 0000000..886ee9e --- /dev/null +++ b/scripts/new.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +// Starts a connector from template/connector: +// +// node scripts/new.mjs +// +// copies the template to connectors// and sets "name" and +// "display_name" in the manifest. Refuses an invalid name or an existing +// directory. +import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url).pathname; +export const NAME = /^[a-z][a-z0-9_]*$/; + +export function displayName(name) { + return name + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +// Creates connectors/ under `root` and returns its path. Throws on an +// invalid name or an existing directory. The manifest is edited textually so +// the template's formatting survives. +export function createConnector(name, { root: base = root } = {}) { + if (typeof name !== "string" || !NAME.test(name)) + throw new Error(`connector name must match ${NAME} (lower-case letters, digits and _), got ${JSON.stringify(name)}`); + const dir = join(base, "connectors", name); + if (existsSync(dir)) throw new Error(`connectors/${name} already exists`); + const template = join(base, "template", "connector"); + if (!existsSync(join(template, "connector.json"))) throw new Error("template/connector is missing"); + cpSync(template, dir, { recursive: true, errorOnExist: true, force: false }); + + const manifestPath = join(dir, "connector.json"); + const display = displayName(name); + let text = readFileSync(manifestPath, "utf8"); + for (const [field, value] of [["name", name], ["display_name", display]]) { + const re = new RegExp(`("${field}"\\s*:\\s*)"[^"]*"`); + if (!re.test(text)) throw new Error(`template manifest has no "${field}" field`); + text = text.replace(re, `$1${JSON.stringify(value)}`); + } + writeFileSync(manifestPath, text); + + const readme = join(dir, "README.md"); + if (existsSync(readme)) writeFileSync(readme, readFileSync(readme, "utf8").replace(/^# .*$/m, `# ${display}`)); + return dir; +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + const [name, ...rest] = process.argv.slice(2); + if (!name || rest.length) { + console.error("usage: node scripts/new.mjs "); + process.exit(2); + } + try { + const dir = createConnector(name); + console.log(`created ${dir.replace(root, "")}: edit connector.json, replace icon.svg and mark.svg, then run the checks`); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} diff --git a/scripts/probe.mjs b/scripts/probe.mjs new file mode 100755 index 0000000..f50c8f3 --- /dev/null +++ b/scripts/probe.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +// Runs a connector's probe against the real API with your own key, the way +// the gateway does when a key is saved or tested: one request, no retries, +// no redirects, the manifest's auth and test_request_headers, and the probe's +// receive_timeout (default 10 s). A 200 passes when every `expect` assertion +// holds; any other status is classified with the `errors` rules, then the +// 401 default. The key is read from the environment and never printed. +// +// node scripts/probe.mjs --key-env VAR [--write-fixture] +// +// --write-fixture records the response as connectors//fixtures/ +// probe_.json after redaction: every string equal to or containing +// the key is dropped, as is any header named authorization or the manifest's +// auth header. Exits non-zero when the probe fails. +import { readFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { evaluate, ok as expectOk } from "./lib/expect.mjs"; + +export const USER_AGENT = "Sprites-API-Gateway"; +export const DEFAULT_RECEIVE_TIMEOUT = 10_000; + +const root = new URL("..", import.meta.url).pathname; + +// `Sprites.Gateway.Connector.auth_header/2`. +export function authHeader(auth, key) { + if (auth === "bearer") return ["authorization", `Bearer ${key}`]; + if (auth && typeof auth.header === "string") + return [auth.header, auth.prefix ? `${auth.prefix} ${key}` : key]; + throw new TypeError(`auth must be "bearer" or {"header": name, "prefix"?: text}`); +} + +// Headers the gateway sends with the probe: auth, user-agent, then the +// manifest's test_request_headers or their default (content-type json iff +// the probe has a JSON body). +export function buildProbeRequest(manifest, key) { + const probe = manifest.probe; + const testHeaders = + manifest.test_request_headers ?? + (probe.json !== undefined ? [{ name: "content-type", value: "application/json" }] : []); + const headers = [authHeader(manifest.auth, key), ["user-agent", USER_AGENT]]; + for (const { name, value } of testHeaders) headers.push([name, value]); + return { + method: probe.method, + url: manifest.base_url + probe.path, + headers, + body: probe.json !== undefined ? JSON.stringify(probe.json) : undefined, + timeout: probe.receive_timeout ?? DEFAULT_RECEIVE_TIMEOUT, + }; +} + +// `Sprites.Gateway.Connector.probe_error/3` then the gateway's defaults. +export function classify(manifest, status, body) { + for (const rule of manifest.probe.errors ?? []) { + if (rule.status !== status) continue; + if (rule.expect === undefined || expectOk(rule.expect, body)) return rule.reason; + } + return status === 401 ? "invalid_api_key" : `validation_failed (${status})`; +} + +function decodeBody(contentType, text) { + if (!/json/i.test(contentType ?? "")) return { body: text, json: false }; + try { + return { body: JSON.parse(text), json: true }; + } catch { + return { body: text, json: false }; + } +} + +// Drops every string equal to or containing the key, wherever it sits. +export function redact(value, key) { + if (typeof value === "string") return value.includes(key) ? undefined : value; + if (Array.isArray(value)) return value.map((v) => redact(v, key)).filter((v) => v !== undefined); + if (value && typeof value === "object") { + const out = {}; + for (const [k, v] of Object.entries(value)) { + if (k.includes(key)) continue; + const r = redact(v, key); + if (r !== undefined) out[k] = r; + } + return out; + } + return value; +} + +export function redactHeaders(headers, manifest, key) { + const authName = manifest.auth === "bearer" ? "authorization" : manifest.auth.header.toLowerCase(); + const out = {}; + for (const [name, value] of Object.entries(headers)) { + const lower = name.toLowerCase(); + if (lower === "authorization" || lower === authName) continue; + const r = redact(value, key); + if (r !== undefined) out[name] = r; + } + return out; +} + +// Sends the probe and judges the response. Returns +// {ok, status, reason, results, fixture, note}; throws nothing for provider +// errors (they come back as reason "provider_unreachable"). +export async function runProbe(manifest, key, { fetch = globalThis.fetch } = {}) { + const request = buildProbeRequest(manifest, key); + let response; + try { + response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + redirect: "manual", + signal: AbortSignal.timeout(request.timeout), + }); + } catch (error) { + const cause = error?.cause?.code ?? error?.name ?? "error"; + return { ok: false, status: null, reason: `provider_unreachable (${cause})`, results: [] }; + } + const text = await response.text(); + const { body, json } = decodeBody(response.headers.get("content-type"), text); + const headers = Object.fromEntries(response.headers.entries()); + const note = json ? undefined : "response is not JSON; the gateway keeps it as text, so no expectation can hold"; + const fixture = { + probe: { method: request.method, path: manifest.probe.path }, + status: response.status, + headers: redactHeaders(headers, manifest, key), + body: redact(body, key) ?? null, + }; + if (response.status !== 200) { + return { ok: false, status: response.status, reason: classify(manifest, response.status, body), results: [], fixture, note }; + } + if (!Array.isArray(manifest.probe.expect)) { + return { ok: false, status: 200, reason: "probe has no expect list; a manifest without one relies on hooks", results: [], fixture, note }; + } + const results = evaluate(manifest.probe.expect, body); + const ok = results.every((r) => r.ok); + return { ok, status: 200, reason: ok ? undefined : "unexpected_response", results, fixture, note }; +} + +export function writeFixture(name, fixture, { dir = join(root, "connectors", name, "fixtures") } = {}) { + mkdirSync(dir, { recursive: true }); + const file = join(dir, `probe_${fixture.status}.json`); + writeFileSync(file, JSON.stringify(fixture, null, 2) + "\n"); + return file; +} + +function usage(message) { + console.error(message); + console.error("usage: node scripts/probe.mjs --key-env VAR [--write-fixture]"); + process.exit(2); +} + +export async function main(argv) { + const args = [...argv]; + const name = args.shift(); + let keyEnv; + let write = false; + while (args.length) { + const arg = args.shift(); + if (arg === "--key-env") keyEnv = args.shift(); + else if (arg.startsWith("--key-env=")) keyEnv = arg.slice("--key-env=".length); + else if (arg === "--write-fixture") write = true; + else usage(`unknown argument ${arg}`); + } + if (!name || !/^[a-z][a-z0-9_]*$/.test(name)) usage("connector name is required"); + if (!keyEnv) usage("--key-env VAR is required"); + const key = (process.env[keyEnv] ?? "").trim(); + if (!key) usage(`${keyEnv} is not set`); + + let manifest; + try { + manifest = JSON.parse(readFileSync(join(root, "connectors", name, "connector.json"), "utf8")); + } catch (error) { + usage(`cannot read connectors/${name}/connector.json: ${error.message}`); + } + const request = buildProbeRequest(manifest, key); + console.log(`${request.method} ${request.url} (timeout ${request.timeout} ms)`); + const result = await runProbe(manifest, key); + if (result.status === null) { + console.log(`FAIL ${result.reason}`); + process.exit(1); + } + console.log(`HTTP ${result.status}`); + if (result.note) console.log(`note: ${result.note}`); + for (const { expectation, ok } of result.results) console.log(` ${ok ? "ok " : "FAIL"} ${JSON.stringify(expectation)}`); + if (write) console.log(`wrote ${writeFixture(name, result.fixture).replace(root, "")}`); + if (result.ok) { + console.log("probe ok"); + } else { + console.log(`FAIL ${result.reason}`); + process.exit(1); + } +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + await main(process.argv.slice(2)); +} diff --git a/scripts/test/expect.test.mjs b/scripts/test/expect.test.mjs new file mode 100644 index 0000000..9249f7f --- /dev/null +++ b/scripts/test/expect.test.mjs @@ -0,0 +1,45 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { ok, validate, problem, evaluate, deepEqual } from "../lib/expect.mjs"; + +const vectors = JSON.parse(readFileSync(new URL("../../v1/vectors.json", import.meta.url), "utf8")); + +test("every expectation vector in v1/vectors.json", () => { + assert.ok(vectors.expectations.length > 50); + for (const c of vectors.expectations) { + validate(c.expect); + assert.equal(ok(c.expect, c.body), c.ok, c.name); + } +}); + +test("every malformed expectation vector is rejected", () => { + assert.ok(vectors.malformed_expectations.length > 10); + for (const c of vectors.malformed_expectations) assert.throws(() => validate(c.expect), TypeError, c.name); +}); + +test("problem names what is wrong and accepts the committed shapes", () => { + assert.equal(problem(["a.b[]", "text"]), null); + assert.equal(problem(["a", "between", 1, 1]), null); + assert.equal(problem(["a", "eq", null]), null); + assert.match(problem(["a", "between", 1, 0]), /lo <= hi/); + assert.match(problem(["a", "min", "1"]), /number/); + assert.match(problem(["a[]b", "text"]), /dotted/); + assert.match(problem(["a", "nope"]), /unknown/); +}); + +test("evaluate reports each expectation and fails all on a non-object body", () => { + const expect = [["id", "text"], ["error", "absent"]]; + assert.deepEqual( + evaluate(expect, { id: "x", error: "e" }).map((r) => r.ok), + [true, false], + ); + assert.deepEqual(evaluate(expect, "text").map((r) => r.ok), [false, false]); +}); + +test("deepEqual is structural and order-insensitive for objects", () => { + assert.ok(deepEqual({ a: [1, { b: null }] }, { a: [1, { b: null }] })); + assert.ok(!deepEqual({ a: 1 }, { a: 1, b: 2 })); + assert.ok(!deepEqual([1, 2], [2, 1])); + assert.ok(!deepEqual(null, {})); +}); diff --git a/scripts/test/manifest.test.mjs b/scripts/test/manifest.test.mjs new file mode 100644 index 0000000..4f21ed3 --- /dev/null +++ b/scripts/test/manifest.test.mjs @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { manifestProblems, stripQuery } from "../lib/manifest.mjs"; + +const root = new URL("../../", import.meta.url); +const read = (path) => JSON.parse(readFileSync(new URL(path, root), "utf8")); +const names = readdirSync(new URL("connectors/", root)); + +test("every committed connector and the template pass the manifest checks", () => { + assert.equal(names.length, 11); + for (const name of names) assert.deepEqual(manifestProblems(read(`connectors/${name}/connector.json`)), [], name); + assert.deepEqual(manifestProblems(read("template/connector/connector.json")), []); +}); + +test("check-connectors.mjs passes on the committed connectors", () => { + const out = execFileSync(process.execPath, [new URL("scripts/check-connectors.mjs", root).pathname], { encoding: "utf8" }); + assert.match(out, /11 connector\(s\) ok/); +}); + +test("stripQuery", () => { + assert.equal(stripQuery("/v1/listen?model=nova-3"), "/v1/listen"); + assert.equal(stripQuery("/v1/listen"), "/v1/listen"); +}); + +const base = { + allowed_paths: [{ methods: ["GET"], pattern: "/me" }, { methods: ["POST"], pattern: "/items/:id" }], + probe: { method: "GET", path: "/me?x=1", expect: [["id", "text"]] }, + examples: [{ title: "t", method: "POST", path: "/items/abc" }], +}; + +test("probe and example paths must be forwarded routes", () => { + assert.deepEqual(manifestProblems(base), []); + assert.match(manifestProblems({ ...base, probe: { ...base.probe, method: "POST" } })[0], /probe POST \/me\?x=1 is not allowed/); + assert.match(manifestProblems({ ...base, examples: [{ title: "t", path: "/items/abc" }] })[0], /examples\[0\] GET \/items\/abc/); + assert.match(manifestProblems({ ...base, examples: [{ title: "t", method: "POST", path: "/items/a.b" }] })[0], /examples\[0\]/); +}); + +test("expectations in the probe and error rules must be well-formed", () => { + const problems = manifestProblems({ + ...base, + probe: { ...base.probe, expect: [["id", "text"], ["n", "min", "1"]], errors: [{ status: 403, reason: "invalid_api_key", expect: [] }, { status: 400, reason: "agent_key_required", expect: [["detail", "contains", ""]] }] }, + }); + assert.equal(problems.length, 3); + assert.match(problems[0], /probe\.expect\[1\] min needs a number/); + assert.match(problems[1], /probe\.errors\[0\]\.expect must be a non-empty list/); + assert.match(problems[2], /probe\.errors\[1\]\.expect\[0\] contains needs a non-empty string/); + assert.match(manifestProblems({ ...base, probe: { ...base.probe, expect: [] } })[0], /probe\.expect must be a non-empty list/); +}); + +test("path_param must compile and is used for the route checks", () => { + assert.match(manifestProblems({ ...base, path_param: "[a-z]+" })[0], /path_param does not compile/); + assert.match(manifestProblems({ ...base, path_param: "\\A[a-z\\z" })[0], /path_param does not compile/); + assert.deepEqual(manifestProblems({ ...base, path_param: "\\A[a-z.]+\\z", examples: [{ title: "t", method: "POST", path: "/items/a.b" }] }), []); +}); diff --git a/scripts/test/new.test.mjs b/scripts/test/new.test.mjs new file mode 100644 index 0000000..9cc99ed --- /dev/null +++ b/scripts/test/new.test.mjs @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, cpSync, rmSync, readFileSync, readdirSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { createConnector, displayName } from "../new.mjs"; +import { manifestProblems } from "../lib/manifest.mjs"; + +const repo = new URL("../../", import.meta.url).pathname; + +function sandbox() { + const root = mkdtempSync(join(tmpdir(), "new-")); + cpSync(join(repo, "template"), join(root, "template"), { recursive: true }); + mkdirSync(join(root, "connectors")); + return root; +} + +test("createConnector copies the template and sets the names", () => { + const root = sandbox(); + try { + const dir = createConnector("acme_search", { root }); + assert.equal(dir, join(root, "connectors", "acme_search")); + assert.deepEqual(readdirSync(dir).sort(), readdirSync(join(repo, "template", "connector")).sort()); + const text = readFileSync(join(dir, "connector.json"), "utf8"); + const manifest = JSON.parse(text); + assert.equal(manifest.name, "acme_search"); + assert.equal(manifest.display_name, "Acme Search"); + assert.equal(manifest.base_url, "https://api.example.com"); + assert.ok(text.includes('{"methods": ["POST"], "pattern": "/v1/search"}'), "template formatting is kept"); + assert.deepEqual(manifestProblems(manifest), []); + assert.match(readFileSync(join(dir, "README.md"), "utf8"), /^# Acme Search$/m); + } finally { + rmSync(root, { recursive: true }); + } +}); + +test("createConnector refuses an existing directory or an invalid name", () => { + const root = sandbox(); + try { + createConnector("acme", { root }); + assert.throws(() => createConnector("acme", { root }), /already exists/); + for (const bad of ["Acme", "9x", "a-b", "", "a b", "../x"]) assert.throws(() => createConnector(bad, { root }), /name must match/, bad); + } finally { + rmSync(root, { recursive: true }); + } +}); + +test("displayName", () => { + assert.equal(displayName("exa"), "Exa"); + assert.equal(displayName("agent_mail2"), "Agent Mail2"); +}); + +test("the CLI exits non-zero on a bad name without touching the repo", () => { + assert.throws(() => execFileSync(process.execPath, [join(repo, "scripts/new.mjs"), "Bad"], { stdio: "pipe" })); + assert.throws(() => execFileSync(process.execPath, [join(repo, "scripts/new.mjs")], { stdio: "pipe" })); + assert.throws(() => execFileSync(process.execPath, [join(repo, "scripts/new.mjs"), "exa"], { stdio: "pipe" }), /already exists/); +}); diff --git a/scripts/test/probe.test.mjs b/scripts/test/probe.test.mjs new file mode 100644 index 0000000..3acdbb9 --- /dev/null +++ b/scripts/test/probe.test.mjs @@ -0,0 +1,178 @@ +// Exercises the probe runner against a local http.createServer stub; nothing +// here touches the network. +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildProbeRequest, runProbe, classify, redact, redactHeaders, writeFixture, authHeader } from "../probe.mjs"; + +const KEY = "sk-live-SECRET123"; +let last; +const server = createServer((req, res) => { + let data = ""; + req.on("data", (chunk) => (data += chunk)); + req.on("end", () => { + last = { method: req.method, url: req.url, headers: req.headers, body: data }; + const route = routes[req.url] ?? routes["*"]; + route(res); + }); +}); +const routes = { + "/me": (res) => { + res.writeHead(200, { "content-type": "application/json; charset=utf-8", "x-request-id": "r1", "x-echo": KEY }); + res.end(JSON.stringify({ id: "u1", token: `prefix ${KEY} suffix`, nested: [{ k: KEY }, { k: "fine" }], error: null })); + }, + "/search": (res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ results: [{ url: "" }] })); + }, + "/forbidden": (res) => { + res.writeHead(403, { "content-type": "application/json" }); + res.end(JSON.stringify({ detail: "bad key" })); + }, + "/agent": (res) => { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ detail: "need X-Agent-Id" })); + }, + "/unauthorized": (res) => { + res.writeHead(401); + res.end("nope"); + }, + "/redirect": (res) => { + res.writeHead(302, { location: "/me" }); + res.end(); + }, + "/html": (res) => { + res.writeHead(200, { "content-type": "text/html" }); + res.end(""); + }, + "/slow": (res) => setTimeout(() => res.end("{}"), 2_000), +}; +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const base_url = `http://127.0.0.1:${server.address().port}`; +after(() => server.close()); + +const manifest = (probe, extra = {}) => ({ + name: "stub", + base_url, + auth: { header: "x-api-key" }, + allowed_paths: ["/"], + probe: { method: "GET", path: "/me", label: "me", expect: [["id", "text"], ["error", "absent"]], ...probe }, + ...extra, +}); + +test("buildProbeRequest mirrors the gateway's request", () => { + const r = buildProbeRequest(manifest({ method: "POST", path: "/search?x=1", json: { q: "x" }, receive_timeout: 5000 }, { auth: "bearer" }), KEY); + assert.equal(r.method, "POST"); + assert.equal(r.url, `${base_url}/search?x=1`); + assert.deepEqual(r.headers, [["authorization", `Bearer ${KEY}`], ["user-agent", "Sprites-API-Gateway"], ["content-type", "application/json"]]); + assert.equal(r.body, '{"q":"x"}'); + assert.equal(r.timeout, 5000); + + const g = buildProbeRequest(manifest({}, { auth: { header: "authorization", prefix: "Token" }, test_request_headers: [{ name: "accept", value: "application/json" }] }), KEY); + assert.deepEqual(g.headers, [["authorization", `Token ${KEY}`], ["user-agent", "Sprites-API-Gateway"], ["accept", "application/json"]]); + assert.equal(g.body, undefined); + assert.equal(g.timeout, 10_000); + assert.deepEqual(authHeader({ header: "x-key" }, KEY), ["x-key", KEY]); + assert.throws(() => authHeader("basic", KEY), TypeError); +}); + +test("a 200 whose body satisfies expect passes, and the fixture is redacted", async () => { + const m = manifest({}); + const result = await runProbe(m, KEY); + assert.equal(result.ok, true); + assert.equal(result.status, 200); + assert.deepEqual(result.results.map((r) => r.ok), [true, true]); + assert.equal(last.headers["x-api-key"], KEY); + assert.equal(last.headers["user-agent"], "Sprites-API-Gateway"); + assert.equal(last.headers["content-type"], undefined); + assert.equal(last.body, ""); + + const text = JSON.stringify(result.fixture); + assert.ok(!text.includes(KEY), "fixture must not contain the key"); + assert.deepEqual(result.fixture.body, { id: "u1", nested: [{}, { k: "fine" }], error: null }); + assert.equal(result.fixture.headers["x-request-id"], "r1"); + assert.equal(result.fixture.headers["x-echo"], undefined); + assert.deepEqual(result.fixture.probe, { method: "GET", path: "/me" }); + + const dir = mkdtempSync(join(tmpdir(), "probe-")); + try { + const file = writeFixture("stub", result.fixture, { dir }); + assert.equal(file, join(dir, "probe_200.json")); + assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), result.fixture); + } finally { + rmSync(dir, { recursive: true }); + } +}); + +test("a JSON body is sent with the default content-type", async () => { + const result = await runProbe(manifest({ method: "POST", path: "/search", json: { q: "x" }, expect: [["results[].url", "text"]] }), KEY); + assert.equal(result.ok, false); + assert.equal(result.reason, "unexpected_response"); + assert.deepEqual(result.results.map((r) => r.ok), [false]); + assert.equal(last.method, "POST"); + assert.equal(last.headers["content-type"], "application/json"); + assert.equal(last.body, '{"q":"x"}'); +}); + +test("non-200 responses are classified by the errors rules, then the 401 default", async () => { + const errors = [ + { status: 403, reason: "invalid_api_key" }, + { status: 400, expect: [["detail", "contains", "X-Agent-Id"]], reason: "agent_key_required" }, + ]; + assert.equal((await runProbe(manifest({ path: "/forbidden", errors }), KEY)).reason, "invalid_api_key"); + assert.equal((await runProbe(manifest({ path: "/agent", errors }), KEY)).reason, "agent_key_required"); + assert.equal(classify(manifest({ errors }), 400, { detail: "other" }), "validation_failed (400)"); + assert.equal(classify(manifest({ errors: [{ status: 401, reason: "admin_key_not_supported" }] }), 401, "x"), "admin_key_not_supported"); + const unauthorized = await runProbe(manifest({ path: "/unauthorized" }), KEY); + assert.equal(unauthorized.ok, false); + assert.equal(unauthorized.status, 401); + assert.equal(unauthorized.reason, "invalid_api_key"); + assert.equal(unauthorized.fixture.body, "nope"); +}); + +test("redirects are not followed", async () => { + const result = await runProbe(manifest({ path: "/redirect" }), KEY); + assert.equal(result.status, 302); + assert.equal(result.reason, "validation_failed (302)"); +}); + +test("a non-JSON 200 cannot satisfy expectations", async () => { + const result = await runProbe(manifest({ path: "/html" }), KEY); + assert.equal(result.ok, false); + assert.match(result.note, /not JSON/); + assert.deepEqual(result.results.map((r) => r.ok), [false, false]); +}); + +test("a missing expect list is reported, not passed", async () => { + const m = manifest({}); + delete m.probe.expect; + const result = await runProbe(m, KEY); + assert.equal(result.ok, false); + assert.match(result.reason, /no expect/); +}); + +test("the receive_timeout aborts a slow probe as unreachable", async () => { + const result = await runProbe(manifest({ path: "/slow", receive_timeout: 100 }), KEY); + assert.equal(result.status, null); + assert.match(result.reason, /provider_unreachable/); +}); + +test("connection errors are unreachable, never thrown", async () => { + const result = await runProbe(manifest({}, { base_url: "http://127.0.0.1:1" }), KEY); + assert.equal(result.status, null); + assert.match(result.reason, /provider_unreachable/); +}); + +test("redaction drops strings holding the key and the auth headers", () => { + assert.deepEqual(redact({ a: KEY, b: [`x${KEY}`, "ok", 1], c: { d: KEY, e: null } }, KEY), { b: ["ok", 1], c: { e: null } }); + assert.equal(redact(KEY, KEY), undefined); + assert.equal(redact(7, KEY), 7); + assert.deepEqual( + redactHeaders({ Authorization: "x", "X-API-KEY": "y", "x-other": "z", "x-leak": KEY }, { auth: { header: "x-api-key" } }, KEY), + { "x-other": "z" }, + ); + assert.deepEqual(redactHeaders({ authorization: "x", "x-api-key": "y" }, { auth: "bearer" }, KEY), { "x-api-key": "y" }); +}); diff --git a/scripts/test/routes.test.mjs b/scripts/test/routes.test.mjs new file mode 100644 index 0000000..4299610 --- /dev/null +++ b/scripts/test/routes.test.mjs @@ -0,0 +1,36 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { compilePathParam, pathAllowed, safePath, DEFAULT_PATH_PARAM } from "../lib/routes.mjs"; + +const vectors = JSON.parse(readFileSync(new URL("../../v1/vectors.json", import.meta.url), "utf8")); + +test("every route vector in v1/vectors.json", () => { + assert.ok(vectors.routes.length > 40); + for (const c of vectors.routes) { + const got = pathAllowed(c.routes, c.method, c.path, { pathParam: c.path_param }); + assert.equal(got, c.allowed, `${c.name}: ${c.method ?? "any"} ${c.path}`); + } +}); + +test("compilePathParam translates the PCRE anchors and rejects the rest", () => { + const re = compilePathParam(DEFAULT_PATH_PARAM); + assert.ok(re.test("a-1_B")); + assert.ok(!re.test("a/b")); + assert.ok(!re.test("a\nb")); + assert.ok(!re.test("")); + assert.throws(() => compilePathParam("[a-z]+"), /anchored/); + assert.throws(() => compilePathParam("\\A[a-z\\z"), SyntaxError); + assert.throws(() => compilePathParam(null), TypeError); + assert.ok(pathAllowed(["/x/:id"], "GET", "/x/A.B", { pathParam: compilePathParam("\\A[A-Z.]+\\z") })); +}); + +test("safePath", () => { + for (const p of ["/a", "/a/b-c", "/a/", "/", "/a.b/c"]) assert.ok(safePath(p), p); + for (const p of ["/a//b", "/a/./b", "/a/..", "/a%2Fb", "/a%5cb", "/%2E", "/a%25", "/a\\b"]) assert.ok(!safePath(p), p); +}); + +test("route entries must be a pattern or {methods, pattern}", () => { + assert.throws(() => pathAllowed([{ pattern: "/a" }], "GET", "/a"), TypeError); + assert.equal(pathAllowed([], "GET", "/anything"), true); +}); diff --git a/template/connector/connector.json b/template/connector/connector.json index 44e7048..da5a22e 100644 --- a/template/connector/connector.json +++ b/template/connector/connector.json @@ -9,6 +9,7 @@ "website_url": "https://example.com/settings/api-keys", "auth": {"header": "x-api-key"}, "allowed_paths": [ + {"methods": ["GET"], "pattern": "/v1/me"}, {"methods": ["POST"], "pattern": "/v1/search"}, {"methods": ["GET"], "pattern": "/v1/items/:id"} ], @@ -36,7 +37,7 @@ "setup": "Create an API key at example.com/settings/api-keys.", "notices": [ {"title": "Billing", "body": "Saving or testing a key reads your account; searches use your Example credits."}, - {"title": "Scope", "body": "Only `POST /v1/search` and `GET /v1/items/:id` are forwarded."} + {"title": "Scope", "body": "Only `GET /v1/me`, `POST /v1/search` and `GET /v1/items/:id` are forwarded."} ], "mark_svg": {"file": "mark.svg"} } diff --git a/v1/vectors.json b/v1/vectors.json new file mode 100644 index 0000000..558f809 --- /dev/null +++ b/v1/vectors.json @@ -0,0 +1,3131 @@ +{ + "$comment": "Parity contract for the connector manifest route matcher and expectation evaluator. sprites-api (Sprites.Gateway.OAuthProvider.path_allowed?/3, Sprites.Gateway.Connector.Expect and the manifest decoding of expectations) and this repository's scripts/lib/{routes,expect}.mjs must both run every case in this file and agree with it; a change to the semantics is made in sprites-api first and then mirrored here with its vector. The cases come from sprites-api's test/sprites/gateway/oauth_provider_routes_test.exs and test/sprites/gateway/connector/expect_test.exs. routes[]: allowed_paths as in a manifest, optional path_param (PCRE source anchored with \\A and \\z), method (upper-case, any case accepted, null for any method), path, and whether the gateway forwards it. expectations[]: a probe expect list, a decoded JSON body, and whether every expectation holds. malformed_expectations[]: expect lists the manifest loader must reject. Bodies avoid integral floats such as 1.0: Elixir decodes them as floats and JSON.parse cannot tell them from integers, so `integer` is the one check whose answer depends on the decoder.", + "routes": [ + { + "name": "parameter segment matches one identifier", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a1/balance", + "allowed": true + }, + { + "name": "identifier may hold upper case, digits, - and _", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/A-1_b/balance", + "allowed": true + }, + { + "name": "method list is matched case-insensitively", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "patch", + "path": "/inboxes/in-1", + "allowed": true + }, + { + "name": "PATCH listed on the route", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "PATCH", + "path": "/inboxes/in-1", + "allowed": true + }, + { + "name": "a null method matches any method on a bare pattern", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": null, + "path": "/open/x/y", + "allowed": true + }, + { + "name": "a null method matches any method on a method-restricted route", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": null, + "path": "/inboxes/in-1", + "allowed": true + }, + { + "name": "fewer segments than the pattern", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a1", + "allowed": false + }, + { + "name": "more segments than the pattern", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a1/balance/extra", + "allowed": false + }, + { + "name": "empty segment is not an identifier", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts//balance", + "allowed": false + }, + { + "name": "trailing slash is an extra empty segment", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a1/balance/", + "allowed": false + }, + { + "name": "method not listed on the route", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "POST", + "path": "/inboxes/in-1", + "allowed": false + }, + { + "name": "bare pattern still needs every segment", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/open/x", + "allowed": false + }, + { + "name": "identifier excludes \"..\"", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/../balance", + "allowed": false + }, + { + "name": "identifier excludes \".\"", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/./balance", + "allowed": false + }, + { + "name": "identifier excludes \"%2e%2e\"", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/%2e%2e/balance", + "allowed": false + }, + { + "name": "identifier excludes \"a%2Fb\"", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a%2Fb/balance", + "allowed": false + }, + { + "name": "identifier excludes \"a.b\"", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a.b/balance", + "allowed": false + }, + { + "name": "identifier excludes \"a@b\"", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a@b/balance", + "allowed": false + }, + { + "name": "identifier excludes \"id?admin=1\"", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/id?admin=1/balance", + "allowed": false + }, + { + "name": "identifier excludes \"é\"", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/é/balance", + "allowed": false + }, + { + "name": "identifier excludes \"a b\"", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a b/balance", + "allowed": false + }, + { + "name": "identifier excludes \"\"", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts//balance", + "allowed": false + }, + { + "name": "identifier excludes a backslash", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a\\b/balance", + "allowed": false + }, + { + "name": "identifier excludes %25", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a%25b/balance", + "allowed": false + }, + { + "name": "identifier excludes %5C (any case)", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/a%5Cb/balance", + "allowed": false + }, + { + "name": "trailing * after parameters matches an empty remainder after the slash", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/inboxes/in-1/messages/", + "allowed": true + }, + { + "name": "trailing * after parameters matches a deep remainder", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/inboxes/in-1/messages/m1/reply", + "allowed": true + }, + { + "name": "trailing * needs the separator before it", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/inboxes/in-1/messages", + "allowed": false + }, + { + "name": "wildcard remainder must be a safe path: dot segment", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/inboxes/in-1/messages/../keys", + "allowed": false + }, + { + "name": "wildcard remainder must be a safe path: encoded dot", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/inboxes/in-1/messages/%2e%2e", + "allowed": false + }, + { + "name": "wildcard remainder must be a safe path: empty segment", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/inboxes/in-1/messages//x", + "allowed": false + }, + { + "name": "exact pattern matches itself", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts", + "allowed": true + }, + { + "name": "exact pattern does not match a trailing slash", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/accounts" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET", + "PATCH" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id/messages/*" + }, + "/open/:kind/:id" + ], + "method": "GET", + "path": "/accounts/", + "allowed": false + }, + { + "name": "reserved: a parameter matches an ordinary id", + "routes": [ + { + "methods": [ + "GET", + "DELETE" + ], + "pattern": "/jobs/:id" + }, + { + "methods": [ + "POST" + ], + "pattern": "/jobs/cancel" + }, + { + "methods": [ + "GET" + ], + "pattern": "/jobs/operations" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/summary/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/agents/:id/state" + }, + { + "methods": [ + "GET" + ], + "pattern": "/trust/advisories/:slug" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/messages/send" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/drafts/:draft" + } + ], + "method": "GET", + "path": "/jobs/job-1", + "allowed": true + }, + { + "name": "reserved: the literal route takes its own method", + "routes": [ + { + "methods": [ + "GET", + "DELETE" + ], + "pattern": "/jobs/:id" + }, + { + "methods": [ + "POST" + ], + "pattern": "/jobs/cancel" + }, + { + "methods": [ + "GET" + ], + "pattern": "/jobs/operations" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/summary/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/agents/:id/state" + }, + { + "methods": [ + "GET" + ], + "pattern": "/trust/advisories/:slug" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/messages/send" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/drafts/:draft" + } + ], + "method": "POST", + "path": "/jobs/cancel", + "allowed": true + }, + { + "name": "reserved: parameter route beside an unrelated literal", + "routes": [ + { + "methods": [ + "GET", + "DELETE" + ], + "pattern": "/jobs/:id" + }, + { + "methods": [ + "POST" + ], + "pattern": "/jobs/cancel" + }, + { + "methods": [ + "GET" + ], + "pattern": "/jobs/operations" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/summary/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/agents/:id/state" + }, + { + "methods": [ + "GET" + ], + "pattern": "/trust/advisories/:slug" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/messages/send" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/drafts/:draft" + } + ], + "method": "GET", + "path": "/accounts/a1/balance", + "allowed": true + }, + { + "name": "reserved: a parameter never matches a literal another route uses at that position", + "routes": [ + { + "methods": [ + "GET", + "DELETE" + ], + "pattern": "/jobs/:id" + }, + { + "methods": [ + "POST" + ], + "pattern": "/jobs/cancel" + }, + { + "methods": [ + "GET" + ], + "pattern": "/jobs/operations" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/summary/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/agents/:id/state" + }, + { + "methods": [ + "GET" + ], + "pattern": "/trust/advisories/:slug" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/messages/send" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/drafts/:draft" + } + ], + "method": "GET", + "path": "/jobs/cancel", + "allowed": false + }, + { + "name": "reserved: even for a method the literal route lacks", + "routes": [ + { + "methods": [ + "GET", + "DELETE" + ], + "pattern": "/jobs/:id" + }, + { + "methods": [ + "POST" + ], + "pattern": "/jobs/cancel" + }, + { + "methods": [ + "GET" + ], + "pattern": "/jobs/operations" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/summary/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/agents/:id/state" + }, + { + "methods": [ + "GET" + ], + "pattern": "/trust/advisories/:slug" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/messages/send" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/drafts/:draft" + } + ], + "method": "DELETE", + "path": "/jobs/operations", + "allowed": false + }, + { + "name": "reserved: the literal route's method does not reach the parameter route", + "routes": [ + { + "methods": [ + "GET", + "DELETE" + ], + "pattern": "/jobs/:id" + }, + { + "methods": [ + "POST" + ], + "pattern": "/jobs/cancel" + }, + { + "methods": [ + "GET" + ], + "pattern": "/jobs/operations" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/summary/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/agents/:id/state" + }, + { + "methods": [ + "GET" + ], + "pattern": "/trust/advisories/:slug" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/messages/send" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/drafts/:draft" + } + ], + "method": "POST", + "path": "/jobs/job-1", + "allowed": false + }, + { + "name": "reserved: the literal route itself still matches", + "routes": [ + { + "methods": [ + "GET", + "DELETE" + ], + "pattern": "/jobs/:id" + }, + { + "methods": [ + "POST" + ], + "pattern": "/jobs/cancel" + }, + { + "methods": [ + "GET" + ], + "pattern": "/jobs/operations" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/summary/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/agents/:id/state" + }, + { + "methods": [ + "GET" + ], + "pattern": "/trust/advisories/:slug" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/messages/send" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/drafts/:draft" + } + ], + "method": "GET", + "path": "/accounts/summary/balance", + "allowed": true + }, + { + "name": "reserved only against routes competing for the same prefix: state", + "routes": [ + { + "methods": [ + "GET", + "DELETE" + ], + "pattern": "/jobs/:id" + }, + { + "methods": [ + "POST" + ], + "pattern": "/jobs/cancel" + }, + { + "methods": [ + "GET" + ], + "pattern": "/jobs/operations" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/summary/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/agents/:id/state" + }, + { + "methods": [ + "GET" + ], + "pattern": "/trust/advisories/:slug" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/messages/send" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/drafts/:draft" + } + ], + "method": "GET", + "path": "/trust/advisories/state", + "allowed": true + }, + { + "name": "reserved only against routes competing for the same prefix: send", + "routes": [ + { + "methods": [ + "GET", + "DELETE" + ], + "pattern": "/jobs/:id" + }, + { + "methods": [ + "POST" + ], + "pattern": "/jobs/cancel" + }, + { + "methods": [ + "GET" + ], + "pattern": "/jobs/operations" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/summary/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/agents/:id/state" + }, + { + "methods": [ + "GET" + ], + "pattern": "/trust/advisories/:slug" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/messages/send" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/drafts/:draft" + } + ], + "method": "GET", + "path": "/inboxes/in-1/drafts/send", + "allowed": true + }, + { + "name": "reserved: still reserved where a competing route exists", + "routes": [ + { + "methods": [ + "GET", + "DELETE" + ], + "pattern": "/jobs/:id" + }, + { + "methods": [ + "POST" + ], + "pattern": "/jobs/cancel" + }, + { + "methods": [ + "GET" + ], + "pattern": "/jobs/operations" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/summary/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/accounts/:id/balance" + }, + { + "methods": [ + "GET" + ], + "pattern": "/agents/:id/state" + }, + { + "methods": [ + "GET" + ], + "pattern": "/trust/advisories/:slug" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/messages/send" + }, + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:inbox/drafts/:draft" + } + ], + "method": "DELETE", + "path": "/jobs/cancel", + "allowed": false + }, + { + "name": "path_param widens the identifier", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/emails/:id" + } + ], + "path_param": "\\A[a-z0-9]+@[a-z.]+\\z", + "method": "GET", + "path": "/inboxes/inbox@example.com", + "allowed": true + }, + { + "name": "path_param replaces the default identifier", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/emails/:id" + } + ], + "path_param": "\\A[a-z0-9]+@[a-z.]+\\z", + "method": "GET", + "path": "/inboxes/in-1", + "allowed": false + }, + { + "name": "path_param cannot admit a dot segment", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/emails/:id" + } + ], + "path_param": "\\A[a-z0-9]+@[a-z.]+\\z", + "method": "GET", + "path": "/inboxes/..", + "allowed": false + }, + { + "name": "path_param cannot admit an encoded separator", + "routes": [ + { + "methods": [ + "GET" + ], + "pattern": "/inboxes/:id" + }, + { + "methods": [ + "GET" + ], + "pattern": "/emails/:id" + } + ], + "path_param": "\\A[a-z0-9]+@[a-z.]+\\z", + "method": "GET", + "path": "/inboxes/a%2Fb@x.y", + "allowed": false + }, + { + "name": "prefix pattern matches the separator alone", + "routes": [ + "/v1/*", + { + "methods": [ + "POST" + ], + "pattern": "/legacy.*" + } + ], + "method": "GET", + "path": "/v1/", + "allowed": true + }, + { + "name": "prefix pattern matches a longer path", + "routes": [ + "/v1/*", + { + "methods": [ + "POST" + ], + "pattern": "/legacy.*" + } + ], + "method": "DELETE", + "path": "/v1/models/x", + "allowed": true + }, + { + "name": "prefix pattern needs the text before *", + "routes": [ + "/v1/*", + { + "methods": [ + "POST" + ], + "pattern": "/legacy.*" + } + ], + "method": "GET", + "path": "/v1", + "allowed": false + }, + { + "name": "prefix pattern does not apply safe_path (access-policy semantics)", + "routes": [ + "/v1/*", + { + "methods": [ + "POST" + ], + "pattern": "/legacy.*" + } + ], + "method": "GET", + "path": "/v1/../x", + "allowed": true + }, + { + "name": "legacy .* suffix is a prefix match", + "routes": [ + "/v1/*", + { + "methods": [ + "POST" + ], + "pattern": "/legacy.*" + } + ], + "method": "POST", + "path": "/legacy/anything", + "allowed": true + }, + { + "name": "legacy .* suffix keeps its method list", + "routes": [ + "/v1/*", + { + "methods": [ + "POST" + ], + "pattern": "/legacy.*" + } + ], + "method": "GET", + "path": "/legacy/anything", + "allowed": false + } + ], + "expectations": [ + { + "name": "type checks resolve dotted paths", + "expect": [ + [ + "success", + "eq", + true + ], + [ + "data", + "map" + ], + [ + "data.remainingCredits", + "number" + ], + [ + "data.planCredits", + "integer" + ] + ], + "body": { + "data": { + "remainingCredits": 12.5, + "planCredits": 100 + }, + "success": true + }, + "ok": true + }, + { + "name": "a fraction is not an integer", + "expect": [ + [ + "data.remainingCredits", + "integer" + ] + ], + "body": { + "data": { + "remainingCredits": 12.5, + "planCredits": 100 + }, + "success": true + }, + "ok": false + }, + { + "name": "a missing nested key fails a type check", + "expect": [ + [ + "data.missing", + "number" + ] + ], + "body": { + "data": { + "remainingCredits": 12.5, + "planCredits": 100 + }, + "success": true + }, + "ok": false + }, + { + "name": "eq compares the value", + "expect": [ + [ + "success", + "eq", + false + ] + ], + "body": { + "data": { + "remainingCredits": 12.5, + "planCredits": 100 + }, + "success": true + }, + "ok": false + }, + { + "name": "[] on an empty list passes vacuously", + "expect": [ + [ + "results", + "list" + ], + [ + "results[].url", + "text" + ] + ], + "body": { + "results": [] + }, + "ok": true + }, + { + "name": "[] applies the rest of the path to every element", + "expect": [ + [ + "results[].url", + "text" + ], + [ + "results[].id", + "string" + ] + ], + "body": { + "results": [ + { + "url": "https://a", + "id": "" + } + ] + }, + "ok": true + }, + { + "name": "[] fails when one element fails", + "expect": [ + [ + "results[].url", + "text" + ] + ], + "body": { + "results": [ + { + "url": "" + } + ] + }, + "ok": false + }, + { + "name": "[] fails when one element lacks the key", + "expect": [ + [ + "results[].url", + "text" + ] + ], + "body": { + "results": [ + {} + ] + }, + "ok": false + }, + { + "name": "[] over a non-list is missing", + "expect": [ + [ + "results[].url", + "text" + ] + ], + "body": { + "results": "nope" + }, + "ok": false + }, + { + "name": "[] with no further path checks each element", + "expect": [ + [ + "results[]", + "map" + ] + ], + "body": { + "results": [ + {}, + 1 + ] + }, + "ok": false + }, + { + "name": "[] with no further path passes when every element fits", + "expect": [ + [ + "results[]", + "map" + ] + ], + "body": { + "results": [ + {}, + { + "a": 1 + } + ] + }, + "ok": true + }, + { + "name": "absent passes when the key is missing", + "expect": [ + [ + "error", + "absent" + ] + ], + "body": {}, + "ok": true + }, + { + "name": "absent over an empty list passes vacuously", + "expect": [ + [ + "items[].error", + "absent" + ] + ], + "body": { + "items": [] + }, + "ok": true + }, + { + "name": "absent per element: missing and null both pass", + "expect": [ + [ + "items[].error", + "absent" + ] + ], + "body": { + "items": [ + {}, + { + "error": null + } + ] + }, + "ok": true + }, + { + "name": "absent per element: a value in the first element fails", + "expect": [ + [ + "items[].error", + "absent" + ] + ], + "body": { + "items": [ + { + "error": "denied" + }, + {} + ] + }, + "ok": false + }, + { + "name": "absent per element: a value in the last element fails", + "expect": [ + [ + "items[].error", + "absent" + ] + ], + "body": { + "items": [ + {}, + { + "error": "denied" + } + ] + }, + "ok": false + }, + { + "name": "absent over a non-list passes (the fan-out is missing)", + "expect": [ + [ + "items[].error", + "absent" + ] + ], + "body": { + "items": "not a list" + }, + "ok": true + }, + { + "name": "absent over a missing list passes", + "expect": [ + [ + "items[].error", + "absent" + ] + ], + "body": {}, + "ok": true + }, + { + "name": "absent passes on null", + "expect": [ + [ + "error", + "absent" + ] + ], + "body": { + "error": null + }, + "ok": true + }, + { + "name": "absent fails on a string", + "expect": [ + [ + "error", + "absent" + ] + ], + "body": { + "error": "denied" + }, + "ok": false + }, + { + "name": "absent fails on an empty list", + "expect": [ + [ + "error", + "absent" + ] + ], + "body": { + "error": [] + }, + "ok": false + }, + { + "name": "present accepts zero", + "expect": [ + [ + "id", + "present" + ] + ], + "body": { + "id": 0 + }, + "ok": true + }, + { + "name": "present accepts false", + "expect": [ + [ + "ok", + "present" + ] + ], + "body": { + "ok": false + }, + "ok": true + }, + { + "name": "present rejects null", + "expect": [ + [ + "id", + "present" + ] + ], + "body": { + "id": null + }, + "ok": false + }, + { + "name": "present rejects a missing key", + "expect": [ + [ + "id", + "present" + ] + ], + "body": {}, + "ok": false + }, + { + "name": "string accepts the empty string", + "expect": [ + [ + "a", + "string" + ] + ], + "body": { + "a": "" + }, + "ok": true + }, + { + "name": "text rejects the empty string", + "expect": [ + [ + "a", + "text" + ] + ], + "body": { + "a": "" + }, + "ok": false + }, + { + "name": "text accepts a non-empty string", + "expect": [ + [ + "a", + "text" + ] + ], + "body": { + "a": "x" + }, + "ok": true + }, + { + "name": "string rejects a number", + "expect": [ + [ + "a", + "string" + ] + ], + "body": { + "a": 1 + }, + "ok": false + }, + { + "name": "list accepts an empty list", + "expect": [ + [ + "a", + "list" + ] + ], + "body": { + "a": [] + }, + "ok": true + }, + { + "name": "nonempty_list rejects an empty list", + "expect": [ + [ + "a", + "nonempty_list" + ] + ], + "body": { + "a": [] + }, + "ok": false + }, + { + "name": "nonempty_list accepts one element", + "expect": [ + [ + "a", + "nonempty_list" + ] + ], + "body": { + "a": [ + 1 + ] + }, + "ok": true + }, + { + "name": "boolean accepts false", + "expect": [ + [ + "a", + "boolean" + ] + ], + "body": { + "a": false + }, + "ok": true + }, + { + "name": "boolean rejects a string", + "expect": [ + [ + "a", + "boolean" + ] + ], + "body": { + "a": "false" + }, + "ok": false + }, + { + "name": "map rejects a list", + "expect": [ + [ + "a", + "map" + ] + ], + "body": { + "a": [] + }, + "ok": false + }, + { + "name": "map rejects null", + "expect": [ + [ + "a", + "map" + ] + ], + "body": { + "a": null + }, + "ok": false + }, + { + "name": "list rejects a map", + "expect": [ + [ + "a", + "list" + ] + ], + "body": { + "a": {} + }, + "ok": false + }, + { + "name": "number accepts an integer", + "expect": [ + [ + "a", + "number" + ] + ], + "body": { + "a": 3 + }, + "ok": true + }, + { + "name": "number rejects a numeric string", + "expect": [ + [ + "a", + "number" + ] + ], + "body": { + "a": "3" + }, + "ok": false + }, + { + "name": "eq on a string", + "expect": [ + [ + "object", + "eq", + "list" + ] + ], + "body": { + "object": "list" + }, + "ok": true + }, + { + "name": "eq compares objects structurally", + "expect": [ + [ + "a", + "eq", + { + "x": [ + 1, + 2 + ], + "y": null + } + ] + ], + "body": { + "a": { + "y": null, + "x": [ + 1, + 2 + ] + } + }, + "ok": true + }, + { + "name": "eq compares lists element-wise", + "expect": [ + [ + "a", + "eq", + [ + 1, + 2 + ] + ] + ], + "body": { + "a": [ + 2, + 1 + ] + }, + "ok": false + }, + { + "name": "eq null never matches (null is missing)", + "expect": [ + [ + "a", + "eq", + null + ] + ], + "body": { + "a": null + }, + "ok": false + }, + { + "name": "in accepts a listed value", + "expect": [ + [ + "scope", + "in", + [ + "pod", + "inbox" + ] + ] + ], + "body": { + "scope": "pod" + }, + "ok": true + }, + { + "name": "in rejects an unlisted value", + "expect": [ + [ + "scope", + "in", + [ + "pod", + "inbox" + ] + ] + ], + "body": { + "scope": "org" + }, + "ok": false + }, + { + "name": "min is inclusive", + "expect": [ + [ + "n", + "min", + 0 + ] + ], + "body": { + "n": 0 + }, + "ok": true + }, + { + "name": "min rejects below", + "expect": [ + [ + "n", + "min", + 1 + ] + ], + "body": { + "n": 0 + }, + "ok": false + }, + { + "name": "min rejects a numeric string", + "expect": [ + [ + "n", + "min", + 0 + ] + ], + "body": { + "n": "0" + }, + "ok": false + }, + { + "name": "gt accepts above", + "expect": [ + [ + "n", + "gt", + 0 + ] + ], + "body": { + "n": 0.5 + }, + "ok": true + }, + { + "name": "gt rejects equal", + "expect": [ + [ + "n", + "gt", + 0 + ] + ], + "body": { + "n": 0 + }, + "ok": false + }, + { + "name": "between is inclusive", + "expect": [ + [ + "status", + "between", + 200, + 299 + ] + ], + "body": { + "status": 204 + }, + "ok": true + }, + { + "name": "between accepts the bounds", + "expect": [ + [ + "status", + "between", + 200, + 299 + ], + [ + "lo", + "between", + 200, + 299 + ] + ], + "body": { + "status": 299, + "lo": 200 + }, + "ok": true + }, + { + "name": "between rejects outside", + "expect": [ + [ + "status", + "between", + 200, + 299 + ] + ], + "body": { + "status": 302 + }, + "ok": false + }, + { + "name": "between rejects a non-number", + "expect": [ + [ + "status", + "between", + 200, + 299 + ] + ], + "body": { + "status": "204" + }, + "ok": false + }, + { + "name": "prefix needs text after the prefix", + "expect": [ + [ + "name", + "prefix", + "accounts/" + ] + ], + "body": { + "name": "accounts/x" + }, + "ok": true + }, + { + "name": "prefix rejects the prefix alone", + "expect": [ + [ + "name", + "prefix", + "accounts/" + ] + ], + "body": { + "name": "accounts/" + }, + "ok": false + }, + { + "name": "prefix rejects another string", + "expect": [ + [ + "name", + "prefix", + "accounts/" + ] + ], + "body": { + "name": "x" + }, + "ok": false + }, + { + "name": "prefix rejects a non-string", + "expect": [ + [ + "name", + "prefix", + "accounts/" + ] + ], + "body": { + "name": 1 + }, + "ok": false + }, + { + "name": "contains accepts a substring", + "expect": [ + [ + "detail", + "contains", + "X-Agent-Id" + ] + ], + "body": { + "detail": "need X-Agent-Id" + }, + "ok": true + }, + { + "name": "contains rejects a non-string", + "expect": [ + [ + "detail", + "contains", + "X-Agent-Id" + ] + ], + "body": { + "detail": 1 + }, + "ok": false + }, + { + "name": "contains rejects a string without it", + "expect": [ + [ + "detail", + "contains", + "X-Agent-Id" + ] + ], + "body": { + "detail": "nope" + }, + "ok": false + }, + { + "name": "every expectation must hold", + "expect": [ + [ + "a", + "text" + ], + [ + "b", + "text" + ] + ], + "body": { + "a": "x", + "b": "" + }, + "ok": false + }, + { + "name": "a list body cannot satisfy expectations", + "expect": [ + [ + "a", + "absent" + ] + ], + "body": [], + "ok": false + }, + { + "name": "a string body cannot satisfy expectations", + "expect": [ + [ + "a", + "absent" + ] + ], + "body": "x", + "ok": false + }, + { + "name": "a null body cannot satisfy expectations", + "expect": [ + [ + "a", + "absent" + ] + ], + "body": null, + "ok": false + }, + { + "name": "an empty expectation list holds on an object", + "expect": [], + "body": {}, + "ok": true + }, + { + "name": "an empty expectation list fails on a non-object", + "expect": [], + "body": [], + "ok": false + }, + { + "name": "deep paths through nested lists", + "expect": [ + [ + "results.channels[].alternatives[].transcript", + "string" + ] + ], + "body": { + "results": { + "channels": [ + { + "alternatives": [ + { + "transcript": "" + }, + { + "transcript": "hi" + } + ] + }, + { + "alternatives": [] + } + ] + } + }, + "ok": true + }, + { + "name": "deep paths: a non-object element is missing", + "expect": [ + [ + "results.channels[].alternatives[].transcript", + "string" + ] + ], + "body": { + "results": { + "channels": [ + { + "alternatives": [ + 1 + ] + } + ] + } + }, + "ok": false + } + ], + "malformed_expectations": [ + { + "name": "unknown check", + "expect": [ + [ + "a", + "nonsense" + ] + ] + }, + { + "name": "empty path", + "expect": [ + [ + "", + "list" + ] + ] + }, + { + "name": "empty segment", + "expect": [ + [ + "a..b", + "list" + ] + ] + }, + { + "name": "[] must end the segment", + "expect": [ + [ + "a[]b", + "list" + ] + ] + }, + { + "name": "min needs a number", + "expect": [ + [ + "a", + "min", + "1" + ] + ] + }, + { + "name": "gt needs a number", + "expect": [ + [ + "a", + "gt", + null + ] + ] + }, + { + "name": "between needs lo <= hi", + "expect": [ + [ + "a", + "between", + 2, + 1 + ] + ] + }, + { + "name": "between needs two numbers", + "expect": [ + [ + "a", + "between", + 1 + ] + ] + }, + { + "name": "in needs a list", + "expect": [ + [ + "a", + "in", + "x" + ] + ] + }, + { + "name": "prefix needs a non-empty string", + "expect": [ + [ + "a", + "prefix", + "" + ] + ] + }, + { + "name": "contains needs a string", + "expect": [ + [ + "a", + "contains", + 1 + ] + ] + }, + { + "name": "path must be a string", + "expect": [ + [ + 1, + "list" + ] + ] + }, + { + "name": "check must be a string", + "expect": [ + [ + "a", + 1 + ] + ] + }, + { + "name": "a check is required", + "expect": [ + [ + "a" + ] + ] + }, + { + "name": "eq needs its value", + "expect": [ + [ + "a", + "eq" + ] + ] + }, + { + "name": "type checks take no argument", + "expect": [ + [ + "a", + "text", + "x" + ] + ] + }, + { + "name": "an expectation is an array", + "expect": [ + { + "path": "a", + "check": "text" + } + ] + }, + { + "name": "the list is a list", + "expect": "a" + } + ] +} From f19077e415a95538b9c511908abbfa38bb849cce Mon Sep 17 00:00:00 2001 From: Alex Ezell Date: Tue, 13 Oct 2026 14:44:50 -0500 Subject: [PATCH 2/7] Address review: derive connector count, match Req's JSON headers, catch body timeouts - manifest.test.mjs derives the expected connector count from the directories instead of hard-coding eleven. - buildProbeRequest adds content-type and accept application/json for a JSON body unless the request already carries them, as Req's encode_body step does; fetch would otherwise send text/plain and */*. - runProbe reads the response body inside the transport-error handler, so a server that sends headers and then stalls returns provider_unreachable instead of throwing. Co-Authored-By: Claude Fable 5.1 --- scripts/probe.mjs | 14 ++++++++++++-- scripts/test/manifest.test.mjs | 4 ++-- scripts/test/probe.test.mjs | 21 ++++++++++++++++++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/scripts/probe.mjs b/scripts/probe.mjs index f50c8f3..38a959c 100755 --- a/scripts/probe.mjs +++ b/scripts/probe.mjs @@ -31,7 +31,9 @@ export function authHeader(auth, key) { // Headers the gateway sends with the probe: auth, user-agent, then the // manifest's test_request_headers or their default (content-type json iff -// the probe has a JSON body). +// the probe has a JSON body). For a JSON body Req also sets content-type and +// accept to application/json unless the request already carries them; fetch +// would otherwise send text/plain and */*. export function buildProbeRequest(manifest, key) { const probe = manifest.probe; const testHeaders = @@ -39,6 +41,11 @@ export function buildProbeRequest(manifest, key) { (probe.json !== undefined ? [{ name: "content-type", value: "application/json" }] : []); const headers = [authHeader(manifest.auth, key), ["user-agent", USER_AGENT]]; for (const { name, value } of testHeaders) headers.push([name, value]); + if (probe.json !== undefined) { + for (const name of ["content-type", "accept"]) { + if (!headers.some(([h]) => h.toLowerCase() === name)) headers.push([name, "application/json"]); + } + } return { method: probe.method, url: manifest.base_url + probe.path, @@ -100,6 +107,7 @@ export function redactHeaders(headers, manifest, key) { export async function runProbe(manifest, key, { fetch = globalThis.fetch } = {}) { const request = buildProbeRequest(manifest, key); let response; + let text; try { response = await fetch(request.url, { method: request.method, @@ -108,11 +116,13 @@ export async function runProbe(manifest, key, { fetch = globalThis.fetch } = {}) redirect: "manual", signal: AbortSignal.timeout(request.timeout), }); + // The timeout covers the body too: a server that sends headers and then + // stalls is as unreachable as one that never answers. + text = await response.text(); } catch (error) { const cause = error?.cause?.code ?? error?.name ?? "error"; return { ok: false, status: null, reason: `provider_unreachable (${cause})`, results: [] }; } - const text = await response.text(); const { body, json } = decodeBody(response.headers.get("content-type"), text); const headers = Object.fromEntries(response.headers.entries()); const note = json ? undefined : "response is not JSON; the gateway keeps it as text, so no expectation can hold"; diff --git a/scripts/test/manifest.test.mjs b/scripts/test/manifest.test.mjs index 4f21ed3..994b0ab 100644 --- a/scripts/test/manifest.test.mjs +++ b/scripts/test/manifest.test.mjs @@ -9,14 +9,14 @@ const read = (path) => JSON.parse(readFileSync(new URL(path, root), "utf8")); const names = readdirSync(new URL("connectors/", root)); test("every committed connector and the template pass the manifest checks", () => { - assert.equal(names.length, 11); + assert.ok(names.length >= 11); for (const name of names) assert.deepEqual(manifestProblems(read(`connectors/${name}/connector.json`)), [], name); assert.deepEqual(manifestProblems(read("template/connector/connector.json")), []); }); test("check-connectors.mjs passes on the committed connectors", () => { const out = execFileSync(process.execPath, [new URL("scripts/check-connectors.mjs", root).pathname], { encoding: "utf8" }); - assert.match(out, /11 connector\(s\) ok/); + assert.match(out, new RegExp(`^${names.length} connector\\(s\\) ok$`, "m")); }); test("stripQuery", () => { diff --git a/scripts/test/probe.test.mjs b/scripts/test/probe.test.mjs index 3acdbb9..6bf6e26 100644 --- a/scripts/test/probe.test.mjs +++ b/scripts/test/probe.test.mjs @@ -49,6 +49,11 @@ const routes = { res.end(""); }, "/slow": (res) => setTimeout(() => res.end("{}"), 2_000), + "/stall": (res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.write("{"); + setTimeout(() => res.end("}"), 2_000); + }, }; await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const base_url = `http://127.0.0.1:${server.address().port}`; @@ -67,7 +72,7 @@ test("buildProbeRequest mirrors the gateway's request", () => { const r = buildProbeRequest(manifest({ method: "POST", path: "/search?x=1", json: { q: "x" }, receive_timeout: 5000 }, { auth: "bearer" }), KEY); assert.equal(r.method, "POST"); assert.equal(r.url, `${base_url}/search?x=1`); - assert.deepEqual(r.headers, [["authorization", `Bearer ${KEY}`], ["user-agent", "Sprites-API-Gateway"], ["content-type", "application/json"]]); + assert.deepEqual(r.headers, [["authorization", `Bearer ${KEY}`], ["user-agent", "Sprites-API-Gateway"], ["content-type", "application/json"], ["accept", "application/json"]]); assert.equal(r.body, '{"q":"x"}'); assert.equal(r.timeout, 5000); @@ -75,6 +80,13 @@ test("buildProbeRequest mirrors the gateway's request", () => { assert.deepEqual(g.headers, [["authorization", `Token ${KEY}`], ["user-agent", "Sprites-API-Gateway"], ["accept", "application/json"]]); assert.equal(g.body, undefined); assert.equal(g.timeout, 10_000); + + // Custom test_request_headers on a JSON probe: Req still adds content-type + // and accept application/json unless they are given; an explicit value wins. + const j = buildProbeRequest(manifest({ method: "POST", json: {} }, { test_request_headers: [{ name: "x-extra", value: "1" }] }), KEY); + assert.deepEqual(j.headers.slice(2), [["x-extra", "1"], ["content-type", "application/json"], ["accept", "application/json"]]); + const o = buildProbeRequest(manifest({ method: "POST", json: {} }, { test_request_headers: [{ name: "Accept", value: "application/vnd.api+json" }] }), KEY); + assert.deepEqual(o.headers.slice(2), [["Accept", "application/vnd.api+json"], ["content-type", "application/json"]]); assert.deepEqual(authHeader({ header: "x-key" }, KEY), ["x-key", KEY]); assert.throws(() => authHeader("basic", KEY), TypeError); }); @@ -114,6 +126,7 @@ test("a JSON body is sent with the default content-type", async () => { assert.deepEqual(result.results.map((r) => r.ok), [false]); assert.equal(last.method, "POST"); assert.equal(last.headers["content-type"], "application/json"); + assert.equal(last.headers["accept"], "application/json"); assert.equal(last.body, '{"q":"x"}'); }); @@ -160,6 +173,12 @@ test("the receive_timeout aborts a slow probe as unreachable", async () => { assert.match(result.reason, /provider_unreachable/); }); +test("a server that sends headers and then stalls is unreachable, never thrown", async () => { + const result = await runProbe(manifest({ path: "/stall", receive_timeout: 100 }), KEY); + assert.equal(result.status, null); + assert.match(result.reason, /provider_unreachable \(TimeoutError\)/); +}); + test("connection errors are unreachable, never thrown", async () => { const result = await runProbe(manifest({}, { base_url: "http://127.0.0.1:1" }), KEY); assert.equal(result.status, null); From e1a0b97ae4d2f1dc8595efc8d282b6635fdf22be Mon Sep 17 00:00:00 2001 From: Alex Ezell Date: Tue, 13 Oct 2026 14:51:03 -0500 Subject: [PATCH 3/7] Rewrite the docs as a step-by-step guide to making a connector README.md says what a connector is and gives the three commands. CONTRIBUTING.md walks from `new.mjs` to the pull request, with connector.json explained field by field in the order a contributor fills it, the expect checks as a table, and the checks and probe commands at the step where they are used. Internal material (how sprites-api compiles manifests, the parity vectors, hooks) is dropped or reduced to one line. The template README now says what to put in it. Co-Authored-By: Claude Fable 5.1 --- CONTRIBUTING.md | 413 ++++++++++++++++++++++++++--------- README.md | 54 ++--- template/connector/README.md | 7 +- 3 files changed, 346 insertions(+), 128 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f0a341..b551997 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,121 +1,336 @@ -# Contributing a connector +# Making a connector -A connector is a JSON manifest plus two icons. You do not write code, and you -do not need the sprites-api source. What you need is an account with the API -you are connecting, so you can run its probe with your own key. +A connector is a `connector.json` manifest plus two icons. It tells Sprites +where an API lives, how to send the user's API key, which requests may pass +through, which single request proves a key works, and what users read when +they set it up. You do not write code and you do not need to know how +Sprites works inside. -## What a connector can do +Before you start you need: -- Forward an explicit list of routes (method and path) on one fixed HTTPS - base URL, adding the stored API key as a bearer token or a fixed header. -- Verify a key with one probe request whose success is described by - assertions over the JSON response. -- Classify a failed probe by status and response shape. -- Show a tagline, setup steps, billing and scope notices, guides and curl - examples in the Sprites dashboard. +- Node 22 (`node --version`). Nothing is installed; the scripts use only what + ships with Node. +- An account with the API you are connecting and an API key of your own, so + you can try the connector before you submit it. +- The vendor's API documentation open in a tab. -## What a connector cannot do +The steps below take you from an empty directory to a pull request. The +field-by-field reference for `connector.json` is in step 2. -These are enforced by the format, not just by review. +## Step 1: create the directory -- Run code: no custom validation, request rewriting, response parsing or - request signing. -- OAuth or any browser flow, token refresh, JWT exchange, multi-part - credentials. API keys only. -- A user-chosen hostname or any secret beyond the key. -- Open wildcards on resource roots. Every route is listed by method, and a - `:param` segment matches one identifier. -- Forward arbitrary request headers; forwarding is an allowlist. -- Usage or billing metrics. -- Ship without a sprites-api deploy. Manifests are vendored at a tag and - compiled; a merged connector reaches production with the next release. - -## Steps - -1. Run `node scripts/new.mjs `, which copies `template/connector` to - `connectors//` and sets the names. `` is `[a-z][a-z0-9_]*` - and must equal `"name"` in the manifest. -2. Fill in `connector.json`. Every field is described in `v1/schema.json`; - the eleven committed connectors show each one in use. The probe must be - the cheapest request that proves the key works, and must be safe to run - on every save and connection test. If it costs money or has side effects, - say so in a notice. -3. Add `icon.svg` (the vendor's mark) and `mark.svg` (the same mark drawn - only in `currentColor`, with no other fill or stroke colours). Any `id` - inside either file must start with `-`. Record where the icon came - from and under what licence in the pull request. -4. Run the checks locally (see below) until they are clean. -5. Run the probe against the real API with your own key (see Checks) and - confirm every assertion holds. Do not commit your key or any response - that contains it; `--write-fixture` redacts the recording for you. -6. Open a pull request. Say whether you work for the vendor - (`owner: vendor`) or not (`owner: community`), and link the API docs for - the probe and every forwarded route. - -## What we do with it - -1. CI validates the manifest against the schema and checks the icons. -2. A reviewer reads the routes against the vendor's API docs, looking for - key management, billing, account administration and anything that is not - the product's data plane. Those routes are refused. -3. A reviewer checks the notices disclose billing and scope, and the probe is - the cheapest idempotent choice. -4. We run the probe once with a test account of our own, or ask you for a - short recording when we cannot get one. -5. We merge and tag. The next sprites-api release vendors the tag, and the - connector appears in the dashboard behind the community rollout flag. - -## Checks - -Everything runs on Node 22 with no dependencies to install. +```sh +node scripts/new.mjs acme +``` + +This copies `template/connector` to `connectors/acme/` and sets the names. +The name is the directory, the `"name"` in the manifest and the id users see, +so pick it carefully: lower-case letters, digits and underscores, starting +with a letter (`acme`, `acme_search`). It cannot be changed later without +breaking existing connections. + +You now have: + +``` +connectors/acme/ + connector.json the manifest, with placeholder values to replace + icon.svg placeholder logo to replace + mark.svg placeholder one-colour logo to replace + README.md optional notes for reviewers +``` + +## Step 2: fill in connector.json + +Work through the file top to bottom. Every field is also described in +`v1/schema.json`, and your editor will validate and autocomplete the file if +it honours the `$schema` line at the top. The eleven connectors under +`connectors/` show every field in real use; copy from the one closest to +your API. + +### Identity + +```json +"name": "acme", +"display_name": "Acme", +"description": "One line for the connector picker", +"long_description": "What a Sprite can do through this connector, what the key check does, and what is not forwarded.", +"website_url": "https://acme.example/settings/api-keys", +"base_url": "https://api.acme.example" +``` + +- `description` is one short line shown in lists. `long_description` is a + paragraph: what the connector is for, what happens when a key is saved, + and what is and is not forwarded. +- `website_url` is where a user creates an API key. +- `base_url` is the one HTTPS origin every request goes to. Routes below are + appended to it. A connector has exactly one base URL; it cannot be chosen + by the user. + +### How the key is sent + +```json +"auth": "bearer" +``` + +sends `Authorization: Bearer `. If the API wants its own header: + +```json +"auth": {"header": "x-api-key"} +``` + +and if it wants a word before the key in that header: + +```json +"auth": {"header": "authorization", "prefix": "Token"} +``` + +which sends `authorization: Token `. That is the whole credential: one +key, one header. APIs that need OAuth, a browser login, token refresh, a +signed request or a second secret cannot be connectors. + +### Which requests are allowed + +```json +"allowed_paths": [ + {"methods": ["POST"], "pattern": "/v1/search"}, + {"methods": ["GET"], "pattern": "/v1/items/:id"}, + {"methods": ["GET"], "pattern": "/v1/me"} +] +``` + +Everything not listed is refused, so list each endpoint a Sprite should be +able to call, with its methods in upper case. A pattern is one of: + +- An exact path: `/v1/search` matches only `/v1/search`. Query strings are + not part of the match, so `/v1/search?q=x` is fine. +- A path with parameters: `/v1/items/:id` matches `/v1/items/abc123`. Each + `:name` matches exactly one path segment made of letters, digits, `-` and + `_`. It never matches a segment that another route uses literally in that + position, so with `/jobs/:id` and `/jobs/cancel` both listed, `/jobs/cancel` + only matches the second. +- A prefix: `/v1/files/*` matches `/v1/files/` and anything under it. Use + this sparingly and never on a resource root; a reviewer will ask you to + list the routes instead. + +If the API's ids are not plain identifiers (email addresses, say), add a +`path_param` regular expression that every `:name` segment must match, +written with `\A` and `\z` anchors: + +```json +"path_param": "\\A[A-Za-z0-9._%+@-]+\\z" +``` + +Include the probe's route (next section) in this list. Leave out anything +that manages keys, billing, users or the account itself: a reviewer reads +every route against the vendor's docs and refuses those. + +Optional, for APIs that need them: + +- `forward_response_headers`: response headers passed back to the Sprite, + such as `["x-request-id", "retry-after"]`. By default none are. +- `forward_request_headers`: request headers a Sprite may send through, by + name. By default none are. + +### The probe: one request that proves a key works + +Every time a user saves or tests a key, Sprites sends this one request and +judges the response. It must be the cheapest request that fails with a bad +key and succeeds with a good one, and it must be safe to repeat: no side +effects, or if it costs money, say so in the Billing notice (step 2, last +section). + +```json +"probe": { + "method": "GET", + "path": "/v1/me", + "label": "account", + "expect": [["id", "text"], ["error", "absent"]], + "errors": [{"status": 403, "reason": "invalid_api_key"}] +} +``` + +- `method` is `GET` or `POST`; `path` may carry a query string; `label` is a + short name for what the request is ("account", "balance", "search"). +- `json` is a request body for `POST` probes, sent as JSON: + `"json": {"query": "sprites", "numResults": 1}`. +- `expect` is what a successful response looks like. A 200 only counts when + the body is a JSON object and every assertion holds. Each assertion is + `[path, check]` or `[path, check, argument]`: + + | Check | Holds when the value at `path` is | + | --- | --- | + | `"present"` | there and not null | + | `"absent"` | missing or null | + | `"map"`, `"list"`, `"nonempty_list"` | an object, an array, a non-empty array | + | `"boolean"`, `"integer"`, `"number"` | that JSON type | + | `"string"`, `"text"` | any string, a non-empty string | + | `"eq", value` | equal to `value` (any JSON) | + | `"in", [values]` | one of `values` | + | `"min", n`, `"gt", n` | a number `>= n`, a number `> n` | + | `"between", lo, hi` | a number from `lo` to `hi` inclusive | + | `"prefix", s` | a string starting with `s` and longer than it | + | `"contains", s` | a string containing `s` | + + `path` is dotted: `data.remainingCredits`. Add `[]` to a segment to check + every element of an array there: `results[].url` checks the `url` of each + result, and an empty array passes. Always end with an assertion that the + API's error field is absent, so an error wrapped in a 200 does not pass. + +- `errors` classifies responses other than 200, in order. Each rule names a + status and a reason, optionally with an `expect` list that the error body + must satisfy: + + ```json + "errors": [ + {"status": 403, "reason": "invalid_api_key"}, + {"status": 400, "expect": [["detail", "contains", "X-Agent-Id"]], "reason": "agent_key_required"} + ] + ``` + + A 401 is `invalid_api_key` without a rule. Reasons are `invalid_api_key`, + `agent_key_required`, `admin_key_not_supported`, `unexpected_response`, + `server_error` and `provider_unreachable`. Most connectors need no rules + or one for a 403. + +### What users see in the dashboard + +```json +"presentation": { + "tagline": "Search Acme from a Sprite without putting your key on it.", + "setup": "Create an API key at acme.example/settings/api-keys.", + "notices": [ + {"title": "Billing", "body": "Saving or testing a key reads your account; searches use your Acme credits."}, + {"title": "Scope", "body": "Only `POST /v1/search` and `GET /v1/items/:id` are forwarded."} + ], + "mark_svg": {"file": "mark.svg"} +}, +"icon_svg": {"file": "icon.svg"}, +"icon_bg_class": "bg-blue-500/20" +``` + +- `tagline` is one sentence; `setup` tells the user where to get a key. +- `notices` must include a Billing notice (what the probe and the forwarded + requests cost the user) and a Scope notice (which routes are forwarded). + Text is plain; backticks mark code. +- `guides` and `usage_notes` are optional extra sections; see AgentMail for + guides. +- `icon_bg_class` is a Tailwind background class for the icon tile; pick one + close to the brand colour. + +```json +"examples": [ + {"title": "Search (uses your Acme credits)", "method": "POST", "path": "/v1/search", "json": {"q": "sprites"}}, + {"title": "Read one item; replace ITEM_ID", "path": "/v1/items/ITEM_ID"} +], +"messages": { + "402": "Acme credits exhausted", + "429": "Acme rate limit reached; try again later" +}, +"discovery": {"categories": ["search"], "keywords": ["web search"]} +``` + +- `examples` become curl snippets on the connection page. Each must be an + allowed route; `method` defaults to `GET`. Put placeholder ids in upper + case and say so in the title. +- `messages` are shown when the API answers with that status (or one of the + reasons above). `%{status}` inserts the status code. +- `discovery` places the connector in the catalog. + +### Fields to leave out + +`receive_timeout`, `account_id_hash_length`, `hooks`, `validation`, +`gateway_error_messages` and `playground` are set by the Sprites team when +needed. Leave them out unless a reviewer asks. Some of the committed +connectors carry them; that is not a cue to copy. + +## Step 3: add the icons + +- `icon.svg`: the vendor's logo as they draw it, from their brand or press + page. Note where it came from and its licence; you will put that in the + pull request. +- `mark.svg`: the same logo drawn in a single colour, using `currentColor` + for every fill and stroke (and `none` where there is no ink). It is shown + on dark and light backgrounds. + +Both files must be plain SVG: no scripts, no event handlers, no links to +outside resources. Any `id` attribute inside them must start with +`-` (`acme-mark`) so icons from different connectors never collide. + +## Step 4: run the checks ```sh -node scripts/check-connectors.mjs # all connectors -node scripts/check-connectors.mjs exa # one +node scripts/check-connectors.mjs acme npx --yes ajv-cli@5 validate --spec=draft2020 --strict=false \ - -s v1/schema.json -d "connectors/*/connector.json" -node --test "scripts/test/*.test.mjs" # the scripts and the parity vectors + -s v1/schema.json -d connectors/acme/connector.json +``` + +The first checks everything the schema cannot: the names agree, the icons are +safe and the mark is one colour, the probe and every example are allowed +routes, every `expect` list is well-formed and `path_param` compiles. The +second checks the manifest against the schema. Fix what they report and run +them again until both are clean. Both run in CI on your pull request. + +## Step 5: try the probe with your own key + +```sh +ACME_KEY=... node scripts/probe.mjs acme --key-env ACME_KEY ``` -`check-connectors.mjs` covers what the schema cannot: the directory and -manifest names agree, the icons are safe and the mark is one ink, the probe -and every example are routes `allowed_paths` forwards (the gateway's route -matcher is ported in `scripts/lib/routes.mjs`), every `expect` list in the -probe and its `errors` rules is well-formed, and `path_param` compiles. -CI runs all three on every pull request. +This sends the probe exactly as Sprites will and prints each `expect` +assertion with its result. The key is read from the environment variable +you name and is never printed. If the response is not 200, it prints the +reason the `errors` rules give it. -### Probe +If an assertion fails, look at the real response and adjust `expect` until +every line reads `ok`. Then try a wrong key and confirm the probe fails. ```sh -EXAMPLE_API_KEY=... node scripts/probe.mjs example --key-env EXAMPLE_API_KEY +ACME_KEY=... node scripts/probe.mjs acme --key-env ACME_KEY --write-fixture ``` -sends the manifest's probe exactly as the gateway does (auth header, -`test_request_headers`, no redirects, no retries, `receive_timeout`, default -10 s), prints each `expect` assertion's result and, for a non-200 response, -the reason the `errors` rules and the 401 default give it. The key is read -from the named environment variable and never printed. Add `--write-fixture` -to record the response as `connectors//fixtures/probe_.json` -with every string containing the key and every credential header removed; -check the file before committing it. Fixtures are optional; when present -they show reviewers the response shape the assertions were written against. +also records the response as `connectors/acme/fixtures/probe_200.json` with +your key and any credential headers removed. Committing it is optional but +helps reviewers see what the assertions were written against. Read the file +before committing it. Never commit your key or any response that contains it. -### Parity +## Step 6: open a pull request -`v1/vectors.json` is the contract between sprites-api and these scripts for -route matching and probe expectations: both run every case in it. A change -to those semantics lands in sprites-api first and is mirrored here with its -vector. +Include in the description: -## Fields that are ours to set +- Whether you work for the vendor (`owner: vendor`) or not + (`owner: community`). +- A link to the vendor's API documentation for the probe and for every + allowed route. +- Where the icon came from and under what licence. +- Confirmation that the probe passed with your key (or the fixture). -`probe.receive_timeout` and `account_id_hash_length` are tuned by the Sprites -team. Leave them out unless a reviewer asks; the exported examples that carry -them do so for historical reasons. +Checks run in CI. A reviewer then reads your routes against the vendor's +docs, checks the notices cover billing and scope, and runs the probe once +with a test account of ours (or asks you for a short recording if we cannot +get one). After merge, the connector ships with the next Sprites release. + +## What a connector cannot do -## Hooks +These are limits of the format, so a reviewer cannot waive them: + +- Run code: no custom validation, request rewriting, response parsing or + request signing. +- OAuth or any browser flow, token refresh, JWT exchange or multi-part + credentials. One API key only. +- A user-chosen hostname, or any secret beyond the key. +- Open wildcards on resource roots. Every route is listed by method, and a + `:param` matches one identifier. +- Forward arbitrary request headers. +- Report usage or billing. + +If your API genuinely needs something outside the format, open an issue +describing the request and response shapes before writing the manifest. + +## Checking the scripts themselves + +```sh +node --test "scripts/test/*.test.mjs" +``` -A manifest may name `"hooks"`, a module compiled into sprites-api that -supplies a hand-written probe check or error classification. Community -connectors cannot add hook modules; if a provider genuinely needs one, open an -issue describing the response shape and we will decide whether to add it. +runs the repository's own tests. You only need this if you change something +under `scripts/`. diff --git a/README.md b/README.md index bb6cd20..047f6d8 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,36 @@ # Sprites connectors -Connector manifests for the [Sprites](https://sprites.dev) API gateway. A -connector lets a Sprite call a third-party API through the gateway with a key -the gateway holds, so the key never reaches the Sprite. +A connector lets a [Sprite](https://sprites.dev) call a third-party API with +an API key that Sprites holds for the user, so the key never lands on the +Sprite. A connector is one JSON file plus two icons. There is no code to +write. -Each connector is a directory under `connectors/` holding a `connector.json` -manifest and two SVG icons. Nothing here is code: the manifest declares where -the API lives, how the key is sent, which routes are forwarded, which request -verifies a key and what the dashboard shows. sprites-api compiles the -manifests in a tagged release of this repository into gateway providers. +To add one, follow [CONTRIBUTING.md](CONTRIBUTING.md). The short version: +```sh +node scripts/new.mjs # start from the template +# edit connectors//connector.json, add icon.svg and mark.svg +node scripts/check-connectors.mjs # check it +MY_KEY=... node scripts/probe.mjs --key-env MY_KEY # try it with your key ``` -connectors/exa/ - connector.json the manifest (schema: v1/schema.json) - icon.svg brand mark, as the vendor draws it - mark.svg the same mark in one ink (currentColor) - fixtures/ optional recorded probe responses (probe_.json), - written by scripts/probe.mjs --write-fixture and redacted + +You need Node 22, an account with the API you are connecting, and its API +documentation. Nothing else is installed. + +``` +connectors// + connector.json what the API is, how the key is sent, which routes are + allowed, which request proves a key works, what users see + icon.svg the vendor's logo + mark.svg the same logo in one colour (currentColor) + fixtures/ optional: a redacted recording of the probe response ``` -- [CONTRIBUTING.md](CONTRIBUTING.md): how to write, check and submit one. -- [v1/schema.json](v1/schema.json): the JSON Schema of the manifest format. +- [CONTRIBUTING.md](CONTRIBUTING.md): every step, field by field. +- [template/connector](template/connector): the starting point. +- [connectors/](connectors): eleven live connectors to copy from. Exa and + Firecrawl are the simplest; AgentMail shows routes with parameters; + Deepgram shows a probe with a JSON body. +- [v1/schema.json](v1/schema.json): the formal definition of every field. Editors that honour `$schema` validate and autocomplete `connector.json` - against it; `.vscode/settings.json` maps the local file. -- [template/connector](template/connector): a starting point for a new one; - `node scripts/new.mjs ` copies it into place. -- [v1/vectors.json](v1/vectors.json): the parity contract for route matching - and probe expectations, run by both sprites-api and the scripts here. - -The eleven connectors committed first (AgentMail, Blotato, Deepgram, Era, Exa, -Firecrawl, Fireworks, LemonSlice, Linkup, Mnemom, Parasail) are exports of -connectors that already run in production, so they are worked examples of -every part of the format. + against it. diff --git a/template/connector/README.md b/template/connector/README.md index f18686d..cf27fcb 100644 --- a/template/connector/README.md +++ b/template/connector/README.md @@ -1,5 +1,6 @@ # Example -Replace this with the setup steps a user follows, the billing and scope -disclosures, and links to the vendor's API documentation for the probe and -every forwarded route. This file becomes the connector's documentation page. +Optional notes for reviewers and later contributors: where the icon came +from, which vendor docs describe the probe and each route, and anything +about the API that is not obvious from the manifest. Delete this file if +there is nothing to say. From 2cbaf160a435e0edb249aab2735a9f499a3b1d58 Mon Sep 17 00:00:00 2001 From: Alex Ezell Date: Tue, 13 Oct 2026 14:57:06 -0500 Subject: [PATCH 4/7] Link the README to fly.io/sprites Documentation points at https://fly.io/sprites; sprites.dev is only for things that connect to that domain. The sprites.dev URLs inside the Exa, Firecrawl and Linkup example request bodies are request data, not docs, and stay as exported. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 047f6d8..9fadab9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sprites connectors -A connector lets a [Sprite](https://sprites.dev) call a third-party API with +A connector lets a [Sprite](https://fly.io/sprites) call a third-party API with an API key that Sprites holds for the user, so the key never lands on the Sprite. A connector is one JSON file plus two icons. There is no code to write. From 31cfee511749d4356b5874dcf0c1c2613da8455d Mon Sep 17 00:00:00 2001 From: Alex Ezell Date: Tue, 13 Oct 2026 15:11:00 -0500 Subject: [PATCH 5/7] Move the seed connectors to examples/; connectors/ is community-only sprites-api vendors every directory under connectors/ at a tag and fails the build on a name that collides with a built-in provider, which all eleven seeds do. They now live in examples/ as worked examples and fixtures. connectors/README.md says what belongs there. check-connectors.mjs checks both roots by default and finds a named connector in either; probe.mjs resolves connectors/ then examples/; new.mjs always creates under connectors/. CI's ajv step validates examples/ and the template, and connectors/ only when it has a manifest, since ajv fails on a glob with no matches. The editor mapping, README and CONTRIBUTING describe the two roots. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/validate.yml | 12 ++++++--- .vscode/settings.json | 2 +- CONTRIBUTING.md | 15 +++++++---- README.md | 17 +++++++----- connectors/README.md | 14 ++++++++++ .../agentmail/connector.json | 0 {connectors => examples}/agentmail/icon.svg | 0 {connectors => examples}/agentmail/mark.svg | 0 .../blotato/connector.json | 0 {connectors => examples}/blotato/icon.svg | 0 {connectors => examples}/blotato/mark.svg | 0 .../deepgram/connector.json | 0 {connectors => examples}/deepgram/icon.svg | 0 {connectors => examples}/deepgram/mark.svg | 0 {connectors => examples}/era/connector.json | 0 {connectors => examples}/era/icon.svg | 0 {connectors => examples}/era/mark.svg | 0 {connectors => examples}/exa/connector.json | 0 {connectors => examples}/exa/icon.svg | 0 {connectors => examples}/exa/mark.svg | 0 .../firecrawl/connector.json | 0 {connectors => examples}/firecrawl/icon.svg | 0 {connectors => examples}/firecrawl/mark.svg | 0 .../fireworks/connector.json | 0 {connectors => examples}/fireworks/icon.svg | 0 {connectors => examples}/fireworks/mark.svg | 0 .../lemonslice/connector.json | 0 {connectors => examples}/lemonslice/icon.svg | 0 {connectors => examples}/lemonslice/mark.svg | 0 .../linkup/connector.json | 0 {connectors => examples}/linkup/icon.svg | 0 {connectors => examples}/linkup/mark.svg | 0 .../mnemom/connector.json | 0 {connectors => examples}/mnemom/icon.svg | 0 {connectors => examples}/mnemom/mark.svg | 0 .../parasail/connector.json | 0 {connectors => examples}/parasail/icon.svg | 0 {connectors => examples}/parasail/mark.svg | 0 scripts/check-connectors.mjs | 26 +++++++++++++------ scripts/probe.mjs | 22 +++++++++++----- scripts/test/manifest.test.mjs | 24 +++++++++++------ scripts/test/new.test.mjs | 11 +++++++- scripts/test/probe.test.mjs | 8 +++++- 43 files changed, 110 insertions(+), 41 deletions(-) create mode 100644 connectors/README.md rename {connectors => examples}/agentmail/connector.json (100%) rename {connectors => examples}/agentmail/icon.svg (100%) rename {connectors => examples}/agentmail/mark.svg (100%) rename {connectors => examples}/blotato/connector.json (100%) rename {connectors => examples}/blotato/icon.svg (100%) rename {connectors => examples}/blotato/mark.svg (100%) rename {connectors => examples}/deepgram/connector.json (100%) rename {connectors => examples}/deepgram/icon.svg (100%) rename {connectors => examples}/deepgram/mark.svg (100%) rename {connectors => examples}/era/connector.json (100%) rename {connectors => examples}/era/icon.svg (100%) rename {connectors => examples}/era/mark.svg (100%) rename {connectors => examples}/exa/connector.json (100%) rename {connectors => examples}/exa/icon.svg (100%) rename {connectors => examples}/exa/mark.svg (100%) rename {connectors => examples}/firecrawl/connector.json (100%) rename {connectors => examples}/firecrawl/icon.svg (100%) rename {connectors => examples}/firecrawl/mark.svg (100%) rename {connectors => examples}/fireworks/connector.json (100%) rename {connectors => examples}/fireworks/icon.svg (100%) rename {connectors => examples}/fireworks/mark.svg (100%) rename {connectors => examples}/lemonslice/connector.json (100%) rename {connectors => examples}/lemonslice/icon.svg (100%) rename {connectors => examples}/lemonslice/mark.svg (100%) rename {connectors => examples}/linkup/connector.json (100%) rename {connectors => examples}/linkup/icon.svg (100%) rename {connectors => examples}/linkup/mark.svg (100%) rename {connectors => examples}/mnemom/connector.json (100%) rename {connectors => examples}/mnemom/icon.svg (100%) rename {connectors => examples}/mnemom/mark.svg (100%) rename {connectors => examples}/parasail/connector.json (100%) rename {connectors => examples}/parasail/icon.svg (100%) rename {connectors => examples}/parasail/mark.svg (100%) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 289d2e8..fd1e963 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -14,10 +14,14 @@ jobs: with: node-version: "22" - name: Manifests match the schema - run: >- - npx --yes ajv-cli@5 validate --spec=draft2020 --strict=false - -s v1/schema.json -d "connectors/*/connector.json" - -d template/connector/connector.json + # ajv exits non-zero on a glob with no matches, and connectors/ may + # hold no community connector yet. + run: | + args=(-d "examples/*/connector.json" -d template/connector/connector.json) + if compgen -G "connectors/*/connector.json" > /dev/null; then + args+=(-d "connectors/*/connector.json") + fi + npx --yes ajv-cli@5 validate --spec=draft2020 --strict=false -s v1/schema.json "${args[@]}" - name: Directories, icons, marks, routes and expectations run: node scripts/check-connectors.mjs - name: Scripts and parity vectors diff --git a/.vscode/settings.json b/.vscode/settings.json index 54ed8e0..0eb9c3a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "json.schemas": [ { - "fileMatch": ["connectors/*/connector.json", "template/connector/connector.json"], + "fileMatch": ["connectors/*/connector.json", "examples/*/connector.json", "template/connector/connector.json"], "url": "./v1/schema.json" } ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b551997..80438d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,9 @@ node scripts/new.mjs acme ``` This copies `template/connector` to `connectors/acme/` and sets the names. +`connectors/` holds community connectors only; every directory in it is +vendored into Sprites at a tag of this repository, so what you merge here +ships. The first-party connectors live in `examples/` as worked examples. The name is the directory, the `"name"` in the manifest and the id users see, so pick it carefully: lower-case letters, digits and underscores, starting with a letter (`acme`, `acme_search`). It cannot be changed later without @@ -44,7 +47,7 @@ connectors/acme/ Work through the file top to bottom. Every field is also described in `v1/schema.json`, and your editor will validate and autocomplete the file if it honours the `$schema` line at the top. The eleven connectors under -`connectors/` show every field in real use; copy from the one closest to +`examples/` show every field in real use; copy from the one closest to your API. ### Identity @@ -266,9 +269,10 @@ npx --yes ajv-cli@5 validate --spec=draft2020 --strict=false \ The first checks everything the schema cannot: the names agree, the icons are safe and the mark is one colour, the probe and every example are allowed -routes, every `expect` list is well-formed and `path_param` compiles. The -second checks the manifest against the schema. Fix what they report and run -them again until both are clean. Both run in CI on your pull request. +routes, every `expect` list is well-formed and `path_param` compiles. Run it +with no name to check every connector under `connectors/` and `examples/`. +The second checks the manifest against the schema. Fix what they report and +run them again until both are clean. Both run in CI on your pull request. ## Step 5: try the probe with your own key @@ -277,7 +281,8 @@ ACME_KEY=... node scripts/probe.mjs acme --key-env ACME_KEY ``` This sends the probe exactly as Sprites will and prints each `expect` -assertion with its result. The key is read from the environment variable +assertion with its result. The name is looked up under `connectors/` and +then `examples/`. The key is read from the environment variable you name and is never printed. If the response is not 200, it prints the reason the `errors` rules give it. diff --git a/README.md b/README.md index 9fadab9..3a46cd1 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,20 @@ You need Node 22, an account with the API you are connecting, and its API documentation. Nothing else is installed. ``` -connectors// - connector.json what the API is, how the key is sent, which routes are - allowed, which request proves a key works, what users see - icon.svg the vendor's logo - mark.svg the same logo in one colour (currentColor) - fixtures/ optional: a redacted recording of the probe response +connectors// community connectors; every directory here is vendored + into Sprites at a tag of this repository + connector.json what the API is, how the key is sent, which routes are + allowed, which request proves a key works, what users see + icon.svg the vendor's logo + mark.svg the same logo in one colour (currentColor) + fixtures/ optional: a redacted recording of the probe response +examples// the first-party connectors, same layout, as worked + examples of the format ``` - [CONTRIBUTING.md](CONTRIBUTING.md): every step, field by field. - [template/connector](template/connector): the starting point. -- [connectors/](connectors): eleven live connectors to copy from. Exa and +- [examples/](examples): eleven live connectors to copy from. Exa and Firecrawl are the simplest; AgentMail shows routes with parameters; Deepgram shows a probe with a JSON body. - [v1/schema.json](v1/schema.json): the formal definition of every field. diff --git a/connectors/README.md b/connectors/README.md new file mode 100644 index 0000000..f925c69 --- /dev/null +++ b/connectors/README.md @@ -0,0 +1,14 @@ +# Community connectors + +This directory holds community connectors only. Every directory here is +vendored into sprites-api at a tag of this repository, so a merged connector +ships with the next Sprites release. + +The first-party connectors (AgentMail, Blotato, Deepgram, Era, Exa, +Firecrawl, Fireworks, LemonSlice, Linkup, Mnemom, Parasail) live in +[`../examples`](../examples) as worked examples of the format. One moves here +only deliberately, together with the removal of its Elixir module in +sprites-api, because a directory here whose name collides with a built-in +provider fails the sprites-api build. + +To add a connector, follow [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/connectors/agentmail/connector.json b/examples/agentmail/connector.json similarity index 100% rename from connectors/agentmail/connector.json rename to examples/agentmail/connector.json diff --git a/connectors/agentmail/icon.svg b/examples/agentmail/icon.svg similarity index 100% rename from connectors/agentmail/icon.svg rename to examples/agentmail/icon.svg diff --git a/connectors/agentmail/mark.svg b/examples/agentmail/mark.svg similarity index 100% rename from connectors/agentmail/mark.svg rename to examples/agentmail/mark.svg diff --git a/connectors/blotato/connector.json b/examples/blotato/connector.json similarity index 100% rename from connectors/blotato/connector.json rename to examples/blotato/connector.json diff --git a/connectors/blotato/icon.svg b/examples/blotato/icon.svg similarity index 100% rename from connectors/blotato/icon.svg rename to examples/blotato/icon.svg diff --git a/connectors/blotato/mark.svg b/examples/blotato/mark.svg similarity index 100% rename from connectors/blotato/mark.svg rename to examples/blotato/mark.svg diff --git a/connectors/deepgram/connector.json b/examples/deepgram/connector.json similarity index 100% rename from connectors/deepgram/connector.json rename to examples/deepgram/connector.json diff --git a/connectors/deepgram/icon.svg b/examples/deepgram/icon.svg similarity index 100% rename from connectors/deepgram/icon.svg rename to examples/deepgram/icon.svg diff --git a/connectors/deepgram/mark.svg b/examples/deepgram/mark.svg similarity index 100% rename from connectors/deepgram/mark.svg rename to examples/deepgram/mark.svg diff --git a/connectors/era/connector.json b/examples/era/connector.json similarity index 100% rename from connectors/era/connector.json rename to examples/era/connector.json diff --git a/connectors/era/icon.svg b/examples/era/icon.svg similarity index 100% rename from connectors/era/icon.svg rename to examples/era/icon.svg diff --git a/connectors/era/mark.svg b/examples/era/mark.svg similarity index 100% rename from connectors/era/mark.svg rename to examples/era/mark.svg diff --git a/connectors/exa/connector.json b/examples/exa/connector.json similarity index 100% rename from connectors/exa/connector.json rename to examples/exa/connector.json diff --git a/connectors/exa/icon.svg b/examples/exa/icon.svg similarity index 100% rename from connectors/exa/icon.svg rename to examples/exa/icon.svg diff --git a/connectors/exa/mark.svg b/examples/exa/mark.svg similarity index 100% rename from connectors/exa/mark.svg rename to examples/exa/mark.svg diff --git a/connectors/firecrawl/connector.json b/examples/firecrawl/connector.json similarity index 100% rename from connectors/firecrawl/connector.json rename to examples/firecrawl/connector.json diff --git a/connectors/firecrawl/icon.svg b/examples/firecrawl/icon.svg similarity index 100% rename from connectors/firecrawl/icon.svg rename to examples/firecrawl/icon.svg diff --git a/connectors/firecrawl/mark.svg b/examples/firecrawl/mark.svg similarity index 100% rename from connectors/firecrawl/mark.svg rename to examples/firecrawl/mark.svg diff --git a/connectors/fireworks/connector.json b/examples/fireworks/connector.json similarity index 100% rename from connectors/fireworks/connector.json rename to examples/fireworks/connector.json diff --git a/connectors/fireworks/icon.svg b/examples/fireworks/icon.svg similarity index 100% rename from connectors/fireworks/icon.svg rename to examples/fireworks/icon.svg diff --git a/connectors/fireworks/mark.svg b/examples/fireworks/mark.svg similarity index 100% rename from connectors/fireworks/mark.svg rename to examples/fireworks/mark.svg diff --git a/connectors/lemonslice/connector.json b/examples/lemonslice/connector.json similarity index 100% rename from connectors/lemonslice/connector.json rename to examples/lemonslice/connector.json diff --git a/connectors/lemonslice/icon.svg b/examples/lemonslice/icon.svg similarity index 100% rename from connectors/lemonslice/icon.svg rename to examples/lemonslice/icon.svg diff --git a/connectors/lemonslice/mark.svg b/examples/lemonslice/mark.svg similarity index 100% rename from connectors/lemonslice/mark.svg rename to examples/lemonslice/mark.svg diff --git a/connectors/linkup/connector.json b/examples/linkup/connector.json similarity index 100% rename from connectors/linkup/connector.json rename to examples/linkup/connector.json diff --git a/connectors/linkup/icon.svg b/examples/linkup/icon.svg similarity index 100% rename from connectors/linkup/icon.svg rename to examples/linkup/icon.svg diff --git a/connectors/linkup/mark.svg b/examples/linkup/mark.svg similarity index 100% rename from connectors/linkup/mark.svg rename to examples/linkup/mark.svg diff --git a/connectors/mnemom/connector.json b/examples/mnemom/connector.json similarity index 100% rename from connectors/mnemom/connector.json rename to examples/mnemom/connector.json diff --git a/connectors/mnemom/icon.svg b/examples/mnemom/icon.svg similarity index 100% rename from connectors/mnemom/icon.svg rename to examples/mnemom/icon.svg diff --git a/connectors/mnemom/mark.svg b/examples/mnemom/mark.svg similarity index 100% rename from connectors/mnemom/mark.svg rename to examples/mnemom/mark.svg diff --git a/connectors/parasail/connector.json b/examples/parasail/connector.json similarity index 100% rename from connectors/parasail/connector.json rename to examples/parasail/connector.json diff --git a/connectors/parasail/icon.svg b/examples/parasail/icon.svg similarity index 100% rename from connectors/parasail/icon.svg rename to examples/parasail/icon.svg diff --git a/connectors/parasail/mark.svg b/examples/parasail/mark.svg similarity index 100% rename from connectors/parasail/mark.svg rename to examples/parasail/mark.svg diff --git a/scripts/check-connectors.mjs b/scripts/check-connectors.mjs index 2978796..951321a 100644 --- a/scripts/check-connectors.mjs +++ b/scripts/check-connectors.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node -// Checks every connector directory (or the ones named on the command line) -// for the things the JSON Schema cannot express: directory and manifest +// Checks every connector directory under connectors/ and examples/ (or the +// ones named on the command line, found in either root) for the things the +// JSON Schema cannot express: directory and manifest // names agree, referenced SVG files exist and are safe to inline, the mark // is one ink, no stray files are committed, the probe and every example are // routes the manifest forwards, expectations are well-formed and path_param @@ -11,11 +12,21 @@ import { join, basename } from "node:path"; import { manifestProblems } from "./lib/manifest.mjs"; const root = new URL("..", import.meta.url).pathname; -const connectorsDir = join(root, "connectors"); +// connectors/ holds community connectors, vendored into sprites-api by tag; +// examples/ holds the first-party ones as worked examples. Both are checked +// so the examples stay valid fixtures. +export const ROOTS = ["connectors", "examples"]; const wanted = process.argv.slice(2); -const names = wanted.length - ? wanted - : readdirSync(connectorsDir).filter((n) => statSync(join(connectorsDir, n)).isDirectory()); +const entries = wanted.length + ? wanted.map((name) => [name, ROOTS.map((r) => join(root, r, name)).find((d) => existsSync(d)) ?? join(root, "connectors", name)]) + : ROOTS.flatMap((r) => { + const dir = join(root, r); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((n) => statSync(join(dir, n)).isDirectory()) + .map((n) => [n, join(dir, n)]); + }); +const names = entries.map(([name]) => name); const allowedFiles = new Set(["connector.json", "icon.svg", "mark.svg", "README.md"]); let failures = 0; @@ -43,8 +54,7 @@ function svgChecks(name, file, svg, { singleInk }) { } } -for (const name of names) { - const dir = join(connectorsDir, name); +for (const [name, dir] of entries) { if (!existsSync(dir)) { fail(name, "no such connector directory"); continue; diff --git a/scripts/probe.mjs b/scripts/probe.mjs index 38a959c..49b86cc 100755 --- a/scripts/probe.mjs +++ b/scripts/probe.mjs @@ -8,11 +8,11 @@ // // node scripts/probe.mjs --key-env VAR [--write-fixture] // -// --write-fixture records the response as connectors//fixtures/ -// probe_.json after redaction: every string equal to or containing +// is looked up under connectors/ and then examples/. --write-fixture +// records the response as /fixtures/probe_.json after redaction: every string equal to or containing // the key is dropped, as is any header named authorization or the manifest's // auth header. Exits non-zero when the probe fails. -import { readFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { evaluate, ok as expectOk } from "./lib/expect.mjs"; @@ -150,6 +150,15 @@ export function writeFixture(name, fixture, { dir = join(root, "connectors", nam return file; } +// connectors/ first (community connectors), then examples/. +export function connectorDir(name) { + for (const base of ["connectors", "examples"]) { + const dir = join(root, base, name); + if (existsSync(dir)) return dir; + } + return join(root, "connectors", name); +} + function usage(message) { console.error(message); console.error("usage: node scripts/probe.mjs --key-env VAR [--write-fixture]"); @@ -173,11 +182,12 @@ export async function main(argv) { const key = (process.env[keyEnv] ?? "").trim(); if (!key) usage(`${keyEnv} is not set`); + const dir = connectorDir(name); let manifest; try { - manifest = JSON.parse(readFileSync(join(root, "connectors", name, "connector.json"), "utf8")); + manifest = JSON.parse(readFileSync(join(dir, "connector.json"), "utf8")); } catch (error) { - usage(`cannot read connectors/${name}/connector.json: ${error.message}`); + usage(`cannot read ${dir.replace(root, "")}/connector.json: ${error.message}`); } const request = buildProbeRequest(manifest, key); console.log(`${request.method} ${request.url} (timeout ${request.timeout} ms)`); @@ -189,7 +199,7 @@ export async function main(argv) { console.log(`HTTP ${result.status}`); if (result.note) console.log(`note: ${result.note}`); for (const { expectation, ok } of result.results) console.log(` ${ok ? "ok " : "FAIL"} ${JSON.stringify(expectation)}`); - if (write) console.log(`wrote ${writeFixture(name, result.fixture).replace(root, "")}`); + if (write) console.log(`wrote ${writeFixture(name, result.fixture, { dir: join(dir, "fixtures") }).replace(root, "")}`); if (result.ok) { console.log("probe ok"); } else { diff --git a/scripts/test/manifest.test.mjs b/scripts/test/manifest.test.mjs index 994b0ab..854b2d7 100644 --- a/scripts/test/manifest.test.mjs +++ b/scripts/test/manifest.test.mjs @@ -1,22 +1,30 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { readdirSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { manifestProblems, stripQuery } from "../lib/manifest.mjs"; const root = new URL("../../", import.meta.url); const read = (path) => JSON.parse(readFileSync(new URL(path, root), "utf8")); -const names = readdirSync(new URL("connectors/", root)); +const dirs = (base) => { + const url = new URL(`${base}/`, root); + return existsSync(url) ? readdirSync(url).filter((n) => statSync(new URL(n, url)).isDirectory()).map((n) => `${base}/${n}`) : []; +}; +const examples = dirs("examples"); +const all = [...dirs("connectors"), ...examples]; -test("every committed connector and the template pass the manifest checks", () => { - assert.ok(names.length >= 11); - for (const name of names) assert.deepEqual(manifestProblems(read(`connectors/${name}/connector.json`)), [], name); +test("every example, community connector and the template pass the manifest checks", () => { + assert.ok(examples.length >= 11); + for (const dir of all) assert.deepEqual(manifestProblems(read(`${dir}/connector.json`)), [], dir); assert.deepEqual(manifestProblems(read("template/connector/connector.json")), []); }); -test("check-connectors.mjs passes on the committed connectors", () => { - const out = execFileSync(process.execPath, [new URL("scripts/check-connectors.mjs", root).pathname], { encoding: "utf8" }); - assert.match(out, new RegExp(`^${names.length} connector\\(s\\) ok$`, "m")); +test("check-connectors.mjs passes on both roots and finds one connector in either", () => { + const script = new URL("scripts/check-connectors.mjs", root).pathname; + const out = execFileSync(process.execPath, [script], { encoding: "utf8" }); + assert.match(out, new RegExp(`^${all.length} connector\\(s\\) ok$`, "m")); + assert.match(execFileSync(process.execPath, [script, "exa"], { encoding: "utf8" }), /^1 connector\(s\) ok$/m); + assert.throws(() => execFileSync(process.execPath, [script, "no_such"], { stdio: "pipe" }), /no such connector directory/); }); test("stripQuery", () => { diff --git a/scripts/test/new.test.mjs b/scripts/test/new.test.mjs index 9cc99ed..3fb7063 100644 --- a/scripts/test/new.test.mjs +++ b/scripts/test/new.test.mjs @@ -54,5 +54,14 @@ test("displayName", () => { test("the CLI exits non-zero on a bad name without touching the repo", () => { assert.throws(() => execFileSync(process.execPath, [join(repo, "scripts/new.mjs"), "Bad"], { stdio: "pipe" })); assert.throws(() => execFileSync(process.execPath, [join(repo, "scripts/new.mjs")], { stdio: "pipe" })); - assert.throws(() => execFileSync(process.execPath, [join(repo, "scripts/new.mjs"), "exa"], { stdio: "pipe" }), /already exists/); +}); + +test("createConnector always creates under connectors/, even when examples/ has the name", () => { + const root = sandbox(); + try { + mkdirSync(join(root, "examples", "exa"), { recursive: true }); + assert.equal(createConnector("exa", { root }), join(root, "connectors", "exa")); + } finally { + rmSync(root, { recursive: true }); + } }); diff --git a/scripts/test/probe.test.mjs b/scripts/test/probe.test.mjs index 6bf6e26..9d42210 100644 --- a/scripts/test/probe.test.mjs +++ b/scripts/test/probe.test.mjs @@ -6,7 +6,7 @@ import { createServer } from "node:http"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { buildProbeRequest, runProbe, classify, redact, redactHeaders, writeFixture, authHeader } from "../probe.mjs"; +import { buildProbeRequest, runProbe, classify, redact, redactHeaders, writeFixture, authHeader, connectorDir } from "../probe.mjs"; const KEY = "sk-live-SECRET123"; let last; @@ -185,6 +185,12 @@ test("connection errors are unreachable, never thrown", async () => { assert.match(result.reason, /provider_unreachable/); }); +test("connectorDir looks in connectors/ then examples/", () => { + const repo = new URL("../../", import.meta.url).pathname; + assert.equal(connectorDir("exa"), join(repo, "examples", "exa")); + assert.equal(connectorDir("no_such"), join(repo, "connectors", "no_such")); +}); + test("redaction drops strings holding the key and the auth headers", () => { assert.deepEqual(redact({ a: KEY, b: [`x${KEY}`, "ok", 1], c: { d: KEY, e: null } }, KEY), { b: ["ok", 1], c: { e: null } }); assert.equal(redact(KEY, KEY), undefined); From 25aa1152be115f252695f1556379e7a9b62482e5 Mon Sep 17 00:00:00 2001 From: Alex Ezell Date: Tue, 13 Oct 2026 15:14:55 -0500 Subject: [PATCH 6/7] Write every contributor-facing text in contributor terms connectors/README.md is three sentences: community connectors, one per subdirectory, shipped with a tagged release, examples under examples/. README, CONTRIBUTING, the check script's comment and the probe runner's message no longer mention how connectors are vendored or built, or that the examples came from anywhere; the examples are complete, working connectors to copy from. The limitations list says a merged connector reaches Sprites with the next release, and that a connector is data only: a provider whose responses the assertions cannot describe gets an issue. Co-Authored-By: Claude Fable 5.1 --- CONTRIBUTING.md | 12 +++++++----- README.md | 10 +++++----- connectors/README.md | 17 +++++------------ scripts/check-connectors.mjs | 5 ++--- scripts/probe.mjs | 2 +- 5 files changed, 20 insertions(+), 26 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80438d9..4405956 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,9 +24,9 @@ node scripts/new.mjs acme ``` This copies `template/connector` to `connectors/acme/` and sets the names. -`connectors/` holds community connectors only; every directory in it is -vendored into Sprites at a tag of this repository, so what you merge here -ships. The first-party connectors live in `examples/` as worked examples. +`connectors/` holds community connectors; every connector in it ships to +Sprites users with a tagged release of this repository. Complete, working +connectors to copy from live in `examples/`. The name is the directory, the `"name"` in the manifest and the id users see, so pick it carefully: lower-case letters, digits and underscores, starting with a letter (`acme`, `acme_search`). It cannot be changed later without @@ -327,9 +327,11 @@ These are limits of the format, so a reviewer cannot waive them: `:param` matches one identifier. - Forward arbitrary request headers. - Report usage or billing. +- Reach Sprites immediately. A merged connector ships with the next release. -If your API genuinely needs something outside the format, open an issue -describing the request and response shapes before writing the manifest. +A connector is data only. If a provider's responses cannot be described with +the manifest's assertions, open an issue describing the response shape +before writing the manifest. ## Checking the scripts themselves diff --git a/README.md b/README.md index 3a46cd1..3ca1acb 100644 --- a/README.md +++ b/README.md @@ -18,20 +18,20 @@ You need Node 22, an account with the API you are connecting, and its API documentation. Nothing else is installed. ``` -connectors// community connectors; every directory here is vendored - into Sprites at a tag of this repository +connectors// community connectors; each ships to Sprites users with + a tagged release of this repository connector.json what the API is, how the key is sent, which routes are allowed, which request proves a key works, what users see icon.svg the vendor's logo mark.svg the same logo in one colour (currentColor) fixtures/ optional: a redacted recording of the probe response -examples// the first-party connectors, same layout, as worked - examples of the format +examples// complete, working connectors in the same layout, to + copy from ``` - [CONTRIBUTING.md](CONTRIBUTING.md): every step, field by field. - [template/connector](template/connector): the starting point. -- [examples/](examples): eleven live connectors to copy from. Exa and +- [examples/](examples): eleven complete connectors to copy from. Exa and Firecrawl are the simplest; AgentMail shows routes with parameters; Deepgram shows a probe with a JSON body. - [v1/schema.json](v1/schema.json): the formal definition of every field. diff --git a/connectors/README.md b/connectors/README.md index f925c69..57afaaa 100644 --- a/connectors/README.md +++ b/connectors/README.md @@ -1,14 +1,7 @@ # Community connectors -This directory holds community connectors only. Every directory here is -vendored into sprites-api at a tag of this repository, so a merged connector -ships with the next Sprites release. - -The first-party connectors (AgentMail, Blotato, Deepgram, Era, Exa, -Firecrawl, Fireworks, LemonSlice, Linkup, Mnemom, Parasail) live in -[`../examples`](../examples) as worked examples of the format. One moves here -only deliberately, together with the removal of its Elixir module in -sprites-api, because a directory here whose name collides with a built-in -provider fails the sprites-api build. - -To add a connector, follow [CONTRIBUTING.md](../CONTRIBUTING.md). +This directory holds community connectors. Each subdirectory is one +connector: a `connector.json` manifest plus its icons. Every connector here +ships to Sprites users with a tagged release of this repository. Worked +examples to copy from are under [`../examples`](../examples); how to make +one is in [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/scripts/check-connectors.mjs b/scripts/check-connectors.mjs index 951321a..fce5fdf 100644 --- a/scripts/check-connectors.mjs +++ b/scripts/check-connectors.mjs @@ -12,9 +12,8 @@ import { join, basename } from "node:path"; import { manifestProblems } from "./lib/manifest.mjs"; const root = new URL("..", import.meta.url).pathname; -// connectors/ holds community connectors, vendored into sprites-api by tag; -// examples/ holds the first-party ones as worked examples. Both are checked -// so the examples stay valid fixtures. +// connectors/ holds community connectors; examples/ holds complete +// connectors to copy from. Both are checked so the examples stay valid. export const ROOTS = ["connectors", "examples"]; const wanted = process.argv.slice(2); const entries = wanted.length diff --git a/scripts/probe.mjs b/scripts/probe.mjs index 49b86cc..fcc0a63 100755 --- a/scripts/probe.mjs +++ b/scripts/probe.mjs @@ -136,7 +136,7 @@ export async function runProbe(manifest, key, { fetch = globalThis.fetch } = {}) return { ok: false, status: response.status, reason: classify(manifest, response.status, body), results: [], fixture, note }; } if (!Array.isArray(manifest.probe.expect)) { - return { ok: false, status: 200, reason: "probe has no expect list; a manifest without one relies on hooks", results: [], fixture, note }; + return { ok: false, status: 200, reason: "probe has no expect list, so the response cannot be judged", results: [], fixture, note }; } const results = evaluate(manifest.probe.expect, body); const ok = results.every((r) => r.ok); From a184f41d1e722665a6495d32aeb8596636d6e954 Mon Sep 17 00:00:00 2001 From: Alex Ezell Date: Tue, 13 Oct 2026 15:21:11 -0500 Subject: [PATCH 7/7] Keep probe recordings out of connector directories: fixtures// at the root A release imports only connector.json, icon.svg, mark.svg and README.md from a connector directory, so a nested fixtures/ directory would break it. probe.mjs --write-fixture now writes fixtures//probe_.json at the repository root. check-connectors.mjs refuses any other file or directory inside a connector, pointing at fixtures//, and validates fixtures/: directories named after existing connectors or examples holding only probe_.json files that parse as JSON. The checks moved into scripts/lib/check.mjs so the tests run them on sandbox layouts. README and CONTRIBUTING show fixtures/ at the root and say recordings are optional and not part of a connector. Co-Authored-By: Claude Fable 5.1 --- CONTRIBUTING.md | 16 +++- README.md | 4 +- scripts/check-connectors.mjs | 116 +++------------------------- scripts/lib/check.mjs | 142 +++++++++++++++++++++++++++++++++++ scripts/probe.mjs | 13 +++- scripts/test/check.test.mjs | 83 ++++++++++++++++++++ scripts/test/probe.test.mjs | 7 +- 7 files changed, 268 insertions(+), 113 deletions(-) create mode 100644 scripts/lib/check.mjs create mode 100644 scripts/test/check.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4405956..a72186f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,10 @@ connectors/acme/ README.md optional notes for reviewers ``` +These four files are all a connector directory may hold; the checks refuse +anything else in it. A probe recording (step 5) goes under `fixtures/` at +the repository root instead. + ## Step 2: fill in connector.json Work through the file top to bottom. Every field is also described in @@ -293,10 +297,14 @@ every line reads `ok`. Then try a wrong key and confirm the probe fails. ACME_KEY=... node scripts/probe.mjs acme --key-env ACME_KEY --write-fixture ``` -also records the response as `connectors/acme/fixtures/probe_200.json` with -your key and any credential headers removed. Committing it is optional but -helps reviewers see what the assertions were written against. Read the file -before committing it. Never commit your key or any response that contains it. +also records the response as `fixtures/acme/probe_200.json` at the +repository root, with your key and any credential headers removed. A +recording is optional and is not part of the connector: a connector +directory holds only its four files, and the checks refuse anything else in +it. When present, a recording shows reviewers what the assertions were +written against, and the checks confirm it is named `probe_.json` +and parses as JSON. Read the file before committing it. Never commit your +key or any response that contains it. ## Step 6: open a pull request diff --git a/README.md b/README.md index 3ca1acb..530a311 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,11 @@ connectors// community connectors; each ships to Sprites users with allowed, which request proves a key works, what users see icon.svg the vendor's logo mark.svg the same logo in one colour (currentColor) - fixtures/ optional: a redacted recording of the probe response examples// complete, working connectors in the same layout, to copy from +fixtures// optional redacted recordings of a connector's probe + response (probe_.json), for reviewers and the + checks; not part of the connector ``` - [CONTRIBUTING.md](CONTRIBUTING.md): every step, field by field. diff --git a/scripts/check-connectors.mjs b/scripts/check-connectors.mjs index fce5fdf..f3dea78 100644 --- a/scripts/check-connectors.mjs +++ b/scripts/check-connectors.mjs @@ -1,112 +1,20 @@ #!/usr/bin/env node // Checks every connector directory under connectors/ and examples/ (or the // ones named on the command line, found in either root) for the things the -// JSON Schema cannot express: directory and manifest -// names agree, referenced SVG files exist and are safe to inline, the mark -// is one ink, no stray files are committed, the probe and every example are -// routes the manifest forwards, expectations are well-formed and path_param -// compiles (see scripts/lib/manifest.mjs). Schema validation itself is -// ajv-cli (see CONTRIBUTING.md); both run in CI. -import { readdirSync, readFileSync, statSync, existsSync } from "node:fs"; -import { join, basename } from "node:path"; -import { manifestProblems } from "./lib/manifest.mjs"; +// JSON Schema cannot express: directory and manifest names agree, a +// connector holds only its four files, referenced SVG files exist and are +// safe to inline, the mark is one ink, the probe and every example are +// routes the manifest forwards, expectations are well-formed, path_param +// compiles, and fixtures/ holds only well-named JSON recordings for +// existing connectors (see scripts/lib/check.mjs). Schema validation itself +// is ajv-cli (see CONTRIBUTING.md); both run in CI. +import { checkRepository } from "./lib/check.mjs"; const root = new URL("..", import.meta.url).pathname; -// connectors/ holds community connectors; examples/ holds complete -// connectors to copy from. Both are checked so the examples stay valid. -export const ROOTS = ["connectors", "examples"]; -const wanted = process.argv.slice(2); -const entries = wanted.length - ? wanted.map((name) => [name, ROOTS.map((r) => join(root, r, name)).find((d) => existsSync(d)) ?? join(root, "connectors", name)]) - : ROOTS.flatMap((r) => { - const dir = join(root, r); - if (!existsSync(dir)) return []; - return readdirSync(dir) - .filter((n) => statSync(join(dir, n)).isDirectory()) - .map((n) => [n, join(dir, n)]); - }); -const names = entries.map(([name]) => name); - -const allowedFiles = new Set(["connector.json", "icon.svg", "mark.svg", "README.md"]); -let failures = 0; - -function fail(name, message) { - failures += 1; - console.error(`${name}: ${message}`); -} - -function svgChecks(name, file, svg, { singleInk }) { - if (!svg.includes("/g, ""); - for (const [, value] of body.matchAll(/(?:fill|stroke|stop-color|color)="([^"]+)"/g)) { - if (value !== "none" && value !== "currentColor") - fail(name, `${file} mixes inks: ${value} (a mark uses only currentColor and none)`); - } - } -} - -for (const [name, dir] of entries) { - if (!existsSync(dir)) { - fail(name, "no such connector directory"); - continue; - } - for (const file of readdirSync(dir)) { - if (file === "fixtures" && statSync(join(dir, file)).isDirectory()) continue; - if (!allowedFiles.has(file)) fail(name, `unexpected file ${file}`); - } - const manifestPath = join(dir, "connector.json"); - if (!existsSync(manifestPath)) { - fail(name, "connector.json is missing"); - continue; - } - let manifest; - try { - manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - } catch (error) { - fail(name, `connector.json is not valid JSON: ${error.message}`); - continue; - } - if (manifest.name !== name) fail(name, `"name" is ${JSON.stringify(manifest.name)}, directory is ${name}`); - if (manifest.format_version !== 1) fail(name, `format_version must be 1`); - - const refs = [ - ["icon_svg", manifest.icon_svg, false], - ["presentation.mark_svg", manifest.presentation?.mark_svg, true], - ]; - for (const [field, value, singleInk] of refs) { - if (value === undefined) continue; - if (typeof value === "string") { - svgChecks(name, field, value, { singleInk }); - } else if (value && typeof value.file === "string") { - if (basename(value.file) !== value.file || !value.file.endsWith(".svg")) { - fail(name, `${field} must reference a plain .svg file name`); - continue; - } - const svgPath = join(dir, value.file); - if (!existsSync(svgPath)) { - fail(name, `${field} references ${value.file}, which is missing`); - continue; - } - svgChecks(name, value.file, readFileSync(svgPath, "utf8"), { singleInk }); - } else { - fail(name, `${field} must be an inline SVG string or {"file": "x.svg"}`); - } - } - if (manifest.presentation && manifest.presentation.mark_svg === undefined && existsSync(join(dir, "mark.svg"))) - fail(name, "mark.svg exists but presentation.mark_svg does not reference it"); - for (const problem of manifestProblems(manifest)) fail(name, problem); -} - -if (failures) { - console.error(`\n${failures} problem(s) in ${names.length} connector(s)`); +const { names, failures } = checkRepository(root, process.argv.slice(2)); +for (const [name, message] of failures) console.error(`${name}: ${message}`); +if (failures.length) { + console.error(`\n${failures.length} problem(s) in ${names.length} connector(s)`); process.exit(1); } console.log(`${names.length} connector(s) ok`); diff --git a/scripts/lib/check.mjs b/scripts/lib/check.mjs new file mode 100644 index 0000000..b3ecb0e --- /dev/null +++ b/scripts/lib/check.mjs @@ -0,0 +1,142 @@ +// The repository checks behind scripts/check-connectors.mjs, as a function +// over a repository root so the tests can run them on sandbox layouts. +// +// A connector directory (under connectors/ or examples/) holds exactly +// connector.json, icon.svg, mark.svg and optionally README.md; nothing else, +// because a release imports only those files. Probe recordings live at the +// repository root in fixtures//probe_.json. +import { readdirSync, readFileSync, statSync, existsSync } from "node:fs"; +import { join, basename } from "node:path"; +import { manifestProblems } from "./manifest.mjs"; + +// connectors/ holds community connectors; examples/ holds complete +// connectors to copy from. Both are checked so the examples stay valid. +export const ROOTS = ["connectors", "examples"]; +export const CONNECTOR_FILES = new Set(["connector.json", "icon.svg", "mark.svg", "README.md"]); +export const FIXTURE_FILE = /^probe_[1-5][0-9]{2}\.json$/; + +const isDir = (p) => existsSync(p) && statSync(p).isDirectory(); + +// connectors/ first, then examples/; null when neither exists. +export function findConnector(root, name) { + return ROOTS.map((r) => join(root, r, name)).find((d) => existsSync(d)) ?? null; +} + +export function listConnectors(root) { + return ROOTS.flatMap((r) => { + const dir = join(root, r); + if (!isDir(dir)) return []; + return readdirSync(dir) + .filter((n) => isDir(join(dir, n))) + .map((n) => [n, join(dir, n)]); + }); +} + +function svgChecks(fail, name, file, svg, { singleInk }) { + if (!svg.includes("/g, ""); + for (const [, value] of body.matchAll(/(?:fill|stroke|stop-color|color)="([^"]+)"/g)) { + if (value !== "none" && value !== "currentColor") + fail(`${file} mixes inks: ${value} (a mark uses only currentColor and none)`); + } + } +} + +export function checkConnector(name, dir, fail) { + if (!existsSync(dir)) { + fail("no such connector directory"); + return; + } + for (const file of readdirSync(dir)) { + if (!CONNECTOR_FILES.has(file)) + fail(`unexpected ${isDir(join(dir, file)) ? "directory" : "file"} ${file}: a connector holds only connector.json, icon.svg, mark.svg and README.md (probe recordings go in fixtures/${name}/)`); + } + const manifestPath = join(dir, "connector.json"); + if (!existsSync(manifestPath)) { + fail("connector.json is missing"); + return; + } + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (error) { + fail(`connector.json is not valid JSON: ${error.message}`); + return; + } + if (manifest.name !== name) fail(`"name" is ${JSON.stringify(manifest.name)}, directory is ${name}`); + if (manifest.format_version !== 1) fail(`format_version must be 1`); + + const refs = [ + ["icon_svg", manifest.icon_svg, false], + ["presentation.mark_svg", manifest.presentation?.mark_svg, true], + ]; + for (const [field, value, singleInk] of refs) { + if (value === undefined) continue; + if (typeof value === "string") { + svgChecks(fail, name, field, value, { singleInk }); + } else if (value && typeof value.file === "string") { + if (basename(value.file) !== value.file || !value.file.endsWith(".svg")) { + fail(`${field} must reference a plain .svg file name`); + continue; + } + const svgPath = join(dir, value.file); + if (!existsSync(svgPath)) { + fail(`${field} references ${value.file}, which is missing`); + continue; + } + svgChecks(fail, name, value.file, readFileSync(svgPath, "utf8"), { singleInk }); + } else { + fail(`${field} must be an inline SVG string or {"file": "x.svg"}`); + } + } + if (manifest.presentation && manifest.presentation.mark_svg === undefined && existsSync(join(dir, "mark.svg"))) + fail("mark.svg exists but presentation.mark_svg does not reference it"); + for (const problem of manifestProblems(manifest)) fail(problem); +} + +// fixtures// must name an existing connector or example and hold only +// probe_.json files that parse as JSON. +export function checkFixtures(root, fail) { + const dir = join(root, "fixtures"); + if (!isDir(dir)) return; + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (!isDir(path)) { + fail(`fixtures/${entry} is not a directory: recordings go in fixtures//probe_.json`); + continue; + } + if (!findConnector(root, entry)) fail(`fixtures/${entry}/ does not name a connector under connectors/ or examples/`); + for (const file of readdirSync(path)) { + if (!FIXTURE_FILE.test(file)) { + fail(`fixtures/${entry}/${file} must be named probe_.json`); + continue; + } + try { + JSON.parse(readFileSync(join(path, file), "utf8")); + } catch (error) { + fail(`fixtures/${entry}/${file} is not valid JSON: ${error.message}`); + } + } + } +} + +// Checks the named connectors (found in either root) or, with no names, +// every connector in both roots plus the fixtures tree. Returns +// {names, failures: [[name, message]]}. +export function checkRepository(root, wanted = []) { + const failures = []; + const entries = wanted.length + ? wanted.map((name) => [name, findConnector(root, name) ?? join(root, "connectors", name)]) + : listConnectors(root); + for (const [name, dir] of entries) checkConnector(name, dir, (message) => failures.push([name, message])); + if (!wanted.length) checkFixtures(root, (message) => failures.push(["fixtures", message])); + return { names: entries.map(([name]) => name), failures }; +} diff --git a/scripts/probe.mjs b/scripts/probe.mjs index fcc0a63..8980962 100755 --- a/scripts/probe.mjs +++ b/scripts/probe.mjs @@ -9,7 +9,8 @@ // node scripts/probe.mjs --key-env VAR [--write-fixture] // // is looked up under connectors/ and then examples/. --write-fixture -// records the response as /fixtures/probe_.json after redaction: every string equal to or containing +// records the response as fixtures//probe_.json at the +// repository root (never inside a connector directory) after redaction: every string equal to or containing // the key is dropped, as is any header named authorization or the manifest's // auth header. Exits non-zero when the probe fails. import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; @@ -143,7 +144,13 @@ export async function runProbe(manifest, key, { fetch = globalThis.fetch } = {}) return { ok, status: 200, reason: ok ? undefined : "unexpected_response", results, fixture, note }; } -export function writeFixture(name, fixture, { dir = join(root, "connectors", name, "fixtures") } = {}) { +// Recordings live at the repository root, outside the connector directory, +// which holds only the manifest and icons. +export function fixtureDir(name) { + return join(root, "fixtures", name); +} + +export function writeFixture(name, fixture, { dir = fixtureDir(name) } = {}) { mkdirSync(dir, { recursive: true }); const file = join(dir, `probe_${fixture.status}.json`); writeFileSync(file, JSON.stringify(fixture, null, 2) + "\n"); @@ -199,7 +206,7 @@ export async function main(argv) { console.log(`HTTP ${result.status}`); if (result.note) console.log(`note: ${result.note}`); for (const { expectation, ok } of result.results) console.log(` ${ok ? "ok " : "FAIL"} ${JSON.stringify(expectation)}`); - if (write) console.log(`wrote ${writeFixture(name, result.fixture, { dir: join(dir, "fixtures") }).replace(root, "")}`); + if (write) console.log(`wrote ${writeFixture(name, result.fixture).replace(root, "")}`); if (result.ok) { console.log("probe ok"); } else { diff --git a/scripts/test/check.test.mjs b/scripts/test/check.test.mjs new file mode 100644 index 0000000..c5066dd --- /dev/null +++ b/scripts/test/check.test.mjs @@ -0,0 +1,83 @@ +// The directory rules of check-connectors.mjs, run on sandbox repositories. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, cpSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { checkRepository, findConnector } from "../lib/check.mjs"; + +const repo = new URL("../../", import.meta.url).pathname; + +// A root with examples/exa and an empty connectors/. +function sandbox() { + const root = mkdtempSync(join(tmpdir(), "check-")); + mkdirSync(join(root, "connectors")); + cpSync(join(repo, "examples", "exa"), join(root, "examples", "exa"), { recursive: true }); + return root; +} +const messages = (root, wanted) => checkRepository(root, wanted).failures.map(([name, m]) => `${name}: ${m}`); + +test("the real repository passes", () => { + const { names, failures } = checkRepository(repo); + assert.deepEqual(failures, []); + assert.ok(names.includes("exa")); +}); + +test("a connector directory may hold only its four files", () => { + const root = sandbox(); + try { + assert.deepEqual(messages(root), []); + writeFileSync(join(root, "examples", "exa", "README.md"), "# Exa\n"); + assert.deepEqual(messages(root), []); + mkdirSync(join(root, "examples", "exa", "fixtures")); + writeFileSync(join(root, "examples", "exa", "fixtures", "probe_200.json"), "{}"); + writeFileSync(join(root, "examples", "exa", "notes.txt"), "x"); + const got = messages(root); + assert.equal(got.length, 2); + assert.match(got[0], /^exa: unexpected directory fixtures: .*fixtures\/exa\//); + assert.match(got[1], /^exa: unexpected file notes\.txt: .*fixtures\/exa\//); + } finally { + rmSync(root, { recursive: true }); + } +}); + +test("fixtures/ holds directories named after connectors with probe_.json files", () => { + const root = sandbox(); + try { + mkdirSync(join(root, "fixtures", "exa"), { recursive: true }); + writeFileSync(join(root, "fixtures", "exa", "probe_200.json"), '{"status": 200}'); + writeFileSync(join(root, "fixtures", "exa", "probe_401.json"), "{}"); + assert.deepEqual(messages(root), []); + + writeFileSync(join(root, "fixtures", "exa", "probe_20.json"), "{}"); + writeFileSync(join(root, "fixtures", "exa", "notes.md"), "x"); + writeFileSync(join(root, "fixtures", "exa", "probe_500.json"), "{not json"); + mkdirSync(join(root, "fixtures", "nobody")); + writeFileSync(join(root, "fixtures", "stray.json"), "{}"); + const got = messages(root).sort(); + assert.deepEqual(got.map((m) => m.replace(/: .*$/, "")), ["fixtures", "fixtures", "fixtures", "fixtures", "fixtures"]); + assert.ok(got.some((m) => /probe_20\.json must be named probe_\.json/.test(m))); + assert.ok(got.some((m) => /notes\.md must be named/.test(m))); + assert.ok(got.some((m) => /probe_500\.json is not valid JSON/.test(m))); + assert.ok(got.some((m) => /fixtures\/nobody\/ does not name a connector/.test(m))); + assert.ok(got.some((m) => /fixtures\/stray\.json is not a directory/.test(m))); + } finally { + rmSync(root, { recursive: true }); + } +}); + +test("a community connector and a fixture for it pass together", () => { + const root = sandbox(); + try { + cpSync(join(root, "examples", "exa"), join(root, "connectors", "exa"), { recursive: true }); + mkdirSync(join(root, "fixtures", "exa"), { recursive: true }); + writeFileSync(join(root, "fixtures", "exa", "probe_200.json"), "{}"); + assert.deepEqual(messages(root), []); + assert.equal(findConnector(root, "exa"), join(root, "connectors", "exa")); + assert.equal(findConnector(root, "none"), null); + assert.deepEqual(checkRepository(root, ["exa"]).names, ["exa"]); + assert.match(messages(root, ["none"])[0], /^none: no such connector directory/); + } finally { + rmSync(root, { recursive: true }); + } +}); diff --git a/scripts/test/probe.test.mjs b/scripts/test/probe.test.mjs index 9d42210..db7a169 100644 --- a/scripts/test/probe.test.mjs +++ b/scripts/test/probe.test.mjs @@ -6,7 +6,7 @@ import { createServer } from "node:http"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { buildProbeRequest, runProbe, classify, redact, redactHeaders, writeFixture, authHeader, connectorDir } from "../probe.mjs"; +import { buildProbeRequest, runProbe, classify, redact, redactHeaders, writeFixture, authHeader, connectorDir, fixtureDir } from "../probe.mjs"; const KEY = "sk-live-SECRET123"; let last; @@ -113,6 +113,11 @@ test("a 200 whose body satisfies expect passes, and the fixture is redacted", as try { const file = writeFixture("stub", result.fixture, { dir }); assert.equal(file, join(dir, "probe_200.json")); + // The default location is fixtures// at the repository root, never + // inside the connector directory. + const repo = new URL("../../", import.meta.url).pathname; + assert.equal(fixtureDir("exa"), join(repo, "fixtures", "exa")); + assert.equal(fixtureDir("stub"), join(repo, "fixtures", "stub")); assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), result.fixture); } finally { rmSync(dir, { recursive: true });