From 55a9f85952c72abb911d3da18fb75af92be46cd6 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 25 Sep 2026 23:05:52 -0500 Subject: [PATCH 1/3] Add the package verifier and run it before release builds Issue #356 asks for scripts/verify-package.mjs, wired into release.yml before the platform builds. It needs product and key-custody decisions before it can assert everything the Phase 7 plan lists, so it enforces what the configuration, README and capability guard already agree on and reports the rest as pending: - enforced: product name and identifier; the 1180x780 default and the documented 480x520 minimum window; withGlobalTauri off; a strict CSP with no unsafe-eval, no wildcard or scheme sources and no network origin in connect-src; the six installer targets and five icons, present on disk; the main window's exact capability allowlist; no shell, filesystem, HTTP, opener or process plugin. - pending (issue #356 decisions 2 and 3): the updater public key with updater artifacts on, and the opencoven-chat protocol. --release makes them failures for when they are decided; release.yml does not pass it yet, since docs/releasing.md documents the updater as opt-in and off. The minimum window follows the implemented contract (README, responsive tiers) rather than the plan's older 820x600, per the evidence on #356. Tests pass the real configuration and fail each of thirteen mutations. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 8 ++ docs/releasing.md | 15 +++ scripts/verify-package.mjs | 181 ++++++++++++++++++++++++++++++++++ src/verify-package.test.ts | 143 +++++++++++++++++++++++++++ 4 files changed, 347 insertions(+) create mode 100644 scripts/verify-package.mjs create mode 100644 src/verify-package.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5750f792..9e03deb9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,6 +223,14 @@ jobs: node-version-file: .node-version cache: pnpm + # The package's declared contract (names, window bounds, strict CSP, + # installer targets and icons, the capability allowlist, no shell or + # filesystem plugins) is enforced before any platform build. Decisions + # issue #356 leaves open (updater key, deep-link protocol) are printed + # but not enforced; pass --release here once they are made. + - name: Verify the package contract + run: node scripts/verify-package.mjs + - id: check name: Verify tag is annotated, signed, and version-consistent env: diff --git a/docs/releasing.md b/docs/releasing.md index 0a8e82dc..b5433561 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -37,6 +37,21 @@ Run through this in order. Every step is runnable as written. grep -m1 '^version' src-tauri/Cargo.toml ``` + **Verify the package contract.** The release workflow runs this before any + platform build, so run it locally first: + + ```bash + node scripts/verify-package.mjs + ``` + + It enforces what the configuration already declares: product name and + identifier, the 1180×780 default and 480×520 minimum window, a strict CSP + with no network origins in `connect-src`, the installer targets and icons, + the main window's capability allowlist, and no shell, filesystem, HTTP or + opener plugin. It also prints the decisions issue #356 leaves open (the + updater key and the `opencoven-chat` protocol) as pending; once they are + made, pass `--release` in `release.yml` so they are enforced too. + Also confirm bundling is enabled in `src-tauri/tauri.conf.json` (`bundle.active: true` with the platform targets), otherwise `tauri build` produces no installers. diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 00000000..5ebdef79 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +// Verifies the packaged app's declared contract before a release build. +// +// Enforced: what tauri.conf.json, the capability, Cargo.toml and README.md +// already agree on. Pending: what issue #356 leaves to a product or +// key-custody decision (updater key, deep-link protocol). Pending items are +// reported, not enforced, unless --release is passed, so the release path can +// require them once decided without this check lying in the meantime. +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const PRODUCT_NAME = 'OpenCoven Chat'; +export const IDENTIFIER = 'ai.opencoven.chat'; +export const DEFAULT_WINDOW = { width: 1180, height: 780 }; +// The implemented and documented minimum (README, responsive tiers). The +// Phase 7 plan's 820x600 predates the responsive work; see issue #356. +export const MINIMUM_WINDOW = { width: 480, height: 520 }; +export const INSTALLER_TARGETS = ['app', 'dmg', 'msi', 'nsis', 'appimage', 'deb']; +export const ICONS = [ + 'icons/32x32.png', + 'icons/128x128.png', + 'icons/128x128@2x.png', + 'icons/icon.icns', + 'icons/icon.ico', +]; +// The main window may reach only these: the app's own commands and setting +// its own title. Mirrors the specification guard on the capability. +export const ALLOWED_PERMISSIONS = [ + 'allow-coven-runtime-status', + 'allow-coven-runtime-familiars', + 'allow-coven-runtime-sessions', + 'allow-coven-runtime-chat-lifecycle', + 'allow-coven-runtime-read', + 'allow-coven-runtime-send', + 'allow-coven-runtime-cancel', + 'allow-coven-screen-connect', + 'allow-coven-screen-send', + 'allow-coven-screen-disconnect', + 'core:window:allow-set-title', +]; +const FORBIDDEN_PERMISSION = + /^(shell|fs|filesystem|opener|http|https|network|process|os|dialog)(:|-)/; +const FORBIDDEN_PLUGIN = /tauri-plugin-(shell|fs|http|opener|process)\b/; + +function directives(csp) { + const map = new Map(); + for (const part of csp.split(';')) { + const [name, ...sources] = part.trim().split(/\s+/); + if (name) map.set(name, sources); + } + return map; +} + +/** + * @param {{ conf: any, capability: any, cargo: string, exists: (path: string) => boolean }} input + * @returns {{ failures: string[], pending: string[] }} + */ +export function verifyPackage({ conf, capability, cargo, exists }) { + const failures = []; + const pending = []; + const fail = (message) => failures.push(message); + + if (conf.productName !== PRODUCT_NAME) + fail(`productName is ${conf.productName}, not ${PRODUCT_NAME}.`); + if (conf.identifier !== IDENTIFIER) fail(`identifier is ${conf.identifier}, not ${IDENTIFIER}.`); + + const windows = conf.app?.windows ?? []; + const main = windows.find((window) => window.label === 'main'); + if (windows.length !== 1 || !main) + fail('The package must declare exactly one window, labelled main.'); + else { + if (main.width !== DEFAULT_WINDOW.width || main.height !== DEFAULT_WINDOW.height) + fail( + `The default window is ${main.width}x${main.height}, not ${DEFAULT_WINDOW.width}x${DEFAULT_WINDOW.height}.`, + ); + if (main.minWidth !== MINIMUM_WINDOW.width || main.minHeight !== MINIMUM_WINDOW.height) + fail( + `The minimum window is ${main.minWidth}x${main.minHeight}, not ${MINIMUM_WINDOW.width}x${MINIMUM_WINDOW.height}.`, + ); + } + if (conf.app?.withGlobalTauri !== false) fail('withGlobalTauri must be false.'); + + const csp = conf.app?.security?.csp; + if (typeof csp !== 'string') fail('A content security policy must be declared.'); + else { + const policy = directives(csp); + const expect = (name, sources) => { + const actual = policy.get(name); + if (!actual || actual.join(' ') !== sources.join(' ')) + fail(`CSP ${name} is "${actual?.join(' ') ?? 'missing'}", not "${sources.join(' ')}".`); + }; + expect('default-src', ["'self'"]); + expect('script-src', ["'self'"]); + expect('object-src', ["'none'"]); + expect('base-uri', ["'none'"]); + expect('form-action', ["'none'"]); + expect('frame-ancestors', ["'none'"]); + for (const [name, sources] of policy) { + if ( + sources.some( + (source) => + source === "'unsafe-eval'" || + source === '*' || + source === 'https:' || + source === 'http:', + ) + ) + fail(`CSP ${name} allows ${sources.join(' ')}.`); + } + const connect = policy.get('connect-src') ?? []; + for (const source of connect) + if (!["'self'", 'ipc:', 'http://ipc.localhost'].includes(source)) + fail( + `CSP connect-src allows ${source}; the webview reaches the network only through the host.`, + ); + } + + const bundle = conf.bundle ?? {}; + if (bundle.active !== true) + fail('bundle.active must be true, or a release produces no installers.'); + const targets = Array.isArray(bundle.targets) ? bundle.targets : []; + for (const target of INSTALLER_TARGETS) + if (!targets.includes(target)) fail(`Installer target ${target} is missing.`); + const icons = Array.isArray(bundle.icon) ? bundle.icon : []; + for (const icon of ICONS) { + if (!icons.includes(icon)) fail(`Icon ${icon} is not declared.`); + else if (!exists(icon)) fail(`Icon ${icon} is declared but missing.`); + } + + if (JSON.stringify(capability.windows) !== JSON.stringify(['main'])) + fail('The capability must apply to the main window only.'); + const permissions = Array.isArray(capability.permissions) ? capability.permissions : []; + for (const permission of permissions) { + if (typeof permission !== 'string') fail('Capability permissions must be plain identifiers.'); + else if (FORBIDDEN_PERMISSION.test(permission)) fail(`Capability grants ${permission}.`); + else if (!ALLOWED_PERMISSIONS.includes(permission)) + fail(`Capability grants unreviewed ${permission}.`); + } + for (const permission of ALLOWED_PERMISSIONS) + if (!permissions.includes(permission)) + fail(`Capability lacks ${permission}, which the window needs.`); + if (capability.remote) fail('The capability must not grant remote origins.'); + + const plugin = cargo.match(FORBIDDEN_PLUGIN); + if (plugin) fail(`Cargo.toml depends on ${plugin[0]}.`); + + const updater = conf.plugins?.updater; + if (!updater?.pubkey || bundle.createUpdaterArtifacts !== true) + pending.push( + 'Updater: no public key and createUpdaterArtifacts is not true (issue #356, decision 3).', + ); + if (!JSON.stringify(conf.plugins ?? {}).includes('"opencoven-chat"')) + pending.push('Deep-link protocol opencoven-chat is not registered (issue #356, decision 2).'); + + return { failures, pending }; +} + +export function readPackage(root) { + const read = (path) => readFileSync(resolve(root, path), 'utf8'); + return { + conf: JSON.parse(read('src-tauri/tauri.conf.json')), + capability: JSON.parse(read('src-tauri/capabilities/default.json')), + cargo: read('src-tauri/Cargo.toml'), + exists: (path) => existsSync(resolve(root, 'src-tauri', path)), + }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const release = process.argv.includes('--release'); + const { failures, pending } = verifyPackage(readPackage(process.cwd())); + for (const failure of failures) console.error(`FAIL ${failure}`); + for (const item of pending) console.error(`${release ? 'FAIL ' : 'PEND '} ${item}`); + const failed = failures.length > 0 || (release && pending.length > 0); + console.log( + failed + ? 'Package verification failed.' + : `Package verified${pending.length ? `; ${pending.length} decision(s) pending (not enforced without --release)` : ''}.`, + ); + process.exitCode = failed ? 1 : 0; +} diff --git a/src/verify-package.test.ts b/src/verify-package.test.ts new file mode 100644 index 00000000..f9ff1c55 --- /dev/null +++ b/src/verify-package.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; + +// @ts-expect-error Executable scripts intentionally have no declaration files. +import { readPackage, verifyPackage } from '../scripts/verify-package.mjs'; + +// biome-ignore lint/suspicious/noExplicitAny: the fixture mutates arbitrary parts of real config JSON. +type Json = any; +type Input = { conf: Json; capability: Json; cargo: string; exists: (path: string) => boolean }; + +function actual(): Input { + return readPackage(process.cwd()); +} + +function mutated(change: (input: Input) => void): Input { + const input = actual(); + const copy: Input = { + conf: structuredClone(input.conf), + capability: structuredClone(input.capability), + cargo: input.cargo, + exists: input.exists, + }; + change(copy); + return copy; +} + +describe('verify-package', () => { + it('passes the package as declared, with the open decisions only pending', () => { + const { failures, pending } = verifyPackage(actual()); + expect(failures).toEqual([]); + expect(pending.join('\n')).toMatch(/Updater/); + expect(pending.join('\n')).toMatch(/opencoven-chat/); + }); + + it.each([ + [ + 'a renamed product', + (i: Input) => { + i.conf.productName = 'Chat'; + }, + /productName/, + ], + [ + 'a changed identifier', + (i: Input) => { + i.conf.identifier = 'com.example.chat'; + }, + /identifier/, + ], + [ + 'a smaller minimum window', + (i: Input) => { + i.conf.app.windows[0].minWidth = 320; + }, + /minimum window/, + ], + [ + 'a second window', + (i: Input) => i.conf.app.windows.push({ label: 'extra' }), + /exactly one window/, + ], + [ + 'the global Tauri object', + (i: Input) => { + i.conf.app.withGlobalTauri = true; + }, + /withGlobalTauri/, + ], + [ + 'unsafe-eval in the CSP', + (i: Input) => { + i.conf.app.security.csp = i.conf.app.security.csp.replace( + "script-src 'self'", + "script-src 'self' 'unsafe-eval'", + ); + }, + /script-src|unsafe-eval/, + ], + [ + 'a network origin in connect-src', + (i: Input) => { + i.conf.app.security.csp = i.conf.app.security.csp.replace( + 'connect-src', + 'connect-src https://example.com', + ); + }, + /connect-src allows https:\/\/example.com/, + ], + [ + 'a dropped installer target', + (i: Input) => { + i.conf.bundle.targets = ['app']; + }, + /Installer target dmg/, + ], + [ + 'a missing icon file', + (i: Input) => { + i.exists = (path) => path !== 'icons/icon.ico'; + }, + /icon.ico is declared but missing/, + ], + [ + 'a shell permission', + (i: Input) => i.capability.permissions.push('shell:allow-execute'), + /grants shell:allow-execute/, + ], + [ + 'an unreviewed core permission', + (i: Input) => i.capability.permissions.push('core:window:allow-close'), + /unreviewed core:window:allow-close/, + ], + [ + 'a remote origin', + (i: Input) => { + i.capability.remote = { urls: ['https://example.com'] }; + }, + /remote origins/, + ], + [ + 'a filesystem plugin', + (i: Input) => { + i.cargo += '\ntauri-plugin-fs = "2"\n'; + }, + /tauri-plugin-fs/, + ], + ])('fails on %s', (_name, change, message) => { + const { failures } = verifyPackage(mutated(change)); + expect(failures.join('\n')).toMatch(message); + }); + + it('treats a decided updater and protocol as no longer pending', () => { + const { pending } = verifyPackage( + mutated((i) => { + i.conf.bundle.createUpdaterArtifacts = true; + i.conf.plugins = { + updater: { pubkey: 'dW50cnVzdGVkIGNvbW1lbnQ=', endpoints: [] }, + 'deep-link': { desktop: { schemes: ['opencoven-chat'] } }, + }; + }), + ); + expect(pending).toEqual([]); + }); +}); From 650f7737e3e13634b8a823e6876c27cc5c9a9c1d Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 25 Sep 2026 23:14:55 -0500 Subject: [PATCH 2/3] Close five gaps in the package verifier - A repeated CSP directive is rejected: the first copy stays in force, so checking only one could pass a permissive policy. - connect-src is required, with ipc: and http://ipc.localhost; without it default-src 'self' blocks the webview's IPC to its own host. - Every capability file is read, and any file other than the reviewed default.json fails, since Tauri loads them all. - The protocol counts as registered only at plugins.deep-link.desktop.schemes, not wherever the string appears. - The release workflow test pins the verifier step in verify-tag, after checkout and before the tag check, ahead of the build job. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/verify-package.mjs | 48 ++++++++++++++++++++++++++++-------- src/release-workflow.test.ts | 13 ++++++++++ src/verify-package.test.ts | 45 +++++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 5ebdef79..cce28a07 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -6,7 +6,7 @@ // key-custody decision (updater key, deep-link protocol). Pending items are // reported, not enforced, unless --release is passed, so the release path can // require them once decided without this check lying in the meantime. -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -45,18 +45,23 @@ const FORBIDDEN_PLUGIN = /tauri-plugin-(shell|fs|http|opener|process)\b/; function directives(csp) { const map = new Map(); + const duplicates = []; for (const part of csp.split(';')) { const [name, ...sources] = part.trim().split(/\s+/); - if (name) map.set(name, sources); + if (!name) continue; + // A repeated directive is not replaced by the later one; the first stays + // in force, so checking only one copy could pass a permissive policy. + if (map.has(name)) duplicates.push(name); + else map.set(name, sources); } - return map; + return { map, duplicates }; } /** - * @param {{ conf: any, capability: any, cargo: string, exists: (path: string) => boolean }} input + * @param {{ conf: any, capabilities: Record, cargo: string, exists: (path: string) => boolean }} input * @returns {{ failures: string[], pending: string[] }} */ -export function verifyPackage({ conf, capability, cargo, exists }) { +export function verifyPackage({ conf, capabilities, cargo, exists }) { const failures = []; const pending = []; const fail = (message) => failures.push(message); @@ -84,7 +89,9 @@ export function verifyPackage({ conf, capability, cargo, exists }) { const csp = conf.app?.security?.csp; if (typeof csp !== 'string') fail('A content security policy must be declared.'); else { - const policy = directives(csp); + const { map: policy, duplicates } = directives(csp); + for (const name of duplicates) + fail(`CSP repeats ${name}; only one copy is checked, the first applies.`); const expect = (name, sources) => { const actual = policy.get(name); if (!actual || actual.join(' ') !== sources.join(' ')) @@ -108,8 +115,13 @@ export function verifyPackage({ conf, capability, cargo, exists }) { ) fail(`CSP ${name} allows ${sources.join(' ')}.`); } - const connect = policy.get('connect-src') ?? []; - for (const source of connect) + const connect = policy.get('connect-src'); + // Without it, default-src 'self' leaves the webview unable to reach the + // host over IPC, so the built app could not run its own commands. + if (!connect) fail('CSP must declare connect-src with the IPC origins.'); + else if (!connect.includes('ipc:') || !connect.includes('http://ipc.localhost')) + fail('CSP connect-src must allow ipc: and http://ipc.localhost.'); + for (const source of connect ?? []) if (!["'self'", 'ipc:', 'http://ipc.localhost'].includes(source)) fail( `CSP connect-src allows ${source}; the webview reaches the network only through the host.`, @@ -128,6 +140,14 @@ export function verifyPackage({ conf, capability, cargo, exists }) { else if (!exists(icon)) fail(`Icon ${icon} is declared but missing.`); } + // Tauri loads every capability file in the directory, so an extra file + // could grant the window anything; only the reviewed one may exist. + const files = Object.keys(capabilities); + for (const file of files) + if (file !== 'default.json') + fail(`Unexpected capability file ${file}; only default.json is reviewed.`); + const capability = capabilities['default.json'] ?? {}; + if (!capabilities['default.json']) fail('Capability default.json is missing.'); if (JSON.stringify(capability.windows) !== JSON.stringify(['main'])) fail('The capability must apply to the main window only.'); const permissions = Array.isArray(capability.permissions) ? capability.permissions : []; @@ -150,7 +170,8 @@ export function verifyPackage({ conf, capability, cargo, exists }) { pending.push( 'Updater: no public key and createUpdaterArtifacts is not true (issue #356, decision 3).', ); - if (!JSON.stringify(conf.plugins ?? {}).includes('"opencoven-chat"')) + const schemes = conf.plugins?.['deep-link']?.desktop?.schemes; + if (!Array.isArray(schemes) || !schemes.includes('opencoven-chat')) pending.push('Deep-link protocol opencoven-chat is not registered (issue #356, decision 2).'); return { failures, pending }; @@ -160,7 +181,14 @@ export function readPackage(root) { const read = (path) => readFileSync(resolve(root, path), 'utf8'); return { conf: JSON.parse(read('src-tauri/tauri.conf.json')), - capability: JSON.parse(read('src-tauri/capabilities/default.json')), + capabilities: Object.fromEntries( + readdirSync(resolve(root, 'src-tauri/capabilities')) + .filter((file) => file.endsWith('.json') || file.endsWith('.toml')) + .map((file) => [ + file, + file.endsWith('.json') ? JSON.parse(read(`src-tauri/capabilities/${file}`)) : {}, + ]), + ), cargo: read('src-tauri/Cargo.toml'), exists: (path) => existsSync(resolve(root, 'src-tauri', path)), }; diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index 763fde84..c43023ad 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -149,6 +149,19 @@ describe('release workflow specification', () => { expect(build).toMatch(/\[ -n "\$\{WINDOWS_CERTIFICATE_PASSWORD:-\}" \]/); }); + test('verifies the package contract before the tag check and every platform build', () => { + const verify = job('verify-tag', 'build'); + const step = verify.indexOf('run: node scripts/verify-package.mjs'); + expect(step).toBeGreaterThan(-1); + // After checkout (it reads the tagged tree), before the tag check and so + // before the build job, which needs verify-tag. + expect(step).toBeGreaterThan(verify.indexOf('uses: actions/checkout@')); + expect(step).toBeLessThan( + verify.indexOf('Verify tag is annotated, signed, and version-consistent'), + ); + expect(job('build', 'publish')).toContain('needs: verify-tag'); + }); + test('refuses to release without platform signing material', () => { const verify = job('verify-tag', 'build'); diff --git a/src/verify-package.test.ts b/src/verify-package.test.ts index f9ff1c55..a18d8640 100644 --- a/src/verify-package.test.ts +++ b/src/verify-package.test.ts @@ -5,7 +5,12 @@ import { readPackage, verifyPackage } from '../scripts/verify-package.mjs'; // biome-ignore lint/suspicious/noExplicitAny: the fixture mutates arbitrary parts of real config JSON. type Json = any; -type Input = { conf: Json; capability: Json; cargo: string; exists: (path: string) => boolean }; +type Input = { + conf: Json; + capabilities: Record; + cargo: string; + exists: (path: string) => boolean; +}; function actual(): Input { return readPackage(process.cwd()); @@ -15,7 +20,7 @@ function mutated(change: (input: Input) => void): Input { const input = actual(); const copy: Input = { conf: structuredClone(input.conf), - capability: structuredClone(input.capability), + capabilities: structuredClone(input.capabilities), cargo: input.cargo, exists: input.exists, }; @@ -85,6 +90,27 @@ describe('verify-package', () => { }, /connect-src allows https:\/\/example.com/, ], + [ + 'a repeated, permissive connect-src ahead of a strict one', + (i: Input) => { + i.conf.app.security.csp = `connect-src https://example.com; ${i.conf.app.security.csp}`; + }, + /repeats connect-src/, + ], + [ + 'no connect-src at all', + (i: Input) => { + i.conf.app.security.csp = i.conf.app.security.csp.replace(/connect-src[^;]*;\s*/, ''); + }, + /must declare connect-src/, + ], + [ + 'an extra capability file', + (i: Input) => { + i.capabilities['extra.json'] = { windows: ['main'], permissions: ['shell:default'] }; + }, + /Unexpected capability file extra.json/, + ], [ 'a dropped installer target', (i: Input) => { @@ -101,18 +127,18 @@ describe('verify-package', () => { ], [ 'a shell permission', - (i: Input) => i.capability.permissions.push('shell:allow-execute'), + (i: Input) => i.capabilities['default.json'].permissions.push('shell:allow-execute'), /grants shell:allow-execute/, ], [ 'an unreviewed core permission', - (i: Input) => i.capability.permissions.push('core:window:allow-close'), + (i: Input) => i.capabilities['default.json'].permissions.push('core:window:allow-close'), /unreviewed core:window:allow-close/, ], [ 'a remote origin', (i: Input) => { - i.capability.remote = { urls: ['https://example.com'] }; + i.capabilities['default.json'].remote = { urls: ['https://example.com'] }; }, /remote origins/, ], @@ -128,6 +154,15 @@ describe('verify-package', () => { expect(failures.join('\n')).toMatch(message); }); + it('does not count an unrelated mention of the protocol as registering it', () => { + const { pending } = verifyPackage( + mutated((i) => { + i.conf.plugins = { other: { note: 'opencoven-chat' } }; + }), + ); + expect(pending.join('\n')).toMatch(/opencoven-chat is not registered/); + }); + it('treats a decided updater and protocol as no longer pending', () => { const { pending } = verifyPackage( mutated((i) => { From c076ab0afa5d4bfab71433a09687384fc9c40860 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 26 Sep 2026 05:15:03 -0500 Subject: [PATCH 3/3] Enforce the v0.0.1 decisions on issue #356 instead of reporting them The three open decisions are now made (2026-09-26): the window minimum stays 480x520, the opencoven-chat protocol is deferred past v0.0.1, and the updater stays off for v0.0.1. With nothing left pending, the verifier drops its pending list and --release flag and enforces the decided state: createUpdaterArtifacts false with no updater config or crate, and no deep-link config or crate. A release that enables either changes the decision here in the same change that configures it. The workflow comment and docs/releasing.md say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 5 ++- docs/releasing.md | 15 +++++---- scripts/verify-package.mjs | 44 +++++++++++-------------- src/verify-package.test.ts | 61 ++++++++++++++++++++++------------- 4 files changed, 68 insertions(+), 57 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e03deb9..eb65a23f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -225,9 +225,8 @@ jobs: # The package's declared contract (names, window bounds, strict CSP, # installer targets and icons, the capability allowlist, no shell or - # filesystem plugins) is enforced before any platform build. Decisions - # issue #356 leaves open (updater key, deep-link protocol) are printed - # but not enforced; pass --release here once they are made. + # filesystem plugins) and the v0.0.1 decisions on issue #356 (no + # updater, no deep-link protocol) are enforced before any build. - name: Verify the package contract run: node scripts/verify-package.mjs diff --git a/docs/releasing.md b/docs/releasing.md index b5433561..b48b63e5 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -44,13 +44,14 @@ Run through this in order. Every step is runnable as written. node scripts/verify-package.mjs ``` - It enforces what the configuration already declares: product name and - identifier, the 1180×780 default and 480×520 minimum window, a strict CSP - with no network origins in `connect-src`, the installer targets and icons, - the main window's capability allowlist, and no shell, filesystem, HTTP or - opener plugin. It also prints the decisions issue #356 leaves open (the - updater key and the `opencoven-chat` protocol) as pending; once they are - made, pass `--release` in `release.yml` so they are enforced too. + It enforces what the configuration declares: product name and identifier, + the 1180×780 default and 480×520 minimum window, a strict CSP with no + network origins in `connect-src`, the installer targets and icons, the + main window's capability allowlist, and no shell, filesystem, HTTP or + opener plugin. It also holds the v0.0.1 decisions recorded on issue #356: + no updater (see § 4) and no deep-link protocol. A release that enables + either changes that decision in `scripts/verify-package.mjs` in the same + change that configures it. Also confirm bundling is enabled in `src-tauri/tauri.conf.json` (`bundle.active: true` with the platform targets), otherwise `tauri build` diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index cce28a07..e704a210 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,11 +1,11 @@ #!/usr/bin/env node // Verifies the packaged app's declared contract before a release build. // -// Enforced: what tauri.conf.json, the capability, Cargo.toml and README.md -// already agree on. Pending: what issue #356 leaves to a product or -// key-custody decision (updater key, deep-link protocol). Pending items are -// reported, not enforced, unless --release is passed, so the release path can -// require them once decided without this check lying in the meantime. +// It enforces what tauri.conf.json, the capability, Cargo.toml and README.md +// declare, and the v0.0.1 decisions recorded on issue #356 (2026-09-26): +// the 480x520 minimum window, no updater, and no deep-link protocol. When a +// later release enables the updater or a protocol, change the decision here +// in the same change that configures it. import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -59,11 +59,10 @@ function directives(csp) { /** * @param {{ conf: any, capabilities: Record, cargo: string, exists: (path: string) => boolean }} input - * @returns {{ failures: string[], pending: string[] }} + * @returns {{ failures: string[] }} */ export function verifyPackage({ conf, capabilities, cargo, exists }) { const failures = []; - const pending = []; const fail = (message) => failures.push(message); if (conf.productName !== PRODUCT_NAME) @@ -165,16 +164,18 @@ export function verifyPackage({ conf, capabilities, cargo, exists }) { const plugin = cargo.match(FORBIDDEN_PLUGIN); if (plugin) fail(`Cargo.toml depends on ${plugin[0]}.`); - const updater = conf.plugins?.updater; - if (!updater?.pubkey || bundle.createUpdaterArtifacts !== true) - pending.push( - 'Updater: no public key and createUpdaterArtifacts is not true (issue #356, decision 3).', + // Issue #356, decision 3: no updater for v0.0.1 (docs/releasing.md section 4). + if (bundle.createUpdaterArtifacts !== false || conf.plugins?.updater) + fail( + 'v0.0.1 ships without the updater: createUpdaterArtifacts must be false, with no updater plugin config (issue #356).', ); - const schemes = conf.plugins?.['deep-link']?.desktop?.schemes; - if (!Array.isArray(schemes) || !schemes.includes('opencoven-chat')) - pending.push('Deep-link protocol opencoven-chat is not registered (issue #356, decision 2).'); + if (/tauri-plugin-updater\b/.test(cargo)) + fail('Cargo.toml depends on tauri-plugin-updater; v0.0.1 has no updater (issue #356).'); + // Issue #356, decision 2: no deep-link protocol for v0.0.1; nothing handles links yet. + if (conf.plugins?.['deep-link'] || /tauri-plugin-deep-link\b/.test(cargo)) + fail('v0.0.1 registers no deep-link protocol (issue #356).'); - return { failures, pending }; + return { failures }; } export function readPackage(root) { @@ -195,15 +196,8 @@ export function readPackage(root) { } if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - const release = process.argv.includes('--release'); - const { failures, pending } = verifyPackage(readPackage(process.cwd())); + const { failures } = verifyPackage(readPackage(process.cwd())); for (const failure of failures) console.error(`FAIL ${failure}`); - for (const item of pending) console.error(`${release ? 'FAIL ' : 'PEND '} ${item}`); - const failed = failures.length > 0 || (release && pending.length > 0); - console.log( - failed - ? 'Package verification failed.' - : `Package verified${pending.length ? `; ${pending.length} decision(s) pending (not enforced without --release)` : ''}.`, - ); - process.exitCode = failed ? 1 : 0; + console.log(failures.length ? 'Package verification failed.' : 'Package verified.'); + process.exitCode = failures.length ? 1 : 0; } diff --git a/src/verify-package.test.ts b/src/verify-package.test.ts index a18d8640..3509f936 100644 --- a/src/verify-package.test.ts +++ b/src/verify-package.test.ts @@ -29,11 +29,8 @@ function mutated(change: (input: Input) => void): Input { } describe('verify-package', () => { - it('passes the package as declared, with the open decisions only pending', () => { - const { failures, pending } = verifyPackage(actual()); - expect(failures).toEqual([]); - expect(pending.join('\n')).toMatch(/Updater/); - expect(pending.join('\n')).toMatch(/opencoven-chat/); + it('passes the package as declared', () => { + expect(verifyPackage(actual()).failures).toEqual([]); }); it.each([ @@ -154,25 +151,45 @@ describe('verify-package', () => { expect(failures.join('\n')).toMatch(message); }); - it('does not count an unrelated mention of the protocol as registering it', () => { - const { pending } = verifyPackage( - mutated((i) => { - i.conf.plugins = { other: { note: 'opencoven-chat' } }; - }), + it.each([ + [ + 'updater artifacts turned on', + (i: Input) => { + i.conf.bundle.createUpdaterArtifacts = true; + }, + ], + [ + 'an updater plugin config', + (i: Input) => { + i.conf.plugins = { updater: { pubkey: 'key', endpoints: [] } }; + }, + ], + [ + 'the updater crate', + (i: Input) => { + i.cargo += '\ntauri-plugin-updater = "2"\n'; + }, + ], + ])('holds the v0.0.1 decision of no updater against %s', (_name, change) => { + expect(verifyPackage(mutated(change)).failures.join('\n')).toMatch( + /without the updater|no updater/, ); - expect(pending.join('\n')).toMatch(/opencoven-chat is not registered/); }); - it('treats a decided updater and protocol as no longer pending', () => { - const { pending } = verifyPackage( - mutated((i) => { - i.conf.bundle.createUpdaterArtifacts = true; - i.conf.plugins = { - updater: { pubkey: 'dW50cnVzdGVkIGNvbW1lbnQ=', endpoints: [] }, - 'deep-link': { desktop: { schemes: ['opencoven-chat'] } }, - }; - }), - ); - expect(pending).toEqual([]); + it.each([ + [ + 'a deep-link scheme', + (i: Input) => { + i.conf.plugins = { 'deep-link': { desktop: { schemes: ['opencoven-chat'] } } }; + }, + ], + [ + 'the deep-link crate', + (i: Input) => { + i.cargo += '\ntauri-plugin-deep-link = "2"\n'; + }, + ], + ])('holds the v0.0.1 decision of no protocol against %s', (_name, change) => { + expect(verifyPackage(mutated(change)).failures.join('\n')).toMatch(/no deep-link protocol/); }); });