diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ecd3b5f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf + +*.tgz -text +*.zip -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..210f0e0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - windows-2022 + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: 10.32.1 + + - name: Install Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + cache: pnpm + + - name: Install exact dependencies + run: pnpm install --frozen-lockfile + + - name: Audit high-severity advisories + run: pnpm audit --audit-level high + + - name: Run the authoritative repository gate + run: pnpm check diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f0916..e82d62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog -All material protocol changes are recorded here. MASA uses semantic versioning for normative schemas and behavior. +All material protocol and reference-tooling changes are recorded here. MASA uses semantic versioning; a tooling patch may advance without changing the normative protocol version when its record contracts remain compatible. + +## 0.1.1 - 2026-08-09 + +Patch release of the TypeScript reference implementation. The normative MASA protocol, schemas, capability-set format, profile directories, and record/request version fields remain `0.1.0`. + +- Enforced every declared profile during public audits and recursively removed approved-extension secrets, credential-bearing URL parameters, absolute private paths, and nested precise coordinates. +- Expanded conservative offline non-public address detection across private, documentation, benchmarking, selected reserved IPv4/IPv6 space, and common transition forms; DNS resolution remains an explicitly documented connection-time policy boundary. +- Refused processing plans against invalid target records and removed duplicate MCP validation work. +- Made ZIP packing and promotion portable across Windows path and directory semantics, bounded temporary names independently of caller filenames, and added a Windows release-gate lane. +- Separated normative protocol identity from generated reference-implementation identity across the CLI, MCP server, capabilities, resources, and conformance evidence. +- Added deterministic capability metadata, synchronized release-version generation, pinned read-only CI, dependency maintenance, and a gate that validates the exact checked-in evidence set against the current specification, fixtures, runner, and release identity. ## 0.1.0 - 2026-08-07 diff --git a/README.md b/README.md index b31a988..454b904 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **MASA** is a project-agnostic Sound Matter Aware protocol. It provides a local-first, language-neutral contract for systems that need to describe, listen to, measure, transform, granulate, dissect, generate, map, relate, govern, and exchange sound as matter without confusing an audio file, a physical event, a perceptual account, or a computational claim. Conformance never depends on any other repository. -Version `0.1.0` is the first public release, MIT-licensed and open to any project. Its normative sources are the specification, JSON Schemas, ontology registries, and conformance fixtures in this repository. The TypeScript packages, CLI, and MCP server are reference implementations of those artifacts. +Protocol version `0.1.0` is the first public specification, MIT-licensed and open to any project. Its normative sources are the specification, JSON Schemas, ontology registries, and conformance fixtures in this repository. The current TypeScript reference-tooling release is `0.1.1`; tooling patches do not rename or silently revise the normative `0.1.0` resources. ## What is included @@ -49,7 +49,7 @@ See [`docs/project-agnostic-integration.md`](docs/project-agnostic-integration.m ## Local conformance evidence -The reference implementation records separate evidence for the `reader`, `writer`, `transformer`, `agent-host`, and `publisher` classes in [`conformance/0.1.0/evidence`](conformance/0.1.0/evidence). The current local run reports all five classes conformant on Node.js 22.22.3 for macOS arm64 against fixture-manifest digest `0240f5403d9d626c3eef75a55eb640cea84cf5b6d17d2824fe4874561c27be95`, computed over the examples, capability catalog, schemas, ontology registries, and JSON-LD context. +The reference implementation records separate evidence for the `reader`, `writer`, `transformer`, `agent-host`, and `publisher` classes in [`conformance/0.1.0/evidence`](conformance/0.1.0/evidence). The current local run reports all five classes conformant on Node.js 22.22.3 for macOS arm64 against fixture-manifest digest `7f57b4870c2414936114ce906258e1d4816be340d1c3c591cb95eec80bc9cdea`. The digest is refreshed from the examples, specification, capability catalog, schemas, ontology registries, JSON-LD context, and executable conformance runner whenever evidence is intentionally recorded. Run `pnpm check` to regenerate derived code and verify conformance on the current machine. Run `pnpm conformance:evidence` only when intentionally recording a new local evidence snapshot. Conformance is always specific to an implementation version, MASA version, profile, class, platform, and exact fixture set; it is not a universal certification. diff --git a/capabilities/0.1.0/reference.json b/capabilities/0.1.0/reference.json index 15b8d35..accf285 100644 --- a/capabilities/0.1.0/reference.json +++ b/capabilities/0.1.0/reference.json @@ -4,11 +4,11 @@ "masaVersion": "0.1.0", "id": "https://masa.sonicfield.org/capabilities/0.1.0/reference", "implementation": { - "id": "https://masa.sonicfield.org/implementations/typescript-reference/0.1.0", + "id": "https://masa.sonicfield.org/implementations/typescript-reference/0.1.1", "name": "MASA local TypeScript reference", - "version": "0.1.0" + "version": "0.1.1" }, - "generatedAt": "2026-07-27T00:00:00Z", + "generatedAt": "2026-08-09T00:00:00.000Z", "capabilities": [ { "id": "https://masa.sonicfield.org/capabilities/0.1.0/matter.validate", diff --git a/cli/package.json b/cli/package.json index 74de2ef..b8ab488 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@sonicfield/masa-cli", - "version": "0.1.0", + "version": "0.1.1", "private": true, "license": "MIT", "description": "Local command-line reference implementation for MASA", @@ -32,7 +32,7 @@ "@sonicfield/masa-validator": "workspace:*" }, "devDependencies": { - "@types/node": "24.3.0", + "@types/node": "24.13.3", "typescript": "7.0.2", "vitest": "4.1.10" }, diff --git a/cli/src/index.test.ts b/cli/src/index.test.ts index b58c5e2..26f7042 100644 --- a/cli/src/index.test.ts +++ b/cli/src/index.test.ts @@ -1,6 +1,10 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + MASA_PROTOCOL_VERSION, + MASA_REFERENCE_IMPLEMENTATION_VERSION, +} from "@sonicfield/masa"; import { afterEach, describe, expect, it } from "vitest"; import { runCli, type CliIo } from "./index.js"; @@ -26,7 +30,9 @@ describe("MASA CLI", () => { it("prints help without side effects", async () => { const result = capture(); await expect(runCli(["help"], result.io)).resolves.toBe(0); - expect(result.out.join("\n")).toContain("MASA 0.1.0 local CLI"); + expect(result.out.join("\n")).toContain( + `MASA local CLI ${MASA_REFERENCE_IMPLEMENTATION_VERSION} (protocol ${MASA_PROTOCOL_VERSION})`, + ); expect(result.error).toEqual([]); }); @@ -47,6 +53,20 @@ describe("MASA CLI", () => { expect(result.error).toEqual([]); }); + it("applies the public-safety audit to publisher conformance", async () => { + const recordPath = join( + import.meta.dirname, + "../../examples/0.1.0/invalid/public-local-path.masa.json", + ); + const result = capture(); + + await expect( + runCli(["conformance", "publisher", recordPath, "--json"], result.io), + ).resolves.toBe(1); + expect(result.out.join("\n")).toContain("MASA_PUBLIC_PATH"); + expect(result.error).toEqual([]); + }); + it("emits bundle diagnostics and exit code 1 when verify fails", async () => { const root = await temporaryRoot(); const bundle = join(root, "broken.masa"); diff --git a/cli/src/index.ts b/cli/src/index.ts index 53b5cc9..219cc5d 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -5,6 +5,8 @@ import { dirname, extname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { createPublicProjection, + MASA_PROTOCOL_VERSION, + MASA_REFERENCE_IMPLEMENTATION_VERSION, parseJsonStrict, referenceCapabilitySet, stableStringify, @@ -136,8 +138,8 @@ function processingTemplate(operation: string): Record { } return { requestType: "masa-processing-request", - requestVersion: "0.1.0", - masaVersion: "0.1.0", + requestVersion: MASA_PROTOCOL_VERSION, + masaVersion: MASA_PROTOCOL_VERSION, id: PLACEHOLDER_ID, createdAt: "2026-01-01T00:00:00Z", operationType: `matter.${operation}`, @@ -173,7 +175,7 @@ async function writeNewJson(path: string, value: unknown): Promise { function usage(): string { return [ - "MASA 0.1.0 local CLI", + `MASA local CLI ${MASA_REFERENCE_IMPLEMENTATION_VERSION} (protocol ${MASA_PROTOCOL_VERSION})`, "", "masa validate [--json]", "masa inspect [--json]", @@ -187,7 +189,7 @@ function usage(): string { "masa lineage [--direction ancestors|descendants|both] [--max-depth 1..64] [--json]", "masa conformance [--json]", "masa schema list [--json]", - "masa schema show [--version 0.1.0] [--json]", + `masa schema show [--version ${MASA_PROTOCOL_VERSION}] [--json]`, "masa capabilities [--json]" ].join("\n"); } @@ -205,7 +207,6 @@ async function validateCommand(path: string): Promise<{ output: unknown; code: n async function conformanceCommand(className: string, path: string): Promise<{ output: unknown; code: number }> { const record = await loadRecord(resolve(path)); - const validation = validateMatterRecord(record); const requiredProfile: Record = { reader: "core", writer: "core", @@ -215,6 +216,9 @@ async function conformanceCommand(className: string, path: string): Promise<{ ou }; const profile = requiredProfile[className]; if (!profile) throw new UsageError(`Unknown conformance class ${className}.`); + const validation = className === "publisher" + ? auditPublicRecord(record) + : validateMatterRecord(record); const profilePresent = Array.isArray(record.profiles) && (record.profiles as readonly unknown[]).includes(profile); @@ -222,7 +226,7 @@ async function conformanceCommand(className: string, path: string): Promise<{ ou return { output: { suite: "masa-artifact-check", - suiteVersion: "0.1.0", + suiteVersion: MASA_REFERENCE_IMPLEMENTATION_VERSION, masaVersion: typeof record.masaVersion === "string" ? record.masaVersion : "undeclared", class: className, profile, @@ -369,9 +373,9 @@ export async function runCli(argv: readonly string[], io: CliIo = defaultIo): Pr return 0; } if (subcommand === "show" && schemaName) { - const version = option(args, "version") ?? "0.1.0"; - if (version !== "0.1.0" || !/^[a-z][a-z0-9/-]*(?:\.schema\.json)?$/u.test(schemaName) || schemaName.includes("..")) { - throw new UsageError("Only a named MASA 0.1.0 schema may be shown."); + const version = option(args, "version") ?? MASA_PROTOCOL_VERSION; + if (version !== MASA_PROTOCOL_VERSION || !/^[a-z][a-z0-9/-]*(?:\.schema\.json)?$/u.test(schemaName) || schemaName.includes("..")) { + throw new UsageError(`Only a named MASA ${MASA_PROTOCOL_VERSION} schema may be shown.`); } const schema = getEmbeddedSchema(schemaName); if (schema === undefined) throw new UsageError(`Unknown MASA schema ${schemaName}.`); diff --git a/conformance/0.1.0/evidence/agent-host.json b/conformance/0.1.0/evidence/agent-host.json index 1196486..bf06e46 100644 --- a/conformance/0.1.0/evidence/agent-host.json +++ b/conformance/0.1.0/evidence/agent-host.json @@ -4,15 +4,15 @@ "extensions": {}, "fixtureManifestDigest": { "algorithm": "sha-256", - "byteLength": 298233, - "digest": "0240f5403d9d626c3eef75a55eb640cea84cf5b6d17d2824fe4874561c27be95", + "byteLength": 352859, + "digest": "7f57b4870c2414936114ce906258e1d4816be340d1c3c591cb95eec80bc9cdea", "status": "verified", - "verifiedAt": "2026-08-07T16:28:56.467Z" + "verifiedAt": "2026-08-09T19:23:54.499Z" }, "id": "urn:uuid:10000000-0000-4000-8000-000000000004", "implementation": { "name": "MASA local TypeScript reference", - "version": "0.1.0" + "version": "0.1.1" }, "masaVersion": "0.1.0", "migrationStatus": "not_applicable", @@ -22,12 +22,12 @@ "runtime": "node v22.22.3" }, "profile": "agent", - "runAt": "2026-08-07T16:28:56.467Z", + "runAt": "2026-08-09T19:23:54.499Z", "status": "conformant", "subject": "@sonicfield/masa workspace", "suite": { "name": "masa-typescript-reference-conformance", - "version": "0.1.0" + "version": "0.1.1" }, "tests": [ { diff --git a/conformance/0.1.0/evidence/publisher.json b/conformance/0.1.0/evidence/publisher.json index 58243a4..64b2e4e 100644 --- a/conformance/0.1.0/evidence/publisher.json +++ b/conformance/0.1.0/evidence/publisher.json @@ -4,15 +4,15 @@ "extensions": {}, "fixtureManifestDigest": { "algorithm": "sha-256", - "byteLength": 298233, - "digest": "0240f5403d9d626c3eef75a55eb640cea84cf5b6d17d2824fe4874561c27be95", + "byteLength": 352859, + "digest": "7f57b4870c2414936114ce906258e1d4816be340d1c3c591cb95eec80bc9cdea", "status": "verified", - "verifiedAt": "2026-08-07T16:28:56.467Z" + "verifiedAt": "2026-08-09T19:23:54.499Z" }, "id": "urn:uuid:10000000-0000-4000-8000-000000000005", "implementation": { "name": "MASA local TypeScript reference", - "version": "0.1.0" + "version": "0.1.1" }, "masaVersion": "0.1.0", "migrationStatus": "not_applicable", @@ -22,12 +22,12 @@ "runtime": "node v22.22.3" }, "profile": "publication", - "runAt": "2026-08-07T16:28:56.467Z", + "runAt": "2026-08-09T19:23:54.499Z", "status": "conformant", "subject": "@sonicfield/masa workspace", "suite": { "name": "masa-typescript-reference-conformance", - "version": "0.1.0" + "version": "0.1.1" }, "tests": [ { diff --git a/conformance/0.1.0/evidence/reader.json b/conformance/0.1.0/evidence/reader.json index 5993774..e798a1e 100644 --- a/conformance/0.1.0/evidence/reader.json +++ b/conformance/0.1.0/evidence/reader.json @@ -4,15 +4,15 @@ "extensions": {}, "fixtureManifestDigest": { "algorithm": "sha-256", - "byteLength": 298233, - "digest": "0240f5403d9d626c3eef75a55eb640cea84cf5b6d17d2824fe4874561c27be95", + "byteLength": 352859, + "digest": "7f57b4870c2414936114ce906258e1d4816be340d1c3c591cb95eec80bc9cdea", "status": "verified", - "verifiedAt": "2026-08-07T16:28:56.467Z" + "verifiedAt": "2026-08-09T19:23:54.499Z" }, "id": "urn:uuid:10000000-0000-4000-8000-000000000001", "implementation": { "name": "MASA local TypeScript reference", - "version": "0.1.0" + "version": "0.1.1" }, "masaVersion": "0.1.0", "migrationStatus": "not_applicable", @@ -22,12 +22,12 @@ "runtime": "node v22.22.3" }, "profile": "core", - "runAt": "2026-08-07T16:28:56.467Z", + "runAt": "2026-08-09T19:23:54.499Z", "status": "conformant", "subject": "@sonicfield/masa workspace", "suite": { "name": "masa-typescript-reference-conformance", - "version": "0.1.0" + "version": "0.1.1" }, "tests": [ { diff --git a/conformance/0.1.0/evidence/transformer.json b/conformance/0.1.0/evidence/transformer.json index 31135f3..da39315 100644 --- a/conformance/0.1.0/evidence/transformer.json +++ b/conformance/0.1.0/evidence/transformer.json @@ -4,15 +4,15 @@ "extensions": {}, "fixtureManifestDigest": { "algorithm": "sha-256", - "byteLength": 298233, - "digest": "0240f5403d9d626c3eef75a55eb640cea84cf5b6d17d2824fe4874561c27be95", + "byteLength": 352859, + "digest": "7f57b4870c2414936114ce906258e1d4816be340d1c3c591cb95eec80bc9cdea", "status": "verified", - "verifiedAt": "2026-08-07T16:28:56.467Z" + "verifiedAt": "2026-08-09T19:23:54.499Z" }, "id": "urn:uuid:10000000-0000-4000-8000-000000000003", "implementation": { "name": "MASA local TypeScript reference", - "version": "0.1.0" + "version": "0.1.1" }, "masaVersion": "0.1.0", "migrationStatus": "not_applicable", @@ -22,12 +22,12 @@ "runtime": "node v22.22.3" }, "profile": "transformation", - "runAt": "2026-08-07T16:28:56.467Z", + "runAt": "2026-08-09T19:23:54.499Z", "status": "conformant", "subject": "@sonicfield/masa workspace", "suite": { "name": "masa-typescript-reference-conformance", - "version": "0.1.0" + "version": "0.1.1" }, "tests": [ { diff --git a/conformance/0.1.0/evidence/writer.json b/conformance/0.1.0/evidence/writer.json index d3dccd9..ed283e2 100644 --- a/conformance/0.1.0/evidence/writer.json +++ b/conformance/0.1.0/evidence/writer.json @@ -4,15 +4,15 @@ "extensions": {}, "fixtureManifestDigest": { "algorithm": "sha-256", - "byteLength": 298233, - "digest": "0240f5403d9d626c3eef75a55eb640cea84cf5b6d17d2824fe4874561c27be95", + "byteLength": 352859, + "digest": "7f57b4870c2414936114ce906258e1d4816be340d1c3c591cb95eec80bc9cdea", "status": "verified", - "verifiedAt": "2026-08-07T16:28:56.467Z" + "verifiedAt": "2026-08-09T19:23:54.499Z" }, "id": "urn:uuid:10000000-0000-4000-8000-000000000002", "implementation": { "name": "MASA local TypeScript reference", - "version": "0.1.0" + "version": "0.1.1" }, "masaVersion": "0.1.0", "migrationStatus": "not_applicable", @@ -22,12 +22,12 @@ "runtime": "node v22.22.3" }, "profile": "core", - "runAt": "2026-08-07T16:28:56.467Z", + "runAt": "2026-08-09T19:23:54.499Z", "status": "conformant", "subject": "@sonicfield/masa workspace", "suite": { "name": "masa-typescript-reference-conformance", - "version": "0.1.0" + "version": "0.1.1" }, "tests": [ { diff --git a/conformance/run.ts b/conformance/run.ts index 748e161..66471a9 100644 --- a/conformance/run.ts +++ b/conformance/run.ts @@ -6,6 +6,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { parseJsonStrict, + MASA_PROTOCOL_VERSION, + MASA_REFERENCE_IMPLEMENTATION_VERSION, stableStringify, StrictJsonError, type Diagnostic, @@ -29,7 +31,7 @@ interface TestResult { } const root = resolve(import.meta.dirname, ".."); -const examples = join(root, "examples", "0.1.0"); +const examples = join(root, "examples", MASA_PROTOCOL_VERSION); const validDirectory = join(examples, "valid"); const invalidDirectory = join(examples, "invalid"); @@ -39,7 +41,7 @@ function conformanceFailure(id: string, error: unknown): Diagnostic { severity: "error", instancePath: `/${id}`, message: error instanceof Error ? error.message : "A conformance assertion failed.", - remediation: "Correct the implementation or fixture, then rerun the exact MASA 0.1.0 suite." + remediation: `Correct the implementation or fixture, then rerun the exact MASA ${MASA_PROTOCOL_VERSION} suite.` }; } @@ -73,7 +75,7 @@ function validateDeclaredRecord(value: MatterRecord) { : validateMatterRecord(value); } -const FIXTURE_EXTENSIONS = /\.(?:json|jsonld|ndjson)$/u; +const EVIDENCE_INPUT_EXTENSIONS = /\.(?:json|jsonld|md|ndjson)$/u; async function fixtureDigest(): Promise<{ digest: string; byteLength: number }> { // The digest witnesses the exact executable fixture set together with the @@ -83,20 +85,26 @@ async function fixtureDigest(): Promise<{ digest: string; byteLength: number }> // digest is identical across platforms and locales. const roots = [ examples, - join(root, "capabilities", "0.1.0"), - join(root, "schemas", "0.1.0"), - join(root, "ontology", "0.1.0"), - join(root, "contexts", "0.1.0") + join(root, "capabilities", MASA_PROTOCOL_VERSION), + join(root, "schemas", MASA_PROTOCOL_VERSION), + join(root, "ontology", MASA_PROTOCOL_VERSION), + join(root, "contexts", MASA_PROTOCOL_VERSION), + join(root, "spec", MASA_PROTOCOL_VERSION), ]; const labeled: Array<{ path: string; label: string }> = []; for (const directory of roots) { const entries = await readdir(directory, { recursive: true, withFileTypes: true }); for (const entry of entries) { - if (!entry.isFile() || !FIXTURE_EXTENSIONS.test(entry.name)) continue; + if (!entry.isFile() || !EVIDENCE_INPUT_EXTENSIONS.test(entry.name)) continue; const path = join(entry.parentPath, entry.name); labeled.push({ path, label: relative(root, path).split(sep).join("/") }); } } + const runnerPath = join(root, "conformance", "run.ts"); + labeled.push({ + path: runnerPath, + label: relative(root, runnerPath).split(sep).join("/"), + }); labeled.sort((left, right) => (left.label < right.label ? -1 : left.label > right.label ? 1 : 0)); const hash = createHash("sha256"); let byteLength = 0; @@ -215,7 +223,9 @@ tests.push(await test("transformer.lineage-and-failure", async () => { })); tests.push(await test("agent-host.capabilities-and-record", async () => { - const catalog = parseJsonStrict(await readFile(join(root, "capabilities", "0.1.0", "reference.json"), "utf8")); + const catalog = parseJsonStrict( + await readFile(join(root, "capabilities", MASA_PROTOCOL_VERSION, "reference.json"), "utf8"), + ); const catalogValidation = validateCapabilitySet(catalog); assert(catalogValidation.valid, `Capability catalog failed: ${stableStringify(catalogValidation.diagnostics)}`); const agentRecord = await record(join(validDirectory, "agent.masa.json")); @@ -229,7 +239,10 @@ tests.push(await test("agent-host.mcp-stdio", async () => { args: [serverPath], env: { ...process.env, MASA_ALLOWED_ROOTS: root } }); - const client = new Client({ name: "masa-conformance", version: "0.1.0" }); + const client = new Client({ + name: "masa-conformance", + version: MASA_REFERENCE_IMPLEMENTATION_VERSION, + }); try { await client.connect(transport); const listed = await client.listTools(); @@ -351,11 +364,17 @@ const evidence = classes.map((className, index) => { return { id: `urn:uuid:10000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, type: "masa:ConformanceResult", - suite: { name: "masa-typescript-reference-conformance", version: "0.1.0" }, - implementation: { name: "MASA local TypeScript reference", version: "0.1.0" }, + suite: { + name: "masa-typescript-reference-conformance", + version: MASA_REFERENCE_IMPLEMENTATION_VERSION, + }, + implementation: { + name: "MASA local TypeScript reference", + version: MASA_REFERENCE_IMPLEMENTATION_VERSION, + }, subject: "@sonicfield/masa workspace", platform: { os: platform(), architecture: arch(), runtime: `node ${process.version}` }, - masaVersion: "0.1.0", + masaVersion: MASA_PROTOCOL_VERSION, profile: classProfiles[className], class: className, status: failed.length === 0 ? "conformant" : "failed", @@ -374,6 +393,75 @@ const evidence = classes.map((className, index) => { }; }); +function evidenceFreshnessProjection(value: unknown): unknown { + const source = typeof value === "object" && value !== null + ? value as Record + : {}; + const fixtureManifestDigest = typeof source.fixtureManifestDigest === "object" && source.fixtureManifestDigest !== null + ? source.fixtureManifestDigest as Record + : {}; + return { + id: source.id, + type: source.type, + suite: source.suite, + implementation: source.implementation, + subject: source.subject, + masaVersion: source.masaVersion, + profile: source.profile, + class: source.class, + status: source.status, + migrationStatus: source.migrationStatus, + tests: source.tests, + fixtureManifestDigest: { + algorithm: fixtureManifestDigest.algorithm, + digest: fixtureManifestDigest.digest, + byteLength: fixtureManifestDigest.byteLength, + status: fixtureManifestDigest.status, + }, + diagnostics: source.diagnostics, + extensions: source.extensions, + }; +} + +async function verifyTrackedEvidence(): Promise { + const evidenceDirectory = join(root, "conformance", MASA_PROTOCOL_VERSION, "evidence"); + const expectedFileNames = evidence.map((result) => `${result.class}.json`).sort(); + const actualFileNames = (await readdir(evidenceDirectory)) + .filter((name) => name.endsWith(".json")) + .sort(); + if (stableStringify(actualFileNames) !== stableStringify(expectedFileNames)) { + throw new Error( + `Tracked conformance evidence must contain exactly: ${expectedFileNames.join(", ")}.`, + ); + } + for (const result of evidence) { + const path = join(evidenceDirectory, `${result.class}.json`); + const recorded = parseJsonStrict(await readFile(path, "utf8")); + const validation = validateDocument("conformanceResult", recorded); + if (!validation.valid) { + throw new Error( + `Tracked ${relative(root, path)} is not valid conformance evidence: ${stableStringify(validation.diagnostics)}.`, + ); + } + if ( + stableStringify(evidenceFreshnessProjection(recorded)) !== + stableStringify(evidenceFreshnessProjection(result)) + ) { + throw new Error( + `Tracked ${relative(root, path)} is stale. Run pnpm conformance:evidence after an intentional implementation or fixture change.`, + ); + } + } + + const readme = await readFile(join(root, "README.md"), "utf8"); + const documentedDigest = /fixture-manifest digest `([a-f0-9]{64})`/u.exec(readme)?.[1]; + if (documentedDigest !== fixture.digest) { + throw new Error( + "README.md does not report the current conformance fixture-manifest digest. Refresh its Local conformance evidence section.", + ); + } +} + for (const result of evidence) { const validation = validateDocument("conformanceResult", result); if (!validation.valid) { @@ -381,13 +469,23 @@ for (const result of evidence) { } } +const conformanceFailed = evidence.some((result) => result.status === "failed"); if (process.argv.includes("--write-evidence")) { - const evidenceDirectory = join(root, "conformance", "0.1.0", "evidence"); + const evidenceDirectory = join(root, "conformance", MASA_PROTOCOL_VERSION, "evidence"); await mkdir(evidenceDirectory, { recursive: true }); for (const result of evidence) { await writeFile(join(evidenceDirectory, `${result.class}.json`), `${stableStringify(result, 2)}\n`, "utf8"); } +} else if (!conformanceFailed) { + await verifyTrackedEvidence(); } -process.stdout.write(`${stableStringify({ suite: "masa-typescript-reference-conformance", version: "0.1.0", evidence }, 2)}\n`); -if (evidence.some((result) => result.status === "failed")) process.exitCode = 1; +process.stdout.write( + `${stableStringify({ + suite: "masa-typescript-reference-conformance", + version: MASA_REFERENCE_IMPLEMENTATION_VERSION, + masaVersion: MASA_PROTOCOL_VERSION, + evidence, + }, 2)}\n`, +); +if (conformanceFailed) process.exitCode = 1; diff --git a/mcp/package.json b/mcp/package.json index 269b7ab..55cbeb6 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@sonicfield/masa-mcp", - "version": "0.1.0", + "version": "0.1.1", "private": true, "license": "MIT", "description": "Read-oriented local MCP server for MASA", @@ -34,7 +34,7 @@ "zod": "4.4.3" }, "devDependencies": { - "@types/node": "24.3.0", + "@types/node": "24.13.3", "typescript": "7.0.2", "vitest": "4.1.10" }, diff --git a/mcp/src/server.ts b/mcp/src/server.ts index ce7e325..a5ae907 100644 --- a/mcp/src/server.ts +++ b/mcp/src/server.ts @@ -3,7 +3,13 @@ import { realpathSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { protocolResources, referenceCapabilitySet, stableStringify } from "@sonicfield/masa"; +import { + MASA_PROTOCOL_VERSION, + MASA_REFERENCE_IMPLEMENTATION_VERSION, + protocolResources, + referenceCapabilitySet, + stableStringify, +} from "@sonicfield/masa"; import { getEmbeddedSchema } from "@sonicfield/masa-validator"; import { z } from "zod"; import { @@ -50,7 +56,10 @@ function asToolResponse(result: Awaited>) { export async function createMasaServer(): Promise { const roots = await configuredRoots(); - const server = new McpServer({ name: "masa-local", version: "0.1.0" }); + const server = new McpServer({ + name: "masa-local", + version: MASA_REFERENCE_IMPLEMENTATION_VERSION, + }); const matterRecordSchema = getEmbeddedSchema("matterRecord"); if (matterRecordSchema === undefined) throw new Error("The embedded MatterRecord schema is absent."); @@ -58,7 +67,7 @@ export async function createMasaServer(): Promise { ...protocolResources, { name: "masa-record-schema", - uri: "masa://schemas/0.1.0/matter-record", + uri: `masa://schemas/${MASA_PROTOCOL_VERSION}/matter-record`, mimeType: "application/schema+json", text: stableStringify(matterRecordSchema, 2) }, diff --git a/mcp/src/tools.test.ts b/mcp/src/tools.test.ts index daeb996..050b3c0 100644 --- a/mcp/src/tools.test.ts +++ b/mcp/src/tools.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { configuredRoots } from "./root-policy.js"; -import { validateTarget } from "./tools.js"; +import { planProcessingTarget, validateTarget } from "./tools.js"; const temporaryRoots: string[] = []; @@ -40,3 +40,39 @@ describe("MCP tool failure redaction", () => { expect(result.diagnostics[0]!.message).not.toContain(outside); }); }); + +describe("MCP processing planning", () => { + it("refuses references supplied by an invalid record", async () => { + const root = await mkdtemp(join(tmpdir(), "masa-tools-")); + temporaryRoots.push(root); + const roots = await configuredRoots(root, root); + const representationId = "urn:uuid:10000000-0000-4000-8000-000000000001"; + const recordPath = join(root, "invalid-record.json"); + await writeFile( + recordPath, + JSON.stringify({ + representations: [{ id: representationId, type: "masa:Representation" }] + }), + "utf8" + ); + + const result = await planProcessingTarget( + { + operationType: "matter.granulate", + parameters: { + grain: { durationMs: { min: 5, max: 80 }, envelope: "gaussian" }, + emission: { mode: "asynchronous", grainsPerSecond: 200 }, + selection: { order: "statistical" }, + output: { kind: "texture" } + }, + inputs: [representationId], + path: recordPath + }, + roots + ); + + expect(result.valid).toBe(false); + expect(result.diagnostics.map(({ code }) => code)).toContain("MASA_SCHEMA_INVALID"); + expect(result.data).toBeUndefined(); + }); +}); diff --git a/mcp/src/tools.ts b/mcp/src/tools.ts index 6fbc197..adb4f28 100644 --- a/mcp/src/tools.ts +++ b/mcp/src/tools.ts @@ -3,6 +3,7 @@ import { extname } from "node:path"; import { generateId, indexRecord, + MASA_PROTOCOL_VERSION, parseJsonStrict, summarizeRecord, traceLineage, @@ -139,13 +140,11 @@ export async function traceLineageTarget( export async function auditPublicTarget(input: string, roots: readonly string[]): Promise { try { const record = await loadRecord(input, roots); - const validation = validateMatterRecord(record); const audit = auditPublicRecord(record); - const diagnostics = uniqueDiagnostics([...validation.diagnostics, ...audit.diagnostics]); return { status: "completed", - valid: validation.valid && audit.valid, - diagnostics, + valid: audit.valid, + diagnostics: audit.diagnostics, data: { audited: true } }; } catch (error) { @@ -185,8 +184,8 @@ export async function planProcessingTarget( try { const request = { requestType: "masa-processing-request", - requestVersion: "0.1.0", - masaVersion: "0.1.0", + requestVersion: MASA_PROTOCOL_VERSION, + masaVersion: MASA_PROTOCOL_VERSION, id: generateId(), createdAt: new Date().toISOString(), operationType: input.operationType, @@ -202,20 +201,24 @@ export async function planProcessingTarget( if (input.path !== undefined) { const record = await loadRecord(input.path, roots); - const index = indexRecord(record); - input.inputs.forEach((reference, position) => { - const entity = index.byId.get(reference); - if (entity === undefined || entity.collection !== "representations") { - diagnostics.push({ - code: "MASA_UNRESOLVED_REF", - severity: "error", - instancePath: `/inputs/${position}`, - schemaPath: "", - message: "A processing input does not resolve to a representation in the target record", - remediation: "Reference an existing representation identifier from the target record." - }); - } - }); + const recordValidation = validateMatterRecord(record); + diagnostics.push(...recordValidation.diagnostics); + if (recordValidation.valid) { + const index = indexRecord(record); + input.inputs.forEach((reference, position) => { + const entity = index.byId.get(reference); + if (entity === undefined || entity.collection !== "representations") { + diagnostics.push({ + code: "MASA_UNRESOLVED_REF", + severity: "error", + instancePath: `/inputs/${position}`, + schemaPath: "", + message: "A processing input does not resolve to a representation in the target record", + remediation: "Reference an existing representation identifier from the target record." + }); + } + }); + } } const valid = !diagnostics.some((item) => item.severity === "error"); diff --git a/package.json b/package.json index 34a00b0..be3c618 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sonicfield/masa-workspace", - "version": "0.1.0", + "version": "0.1.1", "private": true, "license": "MIT", "description": "Local-first reference repository for the MASA Sound Matter Aware protocol", @@ -26,17 +26,17 @@ "mcp": "node mcp/dist/server.js" }, "devDependencies": { - "@types/node": "24.3.0", - "@vitest/coverage-v8": "4.1.10", "@modelcontextprotocol/sdk": "1.30.0", "@sonicfield/masa": "workspace:*", "@sonicfield/masa-bundle": "workspace:*", "@sonicfield/masa-validator": "workspace:*", + "@types/node": "24.13.3", + "@vitest/coverage-v8": "4.1.10", "ajv": "8.20.0", "ajv-formats": "3.0.1", "json-schema-to-typescript": "15.0.4", - "publint": "0.3.22", - "tsx": "4.23.1", + "publint": "0.3.23", + "tsx": "4.23.11", "typescript": "7.0.2", "vitest": "4.1.10" } diff --git a/packages/bundle/package.json b/packages/bundle/package.json index bd66831..0066e05 100644 --- a/packages/bundle/package.json +++ b/packages/bundle/package.json @@ -1,6 +1,6 @@ { "name": "@sonicfield/masa-bundle", - "version": "0.1.0", + "version": "0.1.1", "description": "Deterministic, bounded MASA directory and ZIP bundle tooling", "type": "module", "private": true, diff --git a/packages/bundle/src/directory.ts b/packages/bundle/src/directory.ts index 453d177..f939811 100644 --- a/packages/bundle/src/directory.ts +++ b/packages/bundle/src/directory.ts @@ -5,10 +5,11 @@ import { realpath } from "node:fs/promises"; import { join, relative, resolve, sep } from "node:path"; -import type { - BundleManifest, - Diagnostic, - MatterRecord +import { + MASA_PROTOCOL_VERSION, + type BundleManifest, + type Diagnostic, + type MatterRecord } from "@sonicfield/masa"; import { consumeReadable, @@ -386,7 +387,7 @@ export function verifyManifestSemantics( diagnostics.push( diagnostic( "MASA_BUNDLE_EVENT_LOG_DUPLICATE", - "A MASA 0.1.0 bundle may contain at most one Core event log.", + `A MASA ${MASA_PROTOCOL_VERSION} bundle may contain at most one Core event log.`, "/files" ) ); diff --git a/packages/bundle/src/zip.ts b/packages/bundle/src/zip.ts index 1ed56f3..84eb5ff 100644 --- a/packages/bundle/src/zip.ts +++ b/packages/bundle/src/zip.ts @@ -8,6 +8,7 @@ import { open, rename, rm, + rmdir, unlink } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; @@ -208,7 +209,7 @@ export async function packDirectoryBundle( const parent = dirname(destination); await assertSafeDestinationParent(parent); await assertAbsent(destination); - const temporary = join(parent, `.${destination.split("/").at(-1) ?? "bundle"}.${randomUUID()}.tmp`); + const temporary = join(parent, `.masa-pack.${randomUUID()}.tmp`); try { const zip = new yazl.ZipFile(); @@ -306,11 +307,21 @@ export async function unpackZipBundle( } throw error; } - try { + if (process.platform === "win32") { + // Windows cannot rename a directory over the empty claim directory. + // Removing only our still-empty claim preserves no-overwrite behavior: + // a destination created in the gap makes rename fail rather than replace it. + await rmdir(destination); await rename(temporary, destination); - } catch (error) { - await rm(destination, { recursive: false, force: true }).catch(() => undefined); - throw error; + } else { + try { + await rename(temporary, destination); + } catch (error) { + // Remove only the empty claim we created. If another process populated + // it, rmdir refuses and its data is preserved. + await rmdir(destination).catch(() => undefined); + throw error; + } } promoted = true; return extractedInspection; diff --git a/packages/core/package.json b/packages/core/package.json index 85d0f0b..8005109 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sonicfield/masa", - "version": "0.1.0", + "version": "0.1.1", "private": true, "license": "MIT", "description": "Browser-safe types and pure utilities for MASA 0.1.0", diff --git a/packages/core/src/generated/protocol-resources.ts b/packages/core/src/generated/protocol-resources.ts index 1f45d3e..73b92f4 100644 --- a/packages/core/src/generated/protocol-resources.ts +++ b/packages/core/src/generated/protocol-resources.ts @@ -25,11 +25,11 @@ export const referenceCapabilitySet = { "masaVersion": "0.1.0", "id": "https://masa.sonicfield.org/capabilities/0.1.0/reference", "implementation": { - "id": "https://masa.sonicfield.org/implementations/typescript-reference/0.1.0", + "id": "https://masa.sonicfield.org/implementations/typescript-reference/0.1.1", "name": "MASA local TypeScript reference", - "version": "0.1.0" + "version": "0.1.1" }, - "generatedAt": "2026-07-27T00:00:00Z", + "generatedAt": "2026-08-09T00:00:00.000Z", "capabilities": [ { "id": "https://masa.sonicfield.org/capabilities/0.1.0/matter.validate", diff --git a/packages/core/src/generated/version.ts b/packages/core/src/generated/version.ts new file mode 100644 index 0000000..805625e --- /dev/null +++ b/packages/core/src/generated/version.ts @@ -0,0 +1,3 @@ +/** Generated from synchronized MASA workspace package metadata. */ +export const MASA_PROTOCOL_VERSION = "0.1.0"; +export const MASA_REFERENCE_IMPLEMENTATION_VERSION = "0.1.1"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aaf8159..395eec3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,10 @@ export type { OperationReceipt, } from "./generated/index.js"; export { protocolResources, referenceCapabilitySet } from "./generated/protocol-resources.js"; +export { + MASA_PROTOCOL_VERSION, + MASA_REFERENCE_IMPLEMENTATION_VERSION, +} from "./generated/version.js"; export { acyclicRelationOrientations, acyclicRelationPredicates, @@ -56,7 +60,11 @@ export type { } from "./public-projection.js"; export { createPublicProjection, + isCredentialParameterKey, + isNonPublicHostname, isPrivateHostname, + isSecretConfigurationKey, + isUnsafeFilesystemLocator, PublicProjectionError, } from "./public-projection.js"; export type { JsonVisit, JsonVisitor } from "./traversal.js"; diff --git a/packages/core/src/public-projection.test.ts b/packages/core/src/public-projection.test.ts index b889c21..8703b4a 100644 --- a/packages/core/src/public-projection.test.ts +++ b/packages/core/src/public-projection.test.ts @@ -2,7 +2,12 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { createPublicProjection } from "./public-projection.js"; +import { + createPublicProjection, + isCredentialParameterKey, + isNonPublicHostname, + isUnsafeFilesystemLocator, +} from "./public-projection.js"; import { stableStringify } from "./json.js"; import type { MatterRecord } from "./generated/index.js"; @@ -55,11 +60,112 @@ describe("createPublicProjection", () => { const source = publicationFixture(); const representations = source.representations as Array>; representations[0]!.locator = { state: "known", value: "/Users/somebody/private/master.wav" }; + representations[0]!.extensions = { + "example:provider": { + accessToken: "synthetic-access-token", + privateKey: "synthetic-private-key", + }, + }; + (source.publication as Record).approvedExtensionNamespaces = ["example:"]; const projection = createPublicProjection(source as unknown as MatterRecord); const serialized = stableStringify(projection.record); expect(serialized).not.toContain("/Users/somebody/private/master.wav"); + expect(serialized).not.toContain("synthetic-access-token"); + expect(serialized).not.toContain("synthetic-private-key"); expect(projection.report.redactions.some(({ category }) => category === "unsafe_locator")).toBe(true); + expect( + projection.report.redactions.filter(({ category }) => category === "provider_or_secret_configuration"), + ).toHaveLength(2); + }); + + it("uses valid JSON Pointers for redactions inside approved extensions", () => { + const source = publicationFixture(); + const representations = source.representations as Array>; + representations[0]!.extensions = { + "example:apiKey": "synthetic-namespaced-secret", + "example:provider": { + "foo/bar~baz": { + openaiApiKey: "synthetic-prefixed-secret", + token: "synthetic-token", + "x-amz-security-token": "synthetic-session-credential", + }, + }, + }; + (source.publication as Record).approvedExtensionNamespaces = ["example:"]; + + const projection = createPublicProjection(source as unknown as MatterRecord); + + expect(projection.report.redactions).toContainEqual({ + pointer: "/representations/0/extensions/example:provider/foo~1bar~0baz/token", + category: "provider_or_secret_configuration", + }); + expect(stableStringify(projection.record)).not.toContain("synthetic-token"); + expect(stableStringify(projection.record)).not.toContain("synthetic-session-credential"); + expect(stableStringify(projection.record)).not.toContain("synthetic-namespaced-secret"); + expect(stableStringify(projection.record)).not.toContain("synthetic-prefixed-secret"); + }); + + it("recognizes private hostnames across canonical URL forms", () => { + for (const hostname of [ + "localhost.", + "device.local.", + "fixture.test", + "[::]", + "[::1]", + "[fe9a::1]", + "[fd00::1]", + "[fec0::1]", + "[::127.0.0.1]", + "[::ffff:7f00:1]", + "[::ffff:c0a8:1]", + ]) { + expect(isNonPublicHostname(hostname), hostname).toBe(true); + } + for (const hostname of [ + "192.0.2.1", + "198.18.0.1", + "203.0.113.1", + "224.0.0.1", + "255.255.255.255", + "[2001:db8::1]", + "[3fff::1]", + "[ff02::1]", + "[64:ff9b:1::1]", + "[64:ff9b::c0a8:1]", + "[2002:c0a8:0101::1]", + "[400::1]", + "[2001:10::1]", + "[2001:20::1]", + "[2001:30::1]", + "intranet", + ]) { + expect(isNonPublicHostname(hostname), hostname).toBe(true); + } + expect(isNonPublicHostname("192.0.0.9")).toBe(false); + expect(isNonPublicHostname("[::ffff:0808:0808]")).toBe(false); + expect(isNonPublicHostname("[64:ff9b::0808:0808]")).toBe(false); + expect(isNonPublicHostname("[2002:0808:0808::1]")).toBe(false); + expect(isNonPublicHostname("[2001:3::1]")).toBe(false); + expect(isNonPublicHostname("example.org")).toBe(false); + expect(isCredentialParameterKey("x-amz-signature")).toBe(true); + expect(isCredentialParameterKey("X-Amz-Security-Token")).toBe(true); + expect(isCredentialParameterKey("key")).toBe(true); + expect(isCredentialParameterKey("monkey")).toBe(false); + for (const locator of [ + "FILE:///Users/alice/private.wav", + " file:///Users/alice/private.wav", + "../private/master.wav", + "..\\private\\master.wav", + "%2e%2e%2fprivate.wav", + "%2e%2e%5cprivate.wav", + "%252e%252e%252fprivate.wav", + "~alice/private.wav", + "C:private.wav", + ]) { + expect(isUnsafeFilesystemLocator(locator), locator).toBe(true); + } + expect(isUnsafeFilesystemLocator("media/preview.wav")).toBe(false); }); }); diff --git a/packages/core/src/public-projection.ts b/packages/core/src/public-projection.ts index 5e2f634..b4a8ae5 100644 --- a/packages/core/src/public-projection.ts +++ b/packages/core/src/public-projection.ts @@ -1,5 +1,5 @@ import type { MatterRecord } from "./generated/index.js"; -import { cloneJson } from "./json.js"; +import { cloneJson, escapeJsonPointerSegment } from "./json.js"; import { asJsonArray, isJsonObject, readString, readStringArray } from "./traversal.js"; const DISCLOSURE_COLLECTIONS = [ @@ -37,6 +37,9 @@ const PUBLIC_REPRESENTATION_ROLES = new Set([ "other", ]); +const SECRET_CONFIGURATION_KEY = /^(?:accesskey|accesskeyid|accesstoken|apikey|apisecret|authorization|authkey|authtoken|awsaccesskeyid|awssecretaccesskey|bearertoken|clientsecret|connectionstring|cookie|credentials?|databaseurl|environment|idtoken|oauthsecret|oauthtoken|password|passphrase|privateendpoint|privatekey|providerconfig|providersettings|refreshtoken|secret|secretaccesskey|securitytoken|serviceaccountkey|sessiontoken|signingkey|token|webhooksecret|xamzsecuritytoken|xapikey|xgoogsecuritytoken)$/u; +const SECRET_CONFIGURATION_SUFFIX = /(?:accesskeyid|accesstoken|apikey|apisecret|authkey|authtoken|awssecretaccesskey|bearertoken|clientsecret|connectionstring|databaseurl|idtoken|oauthsecret|oauthtoken|password|passphrase|privatekey|refreshtoken|secretaccesskey|securitytoken|serviceaccountkey|sessiontoken|signingkey|webhooksecret)$/u; + export interface PublicProjectionOptions { /** IDs for entities without a disclosure field that received explicit review. */ includeEntityIds?: readonly string[]; @@ -225,7 +228,13 @@ export function createPublicProjection( // Filter the record-level extension bag before snapshotting the embedded // omission and redaction lists, so extension omissions are reported inside // the published Publication object and not only in the returned report. - output.extensions = filterExtensionBag(source.extensions, approvedNamespaces, "/extensions", omissions); + output.extensions = filterExtensionBag( + source.extensions, + approvedNamespaces, + "/extensions", + omissions, + redactions, + ); const existingOmissions = asJsonArray(publication.omissions).filter(isJsonObject); const existingRedactions = asJsonArray(publication.redactions).filter(isJsonObject); @@ -309,9 +318,15 @@ function sanitizeValue( const output: Record = Object.create(null) as Record; for (const childKey of Object.keys(value).sort()) { - const childPointer = `${pointer}/${childKey}`; + const childPointer = `${pointer}/${escapeJsonPointerSegment(childKey)}`; if (childKey === "extensions") { - output[childKey] = filterExtensionBag(value[childKey], approvedNamespaces, childPointer, omissions); + output[childKey] = filterExtensionBag( + value[childKey], + approvedNamespaces, + childPointer, + omissions, + redactions, + ); continue; } if (isSecretConfigurationKey(childKey)) { @@ -335,6 +350,7 @@ function filterExtensionBag( approvedNamespaces: ReadonlySet, pointer: string, omissions: ProjectionNotice[], + redactions: Array>, ): Record { const output: Record = Object.create(null) as Record; if (!isJsonObject(value)) { @@ -345,20 +361,52 @@ function filterExtensionBag( const namespace = separator < 0 ? "" : `${extensionKey.slice(0, separator)}:`; if (!approvedNamespaces.has(namespace)) { omissions.push({ - pointer: `${pointer}/${extensionKey}`, + pointer: `${pointer}/${escapeJsonPointerSegment(extensionKey)}`, category: "unapproved_extension", reason: "Extension namespace was not approved for publication", }); continue; } - output[extensionKey] = cloneJson(extensionValue); + if (isSecretConfigurationKey(extensionKey)) { + redactions.push({ + pointer: `${pointer}/${escapeJsonPointerSegment(extensionKey)}`, + category: "provider_or_secret_configuration", + }); + continue; + } + output[extensionKey] = sanitizeValue( + extensionValue, + `${pointer}/${escapeJsonPointerSegment(extensionKey)}`, + approvedNamespaces, + omissions, + redactions, + ); } return output; } -function isSecretConfigurationKey(key: string): boolean { - return /^(?:api[_-]?key|authorization|cookie|credential|environment|password|private[_-]?endpoint|provider[_-]?(?:config|settings)|secret|token)$/i.test( - key, +/** Identify configuration keys whose values must never enter a public projection. */ +export function isSecretConfigurationKey(key: string): boolean { + const normalized = normalizeConfigurationKey(key); + const namespaceSeparator = key.lastIndexOf(":"); + const localName = normalizeConfigurationKey( + namespaceSeparator < 0 ? key : key.slice(namespaceSeparator + 1), + ); + return ( + SECRET_CONFIGURATION_KEY.test(normalized) || + SECRET_CONFIGURATION_KEY.test(localName) || + SECRET_CONFIGURATION_SUFFIX.test(localName) + ); +} + +/** Identify URL parameter names that commonly carry credentials or signatures. */ +export function isCredentialParameterKey(key: string): boolean { + const normalized = normalizeConfigurationKey(key); + return ( + isSecretConfigurationKey(key) || + /^(?:credential|googleaccessid|key|keypairid|oauthsignature|securitytoken|signature|sig|xamzcredential|xamzsecuritytoken|xamzsignature|xgoogcredential|xgoogsecuritytoken|xgoogsignature)$/u.test( + normalized, + ) ); } @@ -366,43 +414,183 @@ function isUnsafeLocator(value: unknown): boolean { if (typeof value !== "string") { return false; } - if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("file:")) { + if (isUnsafeFilesystemLocator(value)) { return true; } try { - const url = new URL(value); + const url = new URL(value.trim()); return ( url.username !== "" || url.password !== "" || - isPrivateHostname(url.hostname) || - [...url.searchParams.keys()].some((key) => /key|password|secret|token/i.test(key)) + isNonPublicHostname(url.hostname) || + hasCredentialParameter(url) ); } catch { - return value.includes("\\") || value.split("/").includes(".."); + return false; + } +} + +/** Identify local, absolute, or traversing filesystem locators. */ +export function isUnsafeFilesystemLocator(value: string): boolean { + const forms = [value.trim()]; + for (let depth = 0; depth < 3; depth += 1) { + try { + const decoded = decodeURIComponent(forms.at(-1)!); + if (decoded === forms.at(-1)) break; + forms.push(decoded); + } catch { + break; + } + } + for (const normalized of forms) { + if ( + /^(?:~[^\\/]*[\\/]|\/|[A-Za-z]:|\\\\)/u.test(normalized) || + normalized.includes("\\") || + normalized.split("/").includes("..") + ) { + return true; + } + } + try { + return new URL(forms[0]!).protocol.toLowerCase() === "file:"; + } catch { + return false; } } /** - * Loopback, link-local, unique-local, carrier-grade NAT, and private-range - * detection shared by projection and publication auditing. This is defense - * in depth for obvious private endpoints, not a complete network policy. + * Conservative offline detection for literal non-public hosts shared by + * projection and publication auditing. It covers loopback/private/link-local, + * documentation, benchmarking, selected reserved, and common IPv4-in-IPv6 + * transition forms. A hostname that passes can still resolve to a non-public + * address, so callers must enforce DNS and network policy at connection time. */ -export function isPrivateHostname(hostname: string): boolean { - let normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ""); - if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized.endsWith(".local")) { +export function isNonPublicHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/u, ""); + // Hostless URI schemes such as urn:uuid: have no network authority and are + // not endpoint candidates. Filesystem URLs are classified separately. + if (normalized.length === 0) return false; + if (["example", "home.arpa", "invalid", "local", "localhost", "test"].some( + (suffix) => normalized === suffix || normalized.endsWith(`.${suffix}`), + )) { return true; } - if (normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd")) { - return true; + + const ipv6 = parseIpv6(normalized); + if (ipv6 !== undefined) { + const first = ipv6[0]!; + const unspecified = ipv6.every((word) => word === 0); + const loopback = ipv6.slice(0, 7).every((word) => word === 0) && ipv6[7] === 1; + const linkLocal = (first & 0xffc0) === 0xfe80; + const deprecatedSiteLocal = (first & 0xffc0) === 0xfec0; + const uniqueLocal = (first & 0xfe00) === 0xfc00; + const multicast = (first & 0xff00) === 0xff00; + const ipv4Mapped = ipv6.slice(0, 5).every((word) => word === 0) && ipv6[5] === 0xffff; + const localNat64 = ipv6[0] === 0x64 && ipv6[1] === 0xff9b && ipv6[2] === 1; + const discardOnly = + ipv6[0] === 0x100 && ipv6[1] === 0 && ipv6[2] === 0 && ipv6[3]! <= 1; + const benchmark = ipv6[0] === 0x2001 && ipv6[1] === 0x2 && ipv6[2] === 0; + const orchid = ipv6[0] === 0x2001 && (ipv6[1]! & 0xfff0) === 0x10; + const specialPurposeIdentifier = + ipv6[0] === 0x2001 && + ((ipv6[1]! & 0xfff0) === 0x20 || (ipv6[1]! & 0xfff0) === 0x30); + const documentation = + (ipv6[0] === 0x2001 && ipv6[1] === 0x0db8) || + (ipv6[0] === 0x3fff && (ipv6[1]! & 0xf000) === 0); + const segmentRouting = ipv6[0] === 0x5f00; + if ( + unspecified || + loopback || + linkLocal || + deprecatedSiteLocal || + uniqueLocal || + multicast || + localNat64 || + discardOnly || + benchmark || + orchid || + specialPurposeIdentifier || + documentation || + segmentRouting + ) return true; + if (ipv4Mapped) { + return isNonPublicIpv4([ + ipv6[6]! >>> 8, + ipv6[6]! & 0xff, + ipv6[7]! >>> 8, + ipv6[7]! & 0xff, + ]); + } + const embeddedIpv4 = [ + ipv6[6]! >>> 8, + ipv6[6]! & 0xff, + ipv6[7]! >>> 8, + ipv6[7]! & 0xff, + ] as const; + const wellKnownNat64 = + ipv6[0] === 0x64 && + ipv6[1] === 0xff9b && + ipv6.slice(2, 6).every((word) => word === 0); + const sixToFour = ipv6[0] === 0x2002; + const isatap = ipv6[5] === 0x5efe; + const ipv4Translated = + ipv6.slice(0, 4).every((word) => word === 0) && + ipv6[4] === 0xffff && + ipv6[5] === 0; + const ipv4Compatible = ipv6.slice(0, 6).every((word) => word === 0); + if (wellKnownNat64 || isatap || ipv4Translated || ipv4Compatible) { + return isNonPublicIpv4(embeddedIpv4); + } + if (sixToFour) { + return isNonPublicIpv4([ + ipv6[1]! >>> 8, + ipv6[1]! & 0xff, + ipv6[2]! >>> 8, + ipv6[2]! & 0xff, + ]); + } + // IANA currently allocates general global-unicast space from 2000::/3. + // Explicit transition forms above are evaluated by their embedded IPv4 + // address; other IPv6 space outside that allocation is non-public here. + return (first & 0xe000) !== 0x2000; } - if (normalized.startsWith("::ffff:")) { - normalized = normalized.slice("::ffff:".length); + + const octets = parseIpv4(normalized); + if (octets !== undefined) return isNonPublicIpv4(octets); + return !normalized.includes("."); +} + +/** @deprecated Use isNonPublicHostname; retained as a compatibility alias. */ +export function isPrivateHostname(hostname: string): boolean { + return isNonPublicHostname(hostname); +} + +function hasCredentialParameter(url: URL): boolean { + const parameterNames = [...url.searchParams.keys()]; + if (url.hash.length > 1) { + parameterNames.push(...new URLSearchParams(url.hash.slice(1)).keys()); } - const octets = normalized.split(".").map(Number); - if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) { - return false; + return parameterNames.some(isCredentialParameterKey); +} + +function normalizeConfigurationKey(key: string): string { + return key.normalize("NFKC").replace(/[^A-Za-z0-9]/gu, "").toLowerCase(); +} + +function parseIpv4(value: string): [number, number, number, number] | undefined { + const parts = value.split("."); + if (parts.length !== 4 || parts.some((part) => !/^(?:0|[1-9][0-9]{0,2})$/u.test(part))) { + return undefined; } - const [first = -1, second = -1] = octets; + const octets = parts.map(Number); + if (octets.some((octet) => octet > 255)) { + return undefined; + } + return octets as [number, number, number, number]; +} + +function isNonPublicIpv4(octets: readonly number[]): boolean { + const [first = -1, second = -1, third = -1, fourth = -1] = octets; return ( first === 0 || first === 10 || @@ -410,6 +598,44 @@ export function isPrivateHostname(hostname: string): boolean { (first === 100 && second >= 64 && second <= 127) || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || - (first === 192 && second === 168) + (first === 192 && second === 0 && third === 0 && fourth !== 9 && fourth !== 10) || + (first === 192 && second === 0 && third === 2) || + (first === 192 && second === 88 && third === 99) || + (first === 192 && second === 168) || + (first === 198 && (second === 18 || second === 19)) || + (first === 198 && second === 51 && third === 100) || + (first === 203 && second === 0 && third === 113) || + first >= 224 ); } + +function parseIpv6(value: string): number[] | undefined { + if (!value.includes(":")) return undefined; + + let normalized = value; + const dottedTail = /(?:^|:)([0-9]+(?:\.[0-9]+){3})$/u.exec(normalized)?.[1]; + if (dottedTail !== undefined) { + const octets = parseIpv4(dottedTail); + if (octets === undefined) return undefined; + const high = (octets[0] << 8) | octets[1]; + const low = (octets[2] << 8) | octets[3]; + normalized = `${normalized.slice(0, -dottedTail.length)}${high.toString(16)}:${low.toString(16)}`; + } + + const compressed = normalized.includes("::"); + if (compressed && normalized.indexOf("::") !== normalized.lastIndexOf("::")) return undefined; + const [leftText = "", rightText = ""] = compressed + ? normalized.split("::") + : [normalized, ""]; + const left = leftText === "" ? [] : leftText.split(":"); + const right = rightText === "" ? [] : rightText.split(":"); + if ([...left, ...right].some((word) => !/^[A-Fa-f0-9]{1,4}$/u.test(word))) return undefined; + + const missing = 8 - left.length - right.length; + if ((compressed && missing < 1) || (!compressed && missing !== 0)) return undefined; + return [ + ...left.map((word) => Number.parseInt(word, 16)), + ...Array.from({ length: missing }, () => 0), + ...right.map((word) => Number.parseInt(word, 16)), + ]; +} diff --git a/packages/validator/package.json b/packages/validator/package.json index ad15067..7e855ed 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -1,6 +1,6 @@ { "name": "@sonicfield/masa-validator", - "version": "0.1.0", + "version": "0.1.1", "private": true, "license": "MIT", "description": "Offline structural, semantic, profile, and public-safety validation for MASA 0.1.0", diff --git a/packages/validator/src/index.test.ts b/packages/validator/src/index.test.ts index 9d46dcf..25183f6 100644 --- a/packages/validator/src/index.test.ts +++ b/packages/validator/src/index.test.ts @@ -60,6 +60,183 @@ describe("offline MASA validation", () => { ); }); + it("enforces every declared profile during public audit", () => { + const record = structuredClone( + fixture("examples/0.1.0/valid/publication.masa.json"), + ) as any; + record.profiles.push("audio"); + + const result = auditPublicRecord(record); + + expect(result.valid).toBe(false); + expect(result.diagnostics.map(({ code }) => code)).toContain("MASA_PROFILE_MISMATCH"); + }); + + it("detects public-safety bypass forms", () => { + const base = fixture("examples/0.1.0/valid/publication.masa.json"); + const cases: Array<{ mutate(record: any): void; code: string }> = [ + { + mutate: (record) => { + record.representations[0].locator.value = "/workspace/private/master.wav"; + }, + code: "MASA_PUBLIC_PATH", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "http://[::ffff:7f00:1]/private"; + }, + code: "MASA_PUBLIC_PRIVATE_ENDPOINT", + }, + { + mutate: (record) => { + record.extensions = { "example:geo": { coordinates: [[[-74.1, 4.6]]] } }; + record.publication.approvedExtensionNamespaces = ["example:"]; + }, + code: "MASA_PUBLIC_PRECISE_LOCATION", + }, + { + mutate: (record) => { + record.extensions = { "example:provider": { privateKey: "synthetic-private-key" } }; + record.publication.approvedExtensionNamespaces = ["example:"]; + }, + code: "MASA_PUBLIC_SECRET", + }, + { + mutate: (record) => { + record.extensions = { + "example:provider": { "x-goog-security-token": "synthetic-session-credential" }, + }; + record.publication.approvedExtensionNamespaces = ["example:"]; + }, + code: "MASA_PUBLIC_SECRET", + }, + { + mutate: (record) => { + record.extensions = { "example:apiKey": "synthetic-namespaced-secret" }; + record.publication.approvedExtensionNamespaces = ["example:"]; + }, + code: "MASA_PUBLIC_SECRET", + }, + { + mutate: (record) => { + record.extensions = { + "example:provider": { openaiApiKey: "synthetic-prefixed-secret" }, + }; + record.publication.approvedExtensionNamespaces = ["example:"]; + }, + code: "MASA_PUBLIC_SECRET", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "https://example.test/audio#access_token=synthetic"; + }, + code: "MASA_PUBLIC_SECRET", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "https://audio.sonicfield.org/example.wav?key=synthetic"; + }, + code: "MASA_PUBLIC_SECRET", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "https://audio.sonicfield.org/example.wav?X-Amz-Security-Token=synthetic"; + }, + code: "MASA_PUBLIC_SECRET", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "FILE:///Users/alice/private.wav"; + }, + code: "MASA_PUBLIC_PATH", + }, + { + mutate: (record) => { + record.representations[0].locator.value = " file:///Users/alice/private.wav"; + }, + code: "MASA_PUBLIC_PATH", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "../private/master.wav"; + }, + code: "MASA_PUBLIC_PATH", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "..\\private\\master.wav"; + }, + code: "MASA_PUBLIC_PATH", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "%252e%252e%252fprivate.wav"; + }, + code: "MASA_PUBLIC_PATH", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "~alice/private.wav"; + }, + code: "MASA_PUBLIC_PATH", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "C:private.wav"; + }, + code: "MASA_PUBLIC_PATH", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "https://intranet/private"; + }, + code: "MASA_PUBLIC_PRIVATE_ENDPOINT", + }, + { + mutate: (record) => { + record.representations[0].locator.value = "http://[400::1]/private"; + }, + code: "MASA_PUBLIC_PRIVATE_ENDPOINT", + }, + { + mutate: (record) => { + record.extensions = { "example:geo": { Coordinates: [[[-74.1, 4.6]]] } }; + record.publication.approvedExtensionNamespaces = ["example:"]; + }, + code: "MASA_PUBLIC_PRECISE_LOCATION", + }, + { + mutate: (record) => { + record.publication.omissions = [{ + pointer: "/Users/alice/.ssh/id_rsa", + category: "synthetic", + reason: "synthetic invalid pointer", + }]; + }, + code: "MASA_PUBLIC_PATH", + }, + ]; + + for (const testCase of cases) { + const record = structuredClone(base) as any; + testCase.mutate(record); + expect(auditPublicRecord(record).diagnostics.map(({ code }) => code)).toContain(testCase.code); + } + }); + + it("distinguishes valid publication JSON Pointers from local paths", () => { + const record = structuredClone( + fixture("examples/0.1.0/valid/publication.masa.json"), + ) as any; + record.publication.omissions = [{ + pointer: "/representations/0/locator", + category: "synthetic", + reason: "synthetic valid pointer", + }]; + + expect(auditPublicRecord(record).diagnostics.map(({ code }) => code)).not.toContain("MASA_PUBLIC_PATH"); + }); + it("emits remediable, value-free structural diagnostics", () => { const result = validateMatterRecord({ masaVersion: "9.9.9", diff --git a/packages/validator/src/index.ts b/packages/validator/src/index.ts index 81e769c..e6a59f8 100644 --- a/packages/validator/src/index.ts +++ b/packages/validator/src/index.ts @@ -12,6 +12,7 @@ import { cloneJson, hasErrorDiagnostics, isJsonObject, + MASA_PROTOCOL_VERSION, readString, sortDiagnostics, } from "@sonicfield/masa"; @@ -85,7 +86,7 @@ export function validateMatterRecord(value: unknown): ValidationResult( "MASA_SCHEMA_UNKNOWN", "", "The requested embedded schema is unknown", - "Use a key from SCHEMA_IDS or the exact $id of an embedded MASA 0.1.0 schema", + `Use a key from SCHEMA_IDS or the exact $id of an embedded MASA ${MASA_PROTOCOL_VERSION} schema`, ), ], }; @@ -152,7 +153,7 @@ export function validateDocument( "MASA_SCHEMA_UNKNOWN", "", "The requested embedded schema is unknown", - "Use a key from SCHEMA_IDS or the exact $id of an embedded MASA 0.1.0 schema", + `Use a key from SCHEMA_IDS or the exact $id of an embedded MASA ${MASA_PROTOCOL_VERSION} schema`, ), ], }; @@ -165,8 +166,9 @@ export function auditPublicRecord(value: unknown): ValidationResult( validation: ValidationResult, value: unknown, ): ValidationResult { - if (!isJsonObject(value) || value.masaVersion === undefined || value.masaVersion === "0.1.0") { + if ( + !isJsonObject(value) || + value.masaVersion === undefined || + value.masaVersion === MASA_PROTOCOL_VERSION + ) { return validation; } return { diff --git a/packages/validator/src/public-audit.ts b/packages/validator/src/public-audit.ts index 7db273a..e1389ec 100644 --- a/packages/validator/src/public-audit.ts +++ b/packages/validator/src/public-audit.ts @@ -1,9 +1,13 @@ import type { Diagnostic, MatterRecord } from "@sonicfield/masa"; import { asJsonArray, + escapeJsonPointerSegment, indexRecord, + isCredentialParameterKey, isJsonObject, - isPrivateHostname, + isNonPublicHostname, + isSecretConfigurationKey, + isUnsafeFilesystemLocator, readString, readStringArray, sortDiagnostics, @@ -12,11 +16,18 @@ import { import { diagnostic } from "./diagnostic.js"; -const SECRET_KEY = /^(?:access[_-]?token|api[_-]?key|api[_-]?secret|authorization|client[_-]?secret|cookie|credentials?|environment|password|private[_-]?endpoint|provider[_-]?(?:config|settings)|secret|token)$/i; const SECRET_VALUE = /(?:\bAKIA[A-Z0-9]{16}\b|\bAIza[A-Za-z0-9_-]{20,}\b|\bgh[pousr]_[A-Za-z0-9]{20,}\b|\bsk-[A-Za-z0-9_-]{16,}\b|\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b)/; -const ABSOLUTE_PATH = /^(?:~\/|\/(?:Applications|Users|Volumes|data|etc|home|media|mnt|opt|private|root|run|srv|tmp|usr|var)(?:\/|$)|[A-Za-z]:[\\/]|\\\\)/; const LOCATION_KEY = /^(?:lat|latitude|lon|lng|longitude)$/i; +const COORDINATES_KEY = /^coordinates$/i; const COORDINATE_STRING = /^[-+]?\d{1,3}(?:\.\d+)?$/; +const MATTER_RECORD_POINTER_ROOTS = new Set([ + "$schema", "@context", "masaVersion", "id", "type", "revision", "supersedes", + "profiles", "createdAt", "createdBy", "title", "description", "disclosure", + "registers", "scales", "actors", "sources", "representations", "encounters", + "apertures", "listeningPasses", "claims", "measurements", "regions", + "observations", "mappings", "relations", "policies", "contexts", "agentRuns", + "capabilities", "publication", "integrity", "history", "extensions", +]); export function auditPublicSafety(record: MatterRecord): Diagnostic[] { const diagnostics: Diagnostic[] = []; @@ -64,7 +75,7 @@ export function auditPublicSafety(record: MatterRecord): Diagnostic[] { } walkJson(raw, ({ value, instancePath, parent, key }) => { - if (typeof key === "string" && SECRET_KEY.test(key)) { + if (typeof key === "string" && isSecretConfigurationKey(key)) { diagnostics.push( diagnostic( "MASA_PUBLIC_SECRET", @@ -90,7 +101,7 @@ export function auditPublicSafety(record: MatterRecord): Diagnostic[] { ); } - if (key === "coordinates" && Array.isArray(value) && value.some((item) => typeof item === "number")) { + if (typeof key === "string" && COORDINATES_KEY.test(key) && containsNumericCoordinate(value)) { diagnostics.push( diagnostic( "MASA_PUBLIC_PRECISE_LOCATION", @@ -109,7 +120,7 @@ export function auditPublicSafety(record: MatterRecord): Diagnostic[] { diagnostics.push( diagnostic( "MASA_PUBLIC_EXTENSION", - `${instancePath}/${extensionKey}`, + `${instancePath}/${escapeJsonPointerSegment(extensionKey)}`, "A public projection contains an extension namespace that was not approved", "Remove the extension or record exact namespace approval in the Publication object", ), @@ -303,7 +314,9 @@ function auditPublication( } function auditPublicString(value: string, instancePath: string, diagnostics: Diagnostic[]): void { - if (ABSOLUTE_PATH.test(value) || value.startsWith("file:")) { + const validNoticePointer = + isPublicationNoticePointer(instancePath) && isMatterRecordJsonPointer(value); + if (!validNoticePointer && isUnsafeFilesystemLocator(value)) { diagnostics.push( diagnostic( "MASA_PUBLIC_PATH", @@ -326,7 +339,7 @@ function auditPublicString(value: string, instancePath: string, diagnostics: Dia let url: URL; try { - url = new URL(value); + url = new URL(value.trim()); } catch { return; } @@ -340,7 +353,7 @@ function auditPublicString(value: string, instancePath: string, diagnostics: Dia ), ); } - if ([...url.searchParams.keys()].some((key) => /key|password|secret|signature|token/i.test(key))) { + if (hasCredentialParameter(url)) { diagnostics.push( diagnostic( "MASA_PUBLIC_SECRET", @@ -350,14 +363,48 @@ function auditPublicString(value: string, instancePath: string, diagnostics: Dia ), ); } - if (isPrivateHostname(url.hostname)) { + if (url.hostname !== "" && isNonPublicHostname(url.hostname)) { diagnostics.push( diagnostic( "MASA_PUBLIC_PRIVATE_ENDPOINT", instancePath, - "A public projection contains a loopback or private-network endpoint", - "Remove the endpoint or replace it with an intentionally public service locator", + "A public projection contains a loopback, private, reserved, or otherwise non-public endpoint", + "Remove the endpoint or replace it with an intentionally public service locator, then enforce DNS and network policy at connection time", ), ); } } + +function containsNumericCoordinate(value: unknown): boolean { + if (!Array.isArray(value)) return false; + const pending: unknown[] = [...value]; + while (pending.length > 0) { + const candidate = pending.pop(); + if (typeof candidate === "number" && Number.isFinite(candidate)) return true; + if (Array.isArray(candidate)) { + for (const item of candidate) pending.push(item); + } + } + return false; +} + +function hasCredentialParameter(url: URL): boolean { + const parameterNames = [...url.searchParams.keys()]; + if (url.hash.length > 1) { + parameterNames.push(...new URLSearchParams(url.hash.slice(1)).keys()); + } + return parameterNames.some(isCredentialParameterKey); +} + +function isPublicationNoticePointer(instancePath: string): boolean { + return /^\/publication\/(?:omissions|redactions)\/[0-9]+\/pointer$/u.test(instancePath); +} + +function isMatterRecordJsonPointer(value: string): boolean { + if (!value.startsWith("/") || value.includes("\\") || /~(?:[^01]|$)/u.test(value)) { + return false; + } + const encodedRoot = value.slice(1).split("/", 1)[0] ?? ""; + const root = encodedRoot.replace(/~1/gu, "/").replace(/~0/gu, "~"); + return MATTER_RECORD_POINTER_ROOTS.has(root); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08c5cfb..251da45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: workspace:* version: link:packages/validator '@types/node': - specifier: 24.3.0 - version: 24.3.0 + specifier: 24.13.3 + version: 24.13.3 '@vitest/coverage-v8': specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) @@ -36,17 +36,17 @@ importers: specifier: 15.0.4 version: 15.0.4 publint: - specifier: 0.3.22 - version: 0.3.22 + specifier: 0.3.23 + version: 0.3.23 tsx: - specifier: 4.23.1 - version: 4.23.1 + specifier: 4.23.11 + version: 4.23.11 typescript: specifier: 7.0.2 version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.3.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.3.0)(esbuild@0.28.1)(tsx@4.23.1)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)) cli: dependencies: @@ -61,14 +61,14 @@ importers: version: link:../packages/validator devDependencies: '@types/node': - specifier: 24.3.0 - version: 24.3.0 + specifier: 24.13.3 + version: 24.13.3 typescript: specifier: 7.0.2 version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.3.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.3.0)(esbuild@0.28.1)(tsx@4.23.1)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)) mcp: dependencies: @@ -89,14 +89,14 @@ importers: version: 4.4.3 devDependencies: '@types/node': - specifier: 24.3.0 - version: 24.3.0 + specifier: 24.13.3 + version: 24.13.3 typescript: specifier: 7.0.2 version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.3.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.3.0)(esbuild@0.28.1)(tsx@4.23.1)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)) packages/bundle: dependencies: @@ -488,11 +488,8 @@ packages: '@types/lodash@4.17.25': resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} - '@types/node@24.3.0': - resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==} - - '@types/node@26.2.0': - resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} '@types/yauzl@3.4.0': resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==} @@ -1139,8 +1136,8 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - publint@0.3.22: - resolution: {integrity: sha512-6Z/scsr5CA7APdwyF35EY88CqgDj1textWuY788DVTJYPCWVv/Wn9G6KmLnrVRnStgYcahqN4wCDLZGSbQJ69w==} + publint@0.3.23: + resolution: {integrity: sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ==} engines: {node: '>=18'} hasBin: true @@ -1259,8 +1256,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.23.11: + resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==} engines: {node: '>=18.0.0'} hasBin: true @@ -1273,11 +1270,8 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - undici-types@7.10.0: - resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} - - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -1637,21 +1631,17 @@ snapshots: '@types/lodash@4.17.25': {} - '@types/node@24.3.0': + '@types/node@24.13.3': dependencies: - undici-types: 7.10.0 - - '@types/node@26.2.0': - dependencies: - undici-types: 8.3.0 + undici-types: 7.18.2 '@types/yauzl@3.4.0': dependencies: - '@types/node': 26.2.0 + '@types/node': 24.13.3 '@types/yazl@3.3.1': dependencies: - '@types/node': 26.2.0 + '@types/node': 24.13.3 '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1725,7 +1715,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@24.3.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.3.0)(esbuild@0.28.1)(tsx@4.23.1)) + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)) '@vitest/expect@4.1.10': dependencies: @@ -1736,13 +1726,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.3.0)(esbuild@0.28.1)(tsx@4.23.1))': + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@24.3.0)(esbuild@0.28.1)(tsx@4.23.1) + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11) '@vitest/pretty-format@4.1.10': dependencies: @@ -2218,7 +2208,7 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - publint@0.3.22: + publint@0.3.23: dependencies: '@publint/pack': 0.1.6 package-manager-detector: 1.8.0 @@ -2371,7 +2361,7 @@ snapshots: tslib@2.8.1: optional: true - tsx@4.23.1: + tsx@4.23.11: dependencies: esbuild: 0.28.1 optionalDependencies: @@ -2406,15 +2396,13 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@7.10.0: {} - - undici-types@8.3.0: {} + undici-types@7.18.2: {} unpipe@1.0.0: {} vary@1.1.2: {} - vite@8.1.5(@types/node@24.3.0)(esbuild@0.28.1)(tsx@4.23.1): + vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -2422,15 +2410,15 @@ snapshots: rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 24.13.3 esbuild: 0.28.1 fsevents: 2.3.3 - tsx: 4.23.1 + tsx: 4.23.11 - vitest@4.1.10(@types/node@24.3.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.3.0)(esbuild@0.28.1)(tsx@4.23.1)): + vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.3.0)(esbuild@0.28.1)(tsx@4.23.1)) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2447,10 +2435,10 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.1.5(@types/node@24.3.0)(esbuild@0.28.1)(tsx@4.23.1) + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.11) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 24.13.3 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/scripts/canonical.ts b/scripts/canonical.ts index 38ee2ea..c31ad4a 100644 --- a/scripts/canonical.ts +++ b/scripts/canonical.ts @@ -4,8 +4,9 @@ * verifies them, so a host migration is a one-line change here plus a * regeneration rather than a scattered manual rename. */ +export const MASA_PROTOCOL_VERSION = "0.1.0"; export const CANONICAL_ROOT = "https://masa.sonicfield.org/"; -export const SCHEMA_ID_PREFIX = `${CANONICAL_ROOT}schemas/0.1.0/`; -export const CAPABILITY_ID_PREFIX = `${CANONICAL_ROOT}capabilities/0.1.0/`; +export const SCHEMA_ID_PREFIX = `${CANONICAL_ROOT}schemas/${MASA_PROTOCOL_VERSION}/`; +export const CAPABILITY_ID_PREFIX = `${CANONICAL_ROOT}capabilities/${MASA_PROTOCOL_VERSION}/`; export const IMPLEMENTATION_ID_PREFIX = `${CANONICAL_ROOT}implementations/`; -export const VOCAB_NAMESPACE = `${CANONICAL_ROOT}vocab/0.1.0#`; +export const VOCAB_NAMESPACE = `${CANONICAL_ROOT}vocab/${MASA_PROTOCOL_VERSION}#`; diff --git a/scripts/check-schemas.ts b/scripts/check-schemas.ts index 12fdefb..af0c103 100644 --- a/scripts/check-schemas.ts +++ b/scripts/check-schemas.ts @@ -3,10 +3,10 @@ import { join, relative, resolve, sep } from "node:path"; import Ajv2020 from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; -import { SCHEMA_ID_PREFIX, VOCAB_NAMESPACE } from "./canonical.js"; +import { MASA_PROTOCOL_VERSION, SCHEMA_ID_PREFIX, VOCAB_NAMESPACE } from "./canonical.js"; const root = resolve(import.meta.dirname, ".."); -const schemaRoot = join(root, "schemas", "0.1.0"); +const schemaRoot = join(root, "schemas", MASA_PROTOCOL_VERSION); const EXPECTED_SCHEMA_COUNT = 29; async function filesBelow(directory: string): Promise { @@ -40,7 +40,7 @@ const schemas = await Promise.all( const ids = new Map(); for (const { path, schema } of schemas) { if (typeof schema.$id !== "string" || !schema.$id.startsWith(SCHEMA_ID_PREFIX)) { - throw new Error(`${relative(root, path)} has no stable MASA 0.1.0 $id.`); + throw new Error(`${relative(root, path)} has no stable MASA ${MASA_PROTOCOL_VERSION} $id.`); } if (ids.has(schema.$id)) throw new Error(`Duplicate schema $id ${schema.$id}.`); ids.set(schema.$id, path); @@ -100,14 +100,14 @@ function schemaUriReferences(value: unknown): string[] { return found; } -const termRegistry = await validateArtifact("term-registry", join(root, "ontology", "0.1.0", "terms.json")); -const relationRegistry = await validateArtifact("relation-registry", join(root, "ontology", "0.1.0", "relations.json")); -const capabilityCatalog = await validateArtifact("capability-set", join(root, "capabilities", "0.1.0", "reference.json")); -const jsonldContext = JSON.parse(await readFile(join(root, "contexts", "0.1.0", "masa.jsonld"), "utf8")) as Record; +const termRegistry = await validateArtifact("term-registry", join(root, "ontology", MASA_PROTOCOL_VERSION, "terms.json")); +const relationRegistry = await validateArtifact("relation-registry", join(root, "ontology", MASA_PROTOCOL_VERSION, "relations.json")); +const capabilityCatalog = await validateArtifact("capability-set", join(root, "capabilities", MASA_PROTOCOL_VERSION, "reference.json")); +const jsonldContext = JSON.parse(await readFile(join(root, "contexts", MASA_PROTOCOL_VERSION, "masa.jsonld"), "utf8")) as Record; const contextBody = jsonldContext["@context"]; if (contextBody === null || typeof contextBody !== "object" || Array.isArray(contextBody)) { - throw new Error("contexts/0.1.0/masa.jsonld must declare one @context object."); + throw new Error(`contexts/${MASA_PROTOCOL_VERSION}/masa.jsonld must declare one @context object.`); } if ((contextBody as Record).masa !== VOCAB_NAMESPACE) { throw new Error("The JSON-LD masa prefix must equal the canonical vocabulary namespace."); @@ -122,10 +122,10 @@ for (const registry of [termRegistry, relationRegistry]) { // registered schema $id, so the catalog cannot silently outlive a migration. for (const uri of schemaUriReferences(capabilityCatalog)) { if (uri.startsWith(SCHEMA_ID_PREFIX) && !ids.has(uri)) { - throw new Error(`capabilities/0.1.0/reference.json names an unregistered schema URI: ${uri}`); + throw new Error(`capabilities/${MASA_PROTOCOL_VERSION}/reference.json names an unregistered schema URI: ${uri}`); } if (!uri.startsWith(SCHEMA_ID_PREFIX)) { - throw new Error(`capabilities/0.1.0/reference.json names a non-canonical schema URI: ${uri}`); + throw new Error(`capabilities/${MASA_PROTOCOL_VERSION}/reference.json names a non-canonical schema URI: ${uri}`); } } diff --git a/scripts/generate-artifacts.ts b/scripts/generate-artifacts.ts index ff9671d..29b546f 100644 --- a/scripts/generate-artifacts.ts +++ b/scripts/generate-artifacts.ts @@ -4,10 +4,11 @@ import { fileURLToPath } from "node:url"; import { compile } from "json-schema-to-typescript"; -import { SCHEMA_ID_PREFIX } from "./canonical.js"; +import { MASA_PROTOCOL_VERSION, SCHEMA_ID_PREFIX } from "./canonical.js"; +import { referenceImplementationVersion } from "./release-version.js"; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); -const SCHEMA_DIR = join(ROOT, "schemas", "0.1.0"); +const SCHEMA_DIR = join(ROOT, "schemas", MASA_PROTOCOL_VERSION); const CORE_GENERATED_DIR = join(ROOT, "packages", "core", "src", "generated"); const VALIDATOR_GENERATED_DIR = join(ROOT, "packages", "validator", "src", "generated"); @@ -26,11 +27,12 @@ const TYPE_TARGETS = [ const TYPE_BANNER = `/* eslint-disable */ /** - * Generated from the normative MASA 0.1.0 JSON Schemas. + * Generated from the normative MASA ${MASA_PROTOCOL_VERSION} JSON Schemas. * Do not edit this file. Run pnpm generate after changing schemas. */`; async function main(): Promise { + const implementationVersion = await referenceImplementationVersion(); await Promise.all([ mkdir(CORE_GENERATED_DIR, { recursive: true }), mkdir(VALIDATOR_GENERATED_DIR, { recursive: true }), @@ -68,11 +70,22 @@ async function main(): Promise { } await writeIfChanged(join(CORE_GENERATED_DIR, "index.ts"), generatedTypeIndex()); + await writeIfChanged( + join(CORE_GENERATED_DIR, "version.ts"), + generatedVersionModule(implementationVersion), + ); await writeIfChanged(join(CORE_GENERATED_DIR, "protocol-resources.ts"), await generatedProtocolResources()); await writeIfChanged(join(VALIDATOR_GENERATED_DIR, "schemas.ts"), generatedSchemaRegistry(schemas)); await writeIfChanged(join(CORE_GENERATED_DIR, "ontology.ts"), await generatedRelationRegistry()); } +function generatedVersionModule(implementationVersion: string): string { + return `/** Generated from synchronized MASA workspace package metadata. */ +export const MASA_PROTOCOL_VERSION = ${JSON.stringify(MASA_PROTOCOL_VERSION)}; +export const MASA_REFERENCE_IMPLEMENTATION_VERSION = ${JSON.stringify(implementationVersion)}; +`; +} + async function readJsonSchema(path: string): Promise { const source = await readFile(path, "utf8"); const parsed = JSON.parse(source) as unknown; @@ -118,14 +131,14 @@ async function generatedProtocolResources(): Promise { const resources = await Promise.all([ { name: "masa-core", - uri: "masa://spec/0.1.0/core", - path: join(ROOT, "spec", "0.1.0", "core.md"), + uri: `masa://spec/${MASA_PROTOCOL_VERSION}/core`, + path: join(ROOT, "spec", MASA_PROTOCOL_VERSION, "core.md"), mimeType: "text/markdown", }, { name: "masa-vocabulary", - uri: "masa://spec/0.1.0/vocabulary", - path: join(ROOT, "spec", "0.1.0", "vocabulary.md"), + uri: `masa://spec/${MASA_PROTOCOL_VERSION}/vocabulary`, + path: join(ROOT, "spec", MASA_PROTOCOL_VERSION, "vocabulary.md"), mimeType: "text/markdown", }, ].map(async (resource) => ({ @@ -135,7 +148,7 @@ async function generatedProtocolResources(): Promise { text: await readFile(resource.path, "utf8"), }))); const capabilitySet = JSON.parse( - await readFile(join(ROOT, "capabilities", "0.1.0", "reference.json"), "utf8"), + await readFile(join(ROOT, "capabilities", MASA_PROTOCOL_VERSION, "reference.json"), "utf8"), ) as unknown; return `${TYPE_BANNER} @@ -179,7 +192,7 @@ export function findEmbeddedSchema(id: string): EmbeddedSchema | undefined { } async function generatedRelationRegistry(): Promise { - const registryPath = join(ROOT, "ontology", "0.1.0", "relations.json"); + const registryPath = join(ROOT, "ontology", MASA_PROTOCOL_VERSION, "relations.json"); const source = await readFile(registryPath, "utf8"); const parsed = JSON.parse(source) as { relations?: Array<{ diff --git a/scripts/generate-capabilities.ts b/scripts/generate-capabilities.ts index deacaab..b082577 100644 --- a/scripts/generate-capabilities.ts +++ b/scripts/generate-capabilities.ts @@ -1,9 +1,19 @@ import { mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { CAPABILITY_ID_PREFIX, IMPLEMENTATION_ID_PREFIX, SCHEMA_ID_PREFIX } from "./canonical.js"; +import { + CAPABILITY_ID_PREFIX, + IMPLEMENTATION_ID_PREFIX, + MASA_PROTOCOL_VERSION, + SCHEMA_ID_PREFIX, +} from "./canonical.js"; +import { + REFERENCE_IMPLEMENTATION_GENERATED_AT, + referenceImplementationVersion, +} from "./release-version.js"; -const output = resolve(import.meta.dirname, "..", "capabilities", "0.1.0", "reference.json"); +const implementationVersion = await referenceImplementationVersion(); +const output = resolve(import.meta.dirname, "..", "capabilities", MASA_PROTOCOL_VERSION, "reference.json"); const resultSchema = `${SCHEMA_ID_PREFIX}tools/tool-result.schema.json`; function capability( @@ -17,7 +27,7 @@ function capability( type: "masa:Capability", name, purpose, - supportedMasaVersions: ["0.1.0"], + supportedMasaVersions: [MASA_PROTOCOL_VERSION], profiles: overrides.profiles ?? ["core"], inputSchema: { state: "known", uri: input }, outputSchema: { state: "known", uri: resultSchema }, @@ -44,15 +54,15 @@ function capability( const catalog = { capabilitySetType: "masa-capability-set", - capabilitySetVersion: "0.1.0", - masaVersion: "0.1.0", + capabilitySetVersion: MASA_PROTOCOL_VERSION, + masaVersion: MASA_PROTOCOL_VERSION, id: `${CAPABILITY_ID_PREFIX}reference`, implementation: { - id: `${IMPLEMENTATION_ID_PREFIX}typescript-reference/0.1.0`, + id: `${IMPLEMENTATION_ID_PREFIX}typescript-reference/${implementationVersion}`, name: "MASA local TypeScript reference", - version: "0.1.0" + version: implementationVersion }, - generatedAt: "2026-07-27T00:00:00Z", + generatedAt: REFERENCE_IMPLEMENTATION_GENERATED_AT, capabilities: [ capability( "matter.validate", diff --git a/scripts/generate-examples.ts b/scripts/generate-examples.ts index d7954cc..8ad62c9 100644 --- a/scripts/generate-examples.ts +++ b/scripts/generate-examples.ts @@ -2,13 +2,13 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { createHash } from "node:crypto"; -import { CANONICAL_ROOT, SCHEMA_ID_PREFIX } from "./canonical.js"; +import { CANONICAL_ROOT, MASA_PROTOCOL_VERSION, SCHEMA_ID_PREFIX } from "./canonical.js"; type JsonObject = Record; const root = resolve(import.meta.dirname, ".."); -const validDirectory = join(root, "examples", "0.1.0", "valid"); -const invalidDirectory = join(root, "examples", "0.1.0", "invalid"); +const validDirectory = join(root, "examples", MASA_PROTOCOL_VERSION, "valid"); +const invalidDirectory = join(root, "examples", MASA_PROTOCOL_VERSION, "invalid"); const minimalPath = join(validDirectory, "minimal-record.masa.json"); const minimal = JSON.parse(await readFile(minimalPath, "utf8")) as JsonObject; @@ -443,7 +443,7 @@ agent.capabilities = [ type: "masa:Capability", name: "matter.validate", purpose: "Validate one record inside a host-configured local root without writing or using the network.", - supportedMasaVersions: ["0.1.0"], + supportedMasaVersions: [MASA_PROTOCOL_VERSION], profiles: ["core", "agent"], inputSchema: { state: "known", uri: `${SCHEMA_ID_PREFIX}matter-record.schema.json` }, outputSchema: { state: "known", uri: `${SCHEMA_ID_PREFIX}conformance-result.schema.json` }, @@ -601,7 +601,7 @@ publication.publication = { redactions: [], attribution: known("MASA protocol example"), license: known("MIT for protocol fixture metadata; no media is included"), - retention: known("Retain while protocol 0.1.0 remains supported"), + retention: known(`Retain while protocol ${MASA_PROTOCOL_VERSION} remains supported`), correctionUrl: `${CANONICAL_ROOT}corrections`, revocationUrl: `${CANONICAL_ROOT}revocations`, approvedExtensionNamespaces: [] @@ -665,7 +665,7 @@ deletedState.history = { }; await save(validDirectory, "deleted-state.masa.json", deletedState); -const bundleDirectory = join(root, "examples", "0.1.0", "bundles", "transformation.masa"); +const bundleDirectory = join(root, "examples", MASA_PROTOCOL_VERSION, "bundles", "transformation.masa"); const bundleRecord = structuredClone(transformation) as JsonObject; const bundledReceipt = bundleRecord.history.events[0] as JsonObject; bundleRecord.history = { mode: "external", href: "events.ndjson", eventIds: [bundledReceipt.id] }; @@ -678,8 +678,8 @@ await writeIfChanged(join(bundleDirectory, recordPath), recordText); await writeIfChanged(join(bundleDirectory, "events.ndjson"), eventText); const bundleManifest = { manifestType: "masa-bundle", - manifestVersion: "0.1.0", - masaVersion: "0.1.0", + manifestVersion: MASA_PROTOCOL_VERSION, + masaVersion: MASA_PROTOCOL_VERSION, id: "urn:uuid:00000000-0000-4000-8000-000000000b01", createdAt: "2026-07-27T12:11:00Z", createdBy: actorId, diff --git a/scripts/inspect-package-artifacts.ts b/scripts/inspect-package-artifacts.ts index a6afccf..e24ee72 100644 --- a/scripts/inspect-package-artifacts.ts +++ b/scripts/inspect-package-artifacts.ts @@ -3,8 +3,13 @@ import { readFile } from "node:fs/promises"; import { promisify } from "node:util"; import { join, resolve } from "node:path"; +import { MASA_PROTOCOL_VERSION } from "./canonical.js"; + const execute = promisify(execFile); const root = resolve(import.meta.dirname, ".."); +const pnpmExecutable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm"; +const pnpmArguments = (arguments_: string[]): string[] => + process.platform === "win32" ? ["/d", "/c", "pnpm", ...arguments_] : arguments_; const expectedLicense = (await readFile(join(root, "LICENSE"), "utf8")).trimEnd(); const packages = ["packages/core", "packages/validator", "packages/bundle", "cli", "mcp"]; const prohibitedPath = /(?:^|\/)(?:node_modules|src)(?:\/|$)|\.tsbuildinfo$|\.test\.(?:js|d\.ts)(?:\.map)?$/u; @@ -28,8 +33,10 @@ for (const packagePath of packages) { private?: unknown; name?: unknown; }; - if (packageJson.private !== true) throw new Error(`${packagePath} must remain private in MASA 0.1.0.`); - const { stdout } = await execute("pnpm", ["pack", "--dry-run", "--json"], { + if (packageJson.private !== true) { + throw new Error(`${packagePath} must remain private for MASA ${MASA_PROTOCOL_VERSION}.`); + } + const { stdout } = await execute(pnpmExecutable, pnpmArguments(["pack", "--dry-run", "--json"]), { cwd: directory, maxBuffer: 16 * 1024 * 1024 }); diff --git a/scripts/release-version.ts b/scripts/release-version.ts new file mode 100644 index 0000000..0df96de --- /dev/null +++ b/scripts/release-version.ts @@ -0,0 +1,50 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const PACKAGE_DIRECTORIES = [ + "", + "packages/core", + "packages/validator", + "packages/bundle", + "cli", + "mcp", +] as const; + +/** + * Deterministic timestamp for the current reference-tooling release. Keep it + * explicit so generation never depends on the wall clock or developer locale. + */ +export const REFERENCE_IMPLEMENTATION_GENERATED_AT = "2026-08-09T00:00:00.000Z"; + +/** + * Read the reference-tooling release from package metadata and fail generation + * if any packable workspace has drifted from it. + */ +export async function referenceImplementationVersion(): Promise { + const packages = await Promise.all( + PACKAGE_DIRECTORIES.map(async (directory) => { + const path = join(ROOT, directory, "package.json"); + const document = JSON.parse(await readFile(path, "utf8")) as { name?: unknown; version?: unknown }; + if ( + typeof document.name !== "string" || + typeof document.version !== "string" || + !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(document.version) + ) { + throw new Error(`${directory || "."}/package.json has invalid release metadata.`); + } + return { directory: directory || ".", name: document.name, version: document.version }; + }), + ); + const expected = packages[0]!.version; + const mismatched = packages.filter(({ version }) => version !== expected); + if (mismatched.length > 0) { + throw new Error( + `MASA reference package versions must match ${expected}: ${mismatched + .map(({ directory, version }) => `${directory}=${version}`) + .join(", ")}`, + ); + } + return expected; +} diff --git a/scripts/smoke-package-artifacts.ts b/scripts/smoke-package-artifacts.ts index 5d0b78f..d8df718 100644 --- a/scripts/smoke-package-artifacts.ts +++ b/scripts/smoke-package-artifacts.ts @@ -4,9 +4,17 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; +import { MASA_PROTOCOL_VERSION } from "./canonical.js"; +import { referenceImplementationVersion } from "./release-version.js"; + const execute = promisify(execFile); const root = resolve(import.meta.dirname, ".."); +const pnpmExecutable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm"; +const pnpmArguments = (arguments_: string[]): string[] => + process.platform === "win32" ? ["/d", "/c", "pnpm", ...arguments_] : arguments_; const expectedLicense = (await readFile(join(root, "LICENSE"), "utf8")).trimEnd(); +const implementationVersion = await referenceImplementationVersion(); +const expectedHelpBanner = `MASA local CLI ${implementationVersion} (protocol ${MASA_PROTOCOL_VERSION})`; const temporary = await mkdtemp(join(tmpdir(), "masa-packed-smoke-")); const packages = [ @@ -21,7 +29,7 @@ try { const modules = join(temporary, "node_modules"); for (const item of packages) { const archive = join(temporary, item.archive); - await execute("pnpm", ["pack", "--out", archive], { + await execute(pnpmExecutable, pnpmArguments(["pack", "--out", archive]), { cwd: join(root, item.directory), maxBuffer: 16 * 1024 * 1024, }); @@ -54,7 +62,9 @@ try { const smoke = ` const core = await import("@sonicfield/masa"); - if (!core.protocolResources.some((item) => item.uri === "masa://spec/0.1.0/core")) throw new Error("core resource missing"); + if (!core.protocolResources.some((item) => item.uri === ${JSON.stringify(`masa://spec/${MASA_PROTOCOL_VERSION}/core`)})) throw new Error("core resource missing"); + if (core.MASA_PROTOCOL_VERSION !== ${JSON.stringify(MASA_PROTOCOL_VERSION)}) throw new Error("protocol version missing"); + if (core.MASA_REFERENCE_IMPLEMENTATION_VERSION !== ${JSON.stringify(implementationVersion)}) throw new Error("implementation version missing"); if (!core.referenceCapabilitySet.capabilities.some((item) => item.name === "matter.trace_lineage")) throw new Error("capability missing"); if (!core.referenceCapabilitySet.capabilities.some((item) => item.name === "matter.plan_processing")) throw new Error("processing capability missing"); const validator = await import("@sonicfield/masa-validator"); @@ -86,7 +96,7 @@ try { cwd: temporary, maxBuffer: 1024 * 1024, }); - if (!helpRun.stdout.includes("MASA 0.1.0 local CLI")) { + if (!helpRun.stdout.includes(expectedHelpBanner)) { throw new Error("The symlinked CLI bin produced no usage output; the ESM entry guard regressed."); } diff --git a/scripts/validate-examples.ts b/scripts/validate-examples.ts index 9622276..61619b3 100644 --- a/scripts/validate-examples.ts +++ b/scripts/validate-examples.ts @@ -1,11 +1,16 @@ import { readFile, readdir } from "node:fs/promises"; import { basename, join, relative, resolve } from "node:path"; -import { parseJsonStrict, StrictJsonError, type Diagnostic } from "@sonicfield/masa"; +import { + MASA_PROTOCOL_VERSION, + parseJsonStrict, + StrictJsonError, + type Diagnostic, +} from "@sonicfield/masa"; import { inspectBundle } from "@sonicfield/masa-bundle"; import { auditPublicRecord, validateMatterRecord } from "@sonicfield/masa-validator"; const root = resolve(import.meta.dirname, ".."); -const examples = join(root, "examples", "0.1.0"); +const examples = join(root, "examples", MASA_PROTOCOL_VERSION); const EXPECTED_VALID_COUNT = 9;