diff --git a/apps/desktop/scripts/config-bundle.mts b/apps/desktop/scripts/config-bundle.mts index 4bdf54885..76d46fbed 100644 --- a/apps/desktop/scripts/config-bundle.mts +++ b/apps/desktop/scripts/config-bundle.mts @@ -6,7 +6,6 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { appendArrayInPlace } from 'foxts/append-array-in-place'; -import { isObjectEmpty } from 'foxts/is-object-empty'; // Relative on purpose: this module is inlined into the bundled Vite config, which runs under // plain Node — Node cannot resolve the package's extensionless TS source exports. import { parseBrandIdentityArtifact } from '../../../packages/foundation/common/src/config/brand-identity'; @@ -116,14 +115,6 @@ export function loadGeneratedConfigBundle( 'generated config bundle emergency keyring contains the conformance fixture key', ); } - if ( - env.LINKCODE_REQUIRE_CONFIG_BUNDLE === '1' && - (bundle.endpoints.emergency === null || isObjectEmpty(bundle.keyrings.emergency)) - ) { - throw new Error( - 'LINKCODE_REQUIRE_CONFIG_BUNDLE=1 requires an emergency endpoint and emergency public key', - ); - } const branded = bundle.brandId !== DEFAULT_BRAND_ID; assertExactFiles( generatedDir, diff --git a/apps/desktop/src/__tests__/config-bundle.test.ts b/apps/desktop/src/__tests__/config-bundle.test.ts index 8b8799731..3dc194fa1 100644 --- a/apps/desktop/src/__tests__/config-bundle.test.ts +++ b/apps/desktop/src/__tests__/config-bundle.test.ts @@ -22,7 +22,6 @@ const RE_IMMUTABLE = /immutable/; const RE_REBUILD = /rebuild before packaging/; const RE_WRONG_PLATFORM = /targets ios, expected desktop/; const RE_FIXTURE_KEY = /conformance fixture key/; -const RE_EMERGENCY_BOOTSTRAP = /requires an emergency endpoint and emergency public key/; const RE_ENABLED_TOGETHER = /enabled together/; const SAFE_EMERGENCY_PUBLIC_KEY = 'I-ZZtxm_RMtR2fMqJtiENzX13BIMmqE8X9lDWQ-bg4c'; const FIXTURE_PUBLIC_KEYS = [ @@ -174,7 +173,7 @@ describe('loadGeneratedConfigBundle', () => { }, ); - it('allows an absent emergency bootstrap only when the generated bundle is optional', async () => { + it('allows an absent emergency bootstrap in required release bundles', async () => { const fixture = JSON.parse(desktopFixture) as { endpoints: Record; keyrings: Record; @@ -187,7 +186,7 @@ describe('loadGeneratedConfigBundle', () => { expect(() => loadGeneratedConfigBundle(optionalDir, {})).not.toThrow(); expect(() => loadGeneratedConfigBundle(optionalDir, { LINKCODE_REQUIRE_CONFIG_BUNDLE: '1' }), - ).toThrow(RE_EMERGENCY_BOOTSTRAP); + ).not.toThrow(); }); it.each([ diff --git a/apps/mobile/scripts/verify-release-config.cjs b/apps/mobile/scripts/verify-release-config.cjs index 6d2c5b5cb..c1ade0550 100644 --- a/apps/mobile/scripts/verify-release-config.cjs +++ b/apps/mobile/scripts/verify-release-config.cjs @@ -41,17 +41,18 @@ function loadGeneratedBundle(platform, configDir = CONFIG_DIR) { if (bundle.platform !== platform) { throw new Error(`${path} targets ${String(bundle.platform)}, expected ${platform}`); } + const emergencyEndpoint = bundle.endpoints && bundle.endpoints.emergency; + const emergencyKeyring = bundle.keyrings && bundle.keyrings.emergency; + const emergencyKeyCount = + emergencyKeyring && typeof emergencyKeyring === 'object' && !Array.isArray(emergencyKeyring) + ? Reflect.ownKeys(emergencyKeyring).length + : -1; if ( - !bundle.endpoints || - bundle.endpoints.emergency === null || - bundle.endpoints.emergency === undefined + (emergencyEndpoint !== null && typeof emergencyEndpoint !== 'string') || + emergencyKeyCount < 0 || + (emergencyEndpoint === null) !== (emergencyKeyCount === 0) ) { - throw new Error(`${path} carries no emergency endpoint`); - } - const emergencyKeyring = bundle.keyrings && bundle.keyrings.emergency; - // eslint-disable-next-line sukka/prefer-foxts-object-size -- This pre-install gate has no dependencies. - if (!emergencyKeyring || Object.keys(emergencyKeyring).length === 0) { - throw new Error(`${path} carries no emergency public keys`); + throw new Error(`${path} emergency endpoint and keyring must be enabled together`); } if (Object.values(emergencyKeyring).some((key) => CONFORMANCE_FIXTURE_PUBLIC_KEYS.has(key))) { throw new Error(`${path} emergency keyring contains the conformance fixture key`); diff --git a/apps/mobile/src/runtime/config/__tests__/verify-release-config.test.ts b/apps/mobile/src/runtime/config/__tests__/verify-release-config.test.ts index b3b399c35..d9036ffd2 100644 --- a/apps/mobile/src/runtime/config/__tests__/verify-release-config.test.ts +++ b/apps/mobile/src/runtime/config/__tests__/verify-release-config.test.ts @@ -9,8 +9,7 @@ const RE_SENTINEL = /development sentinel/; const RE_WRONG_PLATFORM = /targets android, expected ios/; const RE_PROVENANCE_DRIFT = /disagree on target or provenance/; const RE_MODULE_SHAPE = /generated config module shape/; -const RE_EMERGENCY_ENDPOINT = /no emergency endpoint/; -const RE_EMERGENCY_KEYS = /no emergency public keys/; +const RE_EMERGENCY_PAIR = /emergency endpoint and keyring must be enabled together/; const RE_FIXTURE_KEY = /conformance fixture key/; const SAFE_EMERGENCY_PUBLIC_KEY = 'I-ZZtxm_RMtR2fMqJtiENzX13BIMmqE8X9lDWQ-bg4c'; const FIXTURE_PUBLIC_KEYS = [ @@ -92,19 +91,25 @@ describe('verify-release-config', () => { }, ); - it('rejects a missing emergency endpoint', () => { + it('accepts an explicitly disabled emergency channel', () => { write( 'ios', bundleFor('ios', { endpoints: { emergency: null, normal: null, telemetry: null }, + keyrings: { emergency: {}, normal: {} }, }), ); - expect(() => verifyReleaseConfig(['ios'], dir)).toThrow(RE_EMERGENCY_ENDPOINT); + expect(() => verifyReleaseConfig(['ios'], dir)).not.toThrow(); }); - it('rejects an empty emergency keyring', () => { - write('ios', bundleFor('ios', { keyrings: { emergency: {}, normal: {} } })); - expect(() => verifyReleaseConfig(['ios'], dir)).toThrow(RE_EMERGENCY_KEYS); + it.each([ + { + endpoints: { emergency: null, normal: null, telemetry: null }, + }, + { keyrings: { emergency: {}, normal: {} } }, + ])('rejects a partially enabled emergency channel', (overrides) => { + write('ios', bundleFor('ios', overrides)); + expect(() => verifyReleaseConfig(['ios'], dir)).toThrow(RE_EMERGENCY_PAIR); }); it('fails when a generated module is missing', () => { diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 798c44382..ed1ebfe23 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -89,7 +89,7 @@ Desktop signing and R2 secrets live in the repo's GitHub **`release` Environment ## Immutable config bundle (build-time render) -Signed desktop builds and every mobile store build embed an immutable config bundle (bootstrap endpoints, public keyrings, bundled defaults) rendered at build time by the config publisher — the client never re-implements rendering. The `render-config` job in `build-desktop.yml` (signed builds only) and `build-mobile.yml` (always) calls `.github/actions/render-release-config`, which checks out publisher code from protected `CONFIG_PUBLISHER_REPO` at `publisherGitSha` and structural data from protected `CONFIG_SOURCE_REPO` at the independent `sourceGitSha`. It renders through `pnpm -F @linkcode/ config:render` and verifies the manifest's digest bindings (revision bytes, public keyring bytes, target identity, telemetry endpoint, expected snapshot SHA-256). Nothing falls back to a mutable ref, an unvalidated or cross-organization repository, a global install, or stale generated output. +Signed desktop builds and every mobile store build embed an immutable config bundle (bootstrap endpoints, public keyrings, bundled defaults) rendered at build time by the config publisher — the client never re-implements rendering. The emergency channel is disabled when its endpoint is `null` and its public keyring is empty; explicit emergency brands must provide both together. The `render-config` job in `build-desktop.yml` (signed builds only) and `build-mobile.yml` (always) calls `.github/actions/render-release-config`, which checks out publisher code from protected `CONFIG_PUBLISHER_REPO` at `publisherGitSha` and structural data from protected `CONFIG_SOURCE_REPO` at the independent `sourceGitSha`. It renders through `pnpm -F @linkcode/ config:render` and verifies the manifest's digest bindings (revision bytes, public keyring bytes, target identity, telemetry endpoint, expected snapshot SHA-256). Nothing falls back to a mutable ref, an unvalidated or cross-organization repository, a global install, or stale generated output. Each checkout uses its own short-lived installation token minted from the organization secrets `BOT_APP_ID` and `BOT_APP_PRIVATE_KEY`. Trusted workflow steps mint these tokens before checking out