Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions dist/packs/obsidian.base/1.0.1.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions packs/obsidian.base/1.0.1.pack.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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.`);
}
Expand Down
66 changes: 66 additions & 0 deletions scripts/configuration-setup.mjs
Original file line number Diff line number Diff line change
@@ -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 },
});
}
92 changes: 92 additions & 0 deletions scripts/configuration-setup.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading