diff --git a/README.md b/README.md index 4e7944c..30773af 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,31 @@ npm run build The importer refuses to overwrite differing existing versioned artifacts. Publish this catalog before consumers request its new immutable URLs. +## Configuration setup carrier + +A catalog pack may declare `setup.configuration.requirements` and +`setup.configuration.provisions`: the engine's existing +`ConfigurationRequirement` (`contains`) and `ConfigurationProvision` (`set_add`) +arrays. The published provision carries `setup` alongside `manifest`, never +inside its strict `mdbase.type-pack` manifest. `scripts/configuration-setup.mjs` +validates the carrier and lifts its arrays into `CollectionSetup.requirements` +and `CollectionSetup.provisions`; the installer supplies the registered +application identity/declaration digest and nested type-pack provisions. + +Installers must assess and apply that **whole envelope** through Core/replica, +with the reviewed assessment digest and collection revision. They must not +ignore `setup`, merge collection YAML themselves, or install its type pack in a +separate mutation. Configuration and both receipts commit atomically. Existing +sequence order/values and implicit `md` remain; uninstall does not subtract +contributions. Only `/settings/record_extensions` is an exception to the +existing `x-*` pointer confinement; other settings remain forbidden. + +`obsidian.base` **1.0.1** is an unlisted candidate carrying the explicit `base` +record-extension requirement/provision. Version 1.0.0 and all contract/type +bytes stay unchanged. List the candidate only after the catalog installer and +Core/replica CollectionSetup path are qualified; the existing type-pack install +verification is not evidence for configuration setup execution. + ## Publishing `mdbase.dev` pins a reviewed commit of this repository, builds it, and copies diff --git a/dist/packs/obsidian.base/1.0.1.json b/dist/packs/obsidian.base/1.0.1.json new file mode 100644 index 0000000..484d413 --- /dev/null +++ b/dist/packs/obsidian.base/1.0.1.json @@ -0,0 +1,62 @@ +{ + "manifest": { + "kind": "mdbase.type-pack", + "id": "obsidian.base", + "version": "1.0.1", + "name": "Obsidian Bases", + "description": "The obsidian.base contract and an editable type that stores Obsidian Bases as records.", + "resources": [ + { + "kind": "contract", + "mode": "managed", + "source": "contracts/obsidian.base/1.0.0.md", + "target": "_contracts/obsidian.base/1.0.0.md", + "digest": "sha256:fca77a9c845223784d4af7f5e13e2f149295621ad1b794ea9bb4d99f604467e8" + }, + { + "kind": "type", + "mode": "seed", + "source": "types/obsidian-base/1.md", + "target": "_types/obsidian_base.md", + "digest": "sha256:bfeb3880200bc1bef7fd50b777d3f927ce931d08b4fb3c30d4cddb55feaf3f92" + } + ] + }, + "resources": [ + { + "source": "contracts/obsidian.base/1.0.0.md", + "document": "---\nkind: mdbase.contract\ncontract_type: record\nid: obsidian.base\nversion: 1.0.0\nname: Obsidian Base\ndescription: An Obsidian Bases saved-view source stored as a YAML document record.\nrecord_schema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n type: object\n required: [views]\n properties:\n filters: {}\n formulas: { type: object }\n properties: { type: object }\n views:\n type: array\n minItems: 1\n items: { type: object, required: [type], properties: { type: { type: string }, name: { type: string } } }\n---\n\n# Obsidian Base\n\nA record exposed through `obsidian.base` is an Obsidian Bases source: global\nfilters, formulas, property metadata, and one or more views. The schema is\ndeliberately permissive. Obsidian owns the format and adds keys over time;\nunknown keys are preserved and ignored.\n\nTools that advertise `obsidian_bases_views` discover and execute these records\nthrough this contract and evaluate them with the Obsidian Bases expression\ndialect described in the [Obsidian Bases adapter](https://mdbase.dev/spec/adapters/obsidian-bases).\n\nThis artifact is passive. Implementing it grants no authority to read, execute,\nor modify any record.\n" + }, + { + "source": "types/obsidian-base/1.md", + "document": "---\nkind: mdbase.type\nname: obsidian_base\nversion: 1\ndescription: Obsidian Bases saved views stored as records\nmatch:\n path_glob: '**/*.base'\nschema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n title: Obsidian Base\n description: An Obsidian Bases file. Obsidian owns the format; unknown keys are kept.\n type: object\n required:\n - views\n properties:\n filters: {}\n formulas:\n type: object\n properties:\n type: object\n views:\n type: array\n minItems: 1\n items:\n type: object\n required:\n - type\n properties:\n type:\n type: string\n name:\n type: string\nimplements:\n - contract: obsidian.base\n version: 1.0.0\n fields:\n filters: filters\n formulas: formulas\n properties: properties\n views: views\n---\n\n# Obsidian Base\n\nMakes `.base` files discoverable as saved views through the `obsidian.base`\ncontract. It takes effect when the collection lists `base` in\n`settings.record_extensions`; narrow the `path_glob` to the folders that hold\nyour Bases. Edit Bases with whole-document replacement so Obsidian's comments\nand layout survive.\n" + } + ], + "provides": [ + { + "id": "obsidian.base", + "version": "1.0.0", + "digest": "sha256:37e8a0bfb32bb4795c1b3c5dec613c32429aaa765dc33e4adc0c627a48f5c1ff" + } + ], + "setup": { + "configuration": { + "requirements": [ + { + "id": "obsidian-base-record-extension", + "path": "/settings/record_extensions", + "predicate": "contains", + "value": "base" + } + ], + "provisions": [ + { + "requirement": "obsidian-base-record-extension", + "operation": "set_add", + "path": "/settings/record_extensions", + "value": "base" + } + ] + } + } +} diff --git a/package.json b/package.json index fa01b6b..8467821 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "node scripts/build.mjs", "expand:type": "node scripts/expand-type.mjs", "verify": "node scripts/verify.mjs", - "test": "node --test scripts/schema-expansion.test.mjs scripts/person-contract.test.mjs scripts/comment-contract.test.mjs scripts/starter-type-keys.test.mjs && npm run build && npm run verify && node --test scripts/seed-baselines.test.mjs scripts/seed-upgrade.test.mjs scripts/person-pack.test.mjs scripts/tasknotes-upgrade.test.mjs scripts/tasknotes-plugin-collections.test.mjs" + "test": "node --test scripts/schema-expansion.test.mjs scripts/person-contract.test.mjs scripts/comment-contract.test.mjs scripts/starter-type-keys.test.mjs && npm run build && node --test scripts/configuration-setup.test.mjs && npm run verify && node --test scripts/seed-baselines.test.mjs scripts/seed-upgrade.test.mjs scripts/person-pack.test.mjs scripts/tasknotes-upgrade.test.mjs scripts/tasknotes-plugin-collections.test.mjs" }, "dependencies": { "ajv": "^8.20.0", diff --git a/packs/obsidian.base/1.0.1.pack.yaml b/packs/obsidian.base/1.0.1.pack.yaml new file mode 100644 index 0000000..213cc75 --- /dev/null +++ b/packs/obsidian.base/1.0.1.pack.yaml @@ -0,0 +1,44 @@ +kind: mdbase.catalog-pack +id: obsidian.base +version: 1.0.1 +name: Obsidian Bases +# Candidate: list when CollectionSetup installers can assess/apply this carrier. +catalog: false +description: The obsidian.base contract and an editable type that stores Obsidian Bases as records. +display: + name: Obsidian Bases + summary: Use Obsidian Bases files as saved views that any view-aware app can list and run. + category: work + audience: general + icon: table + badges: + - Obsidian compatible +installation: + visibility: default + recommendation: optional + primary_type: obsidian_base +setup: + configuration: + requirements: + - id: obsidian-base-record-extension + path: /settings/record_extensions + predicate: contains + value: base + provisions: + - requirement: obsidian-base-record-extension + operation: set_add + path: /settings/record_extensions + value: base +provides: + - id: obsidian.base + version: 1.0.0 + digest: sha256:37e8a0bfb32bb4795c1b3c5dec613c32429aaa765dc33e4adc0c627a48f5c1ff +resources: + - kind: contract + mode: managed + source: contracts/obsidian.base/1.0.0.md + target: _contracts/obsidian.base/1.0.0.md + - kind: type + mode: seed + source: types/obsidian-base/1.md + target: _types/obsidian_base.md diff --git a/scripts/build.mjs b/scripts/build.mjs index 2964c93..9327a08 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import matter from "gray-matter"; import { parse } from "yaml"; import { expandLocalReferences } from "./schema-expansion.mjs"; +import { validateConfigurationSetup } from "./configuration-setup.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const dist = join(root, "dist"); @@ -122,6 +123,7 @@ for (const packFile of packFiles) { }, resources, provides, + ...(definition.setup === undefined ? {} : { setup: definition.setup }), }; const provisionDocument = json(provision); const provisionPath = `packs/${definition.id}/${definition.version}.json`; @@ -337,6 +339,7 @@ function validateEditableType(source, document, requireExpandedReferences) { } function validatePackDefinition(value, label) { + validateConfigurationSetup(value?.setup); if (!value || typeof value !== "object" || value.kind !== "mdbase.catalog-pack") { fail(`${label} must be an mdbase.catalog-pack object.`); } diff --git a/scripts/configuration-setup.mjs b/scripts/configuration-setup.mjs new file mode 100644 index 0000000..1604679 --- /dev/null +++ b/scripts/configuration-setup.mjs @@ -0,0 +1,66 @@ +// Catalog transport for the engine's CollectionSetup configuration algebra. +// This module validates/lifts declarations only. It never merges collection YAML. +const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value); +const scalar = (value) => value === null || typeof value === "string" + || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value)); +const invalid = () => { throw new Error("invalid_collection_setup: invalid catalog configuration declaration"); }; + +function members(value, names) { + if (!object(value) || Object.keys(value).some((key) => !names.includes(key))) invalid(); + if (names.some((key) => !Object.hasOwn(value, key))) invalid(); +} + +function pointer(path) { + if (typeof path !== "string" || Buffer.byteLength(path) > 1024 || !path.startsWith("/")) invalid(); + const encoded = path.slice(1).split("/"); + if (encoded.length < 2 || encoded.length > 16) invalid(); + const segments = encoded.map((part) => { + if (/~(?![01])/u.test(part)) invalid(); + const decoded = part.replace(/~1/gu, "/").replace(/~0/gu, "~"); + if (!decoded || decoded === "-" || /^\d+$/u.test(decoded) || /\p{Cc}/u.test(decoded)) invalid(); + return decoded; + }); + if (path !== "/settings/record_extensions" && !/^x-[a-z0-9][a-z0-9-]*$/u.test(segments[0])) invalid(); +} + +/** Validate optional setup.configuration: the existing requirement/provision arrays. */ +export function validateConfigurationSetup(setup) { + if (setup === undefined) return; + members(setup, ["configuration"]); + const { requirements, provisions } = setup.configuration ?? {}; + members(setup.configuration, ["requirements", "provisions"]); + if (!Array.isArray(requirements) || !Array.isArray(provisions) + || requirements.length > 128 || provisions.length > 128) invalid(); + const indexed = new Map(); + for (const requirement of requirements) { + members(requirement, ["id", "path", "predicate", "value"]); + if (typeof requirement.id !== "string" || requirement.id.length > 128 + || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(requirement.id) + || requirement.predicate !== "contains" || !scalar(requirement.value)) invalid(); + pointer(requirement.path); + if (requirement.path === "/settings/record_extensions" + && (typeof requirement.value !== "string" || !/^[a-z0-9][a-z0-9_-]*$/u.test(requirement.value))) invalid(); + if (indexed.has(requirement.id)) invalid(); + indexed.set(requirement.id, requirement); + } + const linked = new Set(); + for (const provision of provisions) { + members(provision, ["requirement", "operation", "path", "value"]); + const requirement = indexed.get(provision.requirement); + if (provision.operation !== "set_add" || !scalar(provision.value) + || !requirement || provision.path !== requirement.path || provision.value !== requirement.value + || linked.has(provision.requirement)) invalid(); + linked.add(provision.requirement); + } + if (linked.size !== indexed.size) invalid(); +} + +/** Lift validated catalog clauses into CollectionSetup (identity/type packs supplied by installer). */ +export function liftConfigurationSetup(setup) { + validateConfigurationSetup(setup); + const configuration = setup?.configuration ?? { requirements: [], provisions: [] }; + return structuredClone({ + requirements: { configuration: configuration.requirements }, + provisions: { configuration: configuration.provisions }, + }); +} diff --git a/scripts/configuration-setup.test.mjs b/scripts/configuration-setup.test.mjs new file mode 100644 index 0000000..d5f6e70 --- /dev/null +++ b/scripts/configuration-setup.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { liftConfigurationSetup, validateConfigurationSetup } from "./configuration-setup.mjs"; + +const root = new URL("../", import.meta.url); +const base = () => ({ configuration: { + requirements: [{ id: "base-extension", path: "/settings/record_extensions", predicate: "contains", value: "base" }], + provisions: [{ requirement: "base-extension", operation: "set_add", path: "/settings/record_extensions", value: "base" }], +} }); +const change = (mutate) => { const result = base(); mutate(result.configuration); return result; }; + +test("catalog clauses lift into exactly the existing CollectionSetup arrays without aliases", () => { + const declaration = base(); + const lifted = liftConfigurationSetup(declaration); + assert.deepEqual(lifted.requirements.configuration, declaration.configuration.requirements); + assert.deepEqual(lifted.provisions.configuration, declaration.configuration.provisions); + lifted.requirements.configuration[0].value = "changed"; + assert.equal(declaration.configuration.requirements[0].value, "base"); + assert.deepEqual(liftConfigurationSetup(undefined), { + requirements: { configuration: [] }, provisions: { configuration: [] }, + }); +}); + +test("duplicate, orphan, mismatched and unsupported clauses refuse", () => { + for (const declaration of [ + null, {}, { configuration: null }, { ...base(), unexpected: true }, + change((c) => { c.provisions = []; }), + change((c) => { c.requirements.push(c.requirements[0]); }), + change((c) => { c.provisions.push(c.provisions[0]); }), + change((c) => { c.provisions[0].requirement = "orphan"; }), + change((c) => { c.provisions[0].value = "md"; }), + change((c) => { c.provisions[0].path = "/x-private/sources"; }), + change((c) => { c.provisions[0].operation = "replace"; }), + change((c) => { c.requirements[0].predicate = "equals"; }), + change((c) => { c.requirements[0].secret = "never reported"; }), + change((c) => { c.requirements[0].id = "Invalid"; }), + change((c) => { c.requirements = Array(129).fill(c.requirements[0]); }), + ]) assert.throws(() => validateConfigurationSetup(declaration), /^Error: invalid_collection_setup:/); +}); + +test("only the exact approved settings pointer is exempt from x-* confinement", () => { + for (const path of [ + "/settings/timezone", "/settings/record_extensions/extra", "/runtime/events", + "/x-private", "/x-private/0", "/x-private/-", "/x-private/bad~2escape", + "/x-private//empty", "/x-private/\u0085", `/${"x".repeat(1025)}/value`, + `/x-private/${Array(16).fill("nested").join("/")}`, + ]) { + assert.throws(() => validateConfigurationSetup(change((c) => { + c.requirements[0].path = c.provisions[0].path = path; + })), /invalid_collection_setup/); + } + for (const path of ["/settings/record_extensions", "/x-private/escaped~1key", "/x-private/escaped~0key"]) { + validateConfigurationSetup(change((c) => { c.requirements[0].path = c.provisions[0].path = path; })); + } +}); + +test("extension values are strings; extension namespaces retain the scalar domain", () => { + for (const value of [null, true, 12, ["base"], {}, "../base", ".base"]) { + assert.throws(() => validateConfigurationSetup(change((c) => { + c.requirements[0].value = c.provisions[0].value = value; + })), /invalid_collection_setup/); + } + for (const value of [null, true, false, 0, -0, 1.25, "public-source"]) { + validateConfigurationSetup(change((c) => { + c.requirements[0].path = c.provisions[0].path = "/x-private/sources"; + c.requirements[0].value = c.provisions[0].value = value; + })); + } + for (const value of [Infinity, NaN, [], {}]) { + assert.throws(() => validateConfigurationSetup(change((c) => { + c.requirements[0].path = c.provisions[0].path = "/x-private/sources"; + c.requirements[0].value = c.provisions[0].value = value; + })), /invalid_collection_setup/); + } +}); + +test("published old Bases provision is immutable; candidate transports setup outside strict manifest", async () => { + const old = await readFile(new URL("dist/packs/obsidian.base/1.0.0.json", root)); + assert.equal(createHash("sha256").update(old).digest("hex"), "8251b351a4b00c4ea3f4d49988ae45ab52ceaf2420f57ce8105ca4cfe5eafd50"); + const candidate = JSON.parse(await readFile(new URL("dist/packs/obsidian.base/1.0.1.json", root), "utf8")); + assert.equal(candidate.manifest.kind, "mdbase.type-pack"); + assert.equal(candidate.manifest.version, "1.0.1"); + assert.equal(Object.hasOwn(candidate.manifest, "setup"), false); + const lifted = liftConfigurationSetup(candidate.setup); + assert.equal(lifted.requirements.configuration[0].path, "/settings/record_extensions"); + assert.equal(lifted.provisions.configuration[0].value, "base"); + assert.deepEqual(candidate.resources, JSON.parse(old).resources); + const catalog = JSON.parse(await readFile(new URL("dist/catalog.json", root), "utf8")); + assert.equal(catalog.packs.some((pack) => pack.id === "obsidian.base" && pack.version === "1.0.1"), false); +});