diff --git a/biome.json b/biome.json index 8a05cde9..5a98a718 100644 --- a/biome.json +++ b/biome.json @@ -20,6 +20,7 @@ "!**/node_modules", "!**/expo-export", "!packages/vendor/coss-ui", + "!packages/foundation/common/src/config/__fixtures__/contract-v1.json", "!**/.repos", "!**/pnpm-lock.yaml" ] diff --git a/packages/foundation/common/package.json b/packages/foundation/common/package.json index 6c9b369e..2593004b 100644 --- a/packages/foundation/common/package.json +++ b/packages/foundation/common/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "exports": { + "./config": "./src/config/index.ts", "./config-signing-poc": "./src/config-signing-poc/index.ts", "./node": "./src/node/index.ts", "./sentry": "./src/sentry/index.ts", @@ -18,6 +19,7 @@ "@noble/ed25519": "3.0.0", "@noble/hashes": "2.2.0", "canonicalize": "3.0.0", + "foxts": "^5.8.1", "zod": "catalog:", "zustand": "catalog:" }, diff --git a/packages/foundation/common/src/config/__fixtures__/contract-v1.json b/packages/foundation/common/src/config/__fixtures__/contract-v1.json new file mode 100644 index 00000000..13a7d3b0 --- /dev/null +++ b/packages/foundation/common/src/config/__fixtures__/contract-v1.json @@ -0,0 +1,795 @@ +{ + "fixtureVersion": 1, + "contractVersion": 1, + "keys": { + "emergency": { + "emergency-rfc8032-1": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "normal": { + "normal-rfc8032-2": "PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw", + "normal-rfc8032-3": "_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU" + } + }, + "snapshots": { + "current": { + "document": { + "applyModes": { + "app.displayName": "cold", + "content.home.banner": "hot", + "content.items": "hot", + "feature.aiAssist": "hot", + "feature.legacy": "cold", + "feature.newEditor": "cold", + "params.upload.maxSizeMb": "cold", + "ui.theme": "hot", + "ui.theme.primary": "hot" + }, + "brandId": "acme", + "channel": "canary", + "contractVersion": 1, + "overrides": [ + { + "set": { + "feature.newEditor": true + }, + "when": { + "appVersion": ">=2.3.0 <3.0.0" + } + }, + { + "set": { + "params.upload.maxSizeMb": 100 + }, + "when": { + "os": "windows" + } + }, + { + "set": { + "content.home.banner": { + "title": "你好" + } + }, + "when": { + "locale": "zh" + } + } + ], + "platform": "desktop", + "rollouts": { + "feature.aiAssist": { + "basisPoints": 2000, + "salt": "r7", + "value": true + } + }, + "schemaVersion": 1, + "configVersion": "2026.08.03-041", + "generatedAt": "2026-08-03T09:14:00Z", + "values": { + "app.displayName": "Acme Studio", + "content.home.banner": { + "title": "Build with LinkCode", + "url": "https://linkcode.ai/docs" + }, + "content.items": [ + "one", + "two" + ], + "feature.aiAssist": false, + "feature.legacy": false, + "params.upload.maxSizeMb": 200, + "ui.theme": { + "logoVariant": "light", + "nested": { + "keep": true + }, + "primary": "#0066FF" + }, + "ui.theme.primary": "atomic-dotted-key" + } + }, + "canonicalPayload": "{\"applyModes\":{\"app.displayName\":\"cold\",\"content.home.banner\":\"hot\",\"content.items\":\"hot\",\"feature.aiAssist\":\"hot\",\"feature.legacy\":\"cold\",\"feature.newEditor\":\"cold\",\"params.upload.maxSizeMb\":\"cold\",\"ui.theme\":\"hot\",\"ui.theme.primary\":\"hot\"},\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-041\",\"contractVersion\":1,\"generatedAt\":\"2026-08-03T09:14:00Z\",\"overrides\":[{\"set\":{\"feature.newEditor\":true},\"when\":{\"appVersion\":\">=2.3.0 <3.0.0\"}},{\"set\":{\"params.upload.maxSizeMb\":100},\"when\":{\"os\":\"windows\"}},{\"set\":{\"content.home.banner\":{\"title\":\"你好\"}},\"when\":{\"locale\":\"zh\"}}],\"platform\":\"desktop\",\"rollouts\":{\"feature.aiAssist\":{\"basisPoints\":2000,\"salt\":\"r7\",\"value\":true}},\"schemaVersion\":1,\"values\":{\"app.displayName\":\"Acme Studio\",\"content.home.banner\":{\"title\":\"Build with LinkCode\",\"url\":\"https://linkcode.ai/docs\"},\"content.items\":[\"one\",\"two\"],\"feature.aiAssist\":false,\"feature.legacy\":false,\"params.upload.maxSizeMb\":200,\"ui.theme\":{\"logoVariant\":\"light\",\"nested\":{\"keep\":true},\"primary\":\"#0066FF\"},\"ui.theme.primary\":\"atomic-dotted-key\"}}", + "canonicalPayloadBase64Url": "eyJhcHBseU1vZGVzIjp7ImFwcC5kaXNwbGF5TmFtZSI6ImNvbGQiLCJjb250ZW50LmhvbWUuYmFubmVyIjoiaG90IiwiY29udGVudC5pdGVtcyI6ImhvdCIsImZlYXR1cmUuYWlBc3Npc3QiOiJob3QiLCJmZWF0dXJlLmxlZ2FjeSI6ImNvbGQiLCJmZWF0dXJlLm5ld0VkaXRvciI6ImNvbGQiLCJwYXJhbXMudXBsb2FkLm1heFNpemVNYiI6ImNvbGQiLCJ1aS50aGVtZSI6ImhvdCIsInVpLnRoZW1lLnByaW1hcnkiOiJob3QifSwiYnJhbmRJZCI6ImFjbWUiLCJjaGFubmVsIjoiY2FuYXJ5IiwiY29uZmlnVmVyc2lvbiI6IjIwMjYuMDguMDMtMDQxIiwiY29udHJhY3RWZXJzaW9uIjoxLCJnZW5lcmF0ZWRBdCI6IjIwMjYtMDgtMDNUMDk6MTQ6MDBaIiwib3ZlcnJpZGVzIjpbeyJzZXQiOnsiZmVhdHVyZS5uZXdFZGl0b3IiOnRydWV9LCJ3aGVuIjp7ImFwcFZlcnNpb24iOiI-PTIuMy4wIDwzLjAuMCJ9fSx7InNldCI6eyJwYXJhbXMudXBsb2FkLm1heFNpemVNYiI6MTAwfSwid2hlbiI6eyJvcyI6IndpbmRvd3MifX0seyJzZXQiOnsiY29udGVudC5ob21lLmJhbm5lciI6eyJ0aXRsZSI6IuS9oOWlvSJ9fSwid2hlbiI6eyJsb2NhbGUiOiJ6aCJ9fV0sInBsYXRmb3JtIjoiZGVza3RvcCIsInJvbGxvdXRzIjp7ImZlYXR1cmUuYWlBc3Npc3QiOnsiYmFzaXNQb2ludHMiOjIwMDAsInNhbHQiOiJyNyIsInZhbHVlIjp0cnVlfX0sInNjaGVtYVZlcnNpb24iOjEsInZhbHVlcyI6eyJhcHAuZGlzcGxheU5hbWUiOiJBY21lIFN0dWRpbyIsImNvbnRlbnQuaG9tZS5iYW5uZXIiOnsidGl0bGUiOiJCdWlsZCB3aXRoIExpbmtDb2RlIiwidXJsIjoiaHR0cHM6Ly9saW5rY29kZS5haS9kb2NzIn0sImNvbnRlbnQuaXRlbXMiOlsib25lIiwidHdvIl0sImZlYXR1cmUuYWlBc3Npc3QiOmZhbHNlLCJmZWF0dXJlLmxlZ2FjeSI6ZmFsc2UsInBhcmFtcy51cGxvYWQubWF4U2l6ZU1iIjoyMDAsInVpLnRoZW1lIjp7ImxvZ29WYXJpYW50IjoibGlnaHQiLCJuZXN0ZWQiOnsia2VlcCI6dHJ1ZX0sInByaW1hcnkiOiIjMDA2NkZGIn0sInVpLnRoZW1lLnByaW1hcnkiOiJhdG9taWMtZG90dGVkLWtleSJ9fQ", + "sha256": "f3482464e099c416e92feb19f9492458d0c1c9de8f74c89ca5d2d4a4d47600b9", + "sizeBytes": 1066 + }, + "previous": { + "document": { + "applyModes": { + "app.displayName": "cold", + "content.home.banner": "hot", + "content.items": "hot", + "feature.aiAssist": "hot", + "feature.legacy": "cold", + "feature.newEditor": "cold", + "params.upload.maxSizeMb": "cold", + "ui.theme": "hot", + "ui.theme.primary": "hot" + }, + "brandId": "acme", + "channel": "canary", + "contractVersion": 1, + "overrides": [ + { + "set": { + "feature.newEditor": true + }, + "when": { + "appVersion": ">=2.3.0 <3.0.0" + } + }, + { + "set": { + "params.upload.maxSizeMb": 100 + }, + "when": { + "os": "windows" + } + }, + { + "set": { + "content.home.banner": { + "title": "你好" + } + }, + "when": { + "locale": "zh" + } + } + ], + "platform": "desktop", + "rollouts": { + "feature.aiAssist": { + "basisPoints": 2000, + "salt": "r7", + "value": true + } + }, + "schemaVersion": 1, + "configVersion": "2026.08.03-040", + "generatedAt": "2026-08-03T09:04:00Z", + "values": { + "app.displayName": "Acme Studio", + "content.home.banner": { + "title": "Welcome", + "url": "https://linkcode.ai" + }, + "content.items": [ + "one", + "two" + ], + "feature.aiAssist": false, + "feature.legacy": true, + "params.upload.maxSizeMb": 200, + "ui.theme": { + "logoVariant": "dark", + "nested": { + "drop": true, + "keep": true + }, + "primary": "#1D9E75" + }, + "ui.theme.primary": "atomic-dotted-key" + } + }, + "canonicalPayload": "{\"applyModes\":{\"app.displayName\":\"cold\",\"content.home.banner\":\"hot\",\"content.items\":\"hot\",\"feature.aiAssist\":\"hot\",\"feature.legacy\":\"cold\",\"feature.newEditor\":\"cold\",\"params.upload.maxSizeMb\":\"cold\",\"ui.theme\":\"hot\",\"ui.theme.primary\":\"hot\"},\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-040\",\"contractVersion\":1,\"generatedAt\":\"2026-08-03T09:04:00Z\",\"overrides\":[{\"set\":{\"feature.newEditor\":true},\"when\":{\"appVersion\":\">=2.3.0 <3.0.0\"}},{\"set\":{\"params.upload.maxSizeMb\":100},\"when\":{\"os\":\"windows\"}},{\"set\":{\"content.home.banner\":{\"title\":\"你好\"}},\"when\":{\"locale\":\"zh\"}}],\"platform\":\"desktop\",\"rollouts\":{\"feature.aiAssist\":{\"basisPoints\":2000,\"salt\":\"r7\",\"value\":true}},\"schemaVersion\":1,\"values\":{\"app.displayName\":\"Acme Studio\",\"content.home.banner\":{\"title\":\"Welcome\",\"url\":\"https://linkcode.ai\"},\"content.items\":[\"one\",\"two\"],\"feature.aiAssist\":false,\"feature.legacy\":true,\"params.upload.maxSizeMb\":200,\"ui.theme\":{\"logoVariant\":\"dark\",\"nested\":{\"drop\":true,\"keep\":true},\"primary\":\"#1D9E75\"},\"ui.theme.primary\":\"atomic-dotted-key\"}}", + "canonicalPayloadBase64Url": "eyJhcHBseU1vZGVzIjp7ImFwcC5kaXNwbGF5TmFtZSI6ImNvbGQiLCJjb250ZW50LmhvbWUuYmFubmVyIjoiaG90IiwiY29udGVudC5pdGVtcyI6ImhvdCIsImZlYXR1cmUuYWlBc3Npc3QiOiJob3QiLCJmZWF0dXJlLmxlZ2FjeSI6ImNvbGQiLCJmZWF0dXJlLm5ld0VkaXRvciI6ImNvbGQiLCJwYXJhbXMudXBsb2FkLm1heFNpemVNYiI6ImNvbGQiLCJ1aS50aGVtZSI6ImhvdCIsInVpLnRoZW1lLnByaW1hcnkiOiJob3QifSwiYnJhbmRJZCI6ImFjbWUiLCJjaGFubmVsIjoiY2FuYXJ5IiwiY29uZmlnVmVyc2lvbiI6IjIwMjYuMDguMDMtMDQwIiwiY29udHJhY3RWZXJzaW9uIjoxLCJnZW5lcmF0ZWRBdCI6IjIwMjYtMDgtMDNUMDk6MDQ6MDBaIiwib3ZlcnJpZGVzIjpbeyJzZXQiOnsiZmVhdHVyZS5uZXdFZGl0b3IiOnRydWV9LCJ3aGVuIjp7ImFwcFZlcnNpb24iOiI-PTIuMy4wIDwzLjAuMCJ9fSx7InNldCI6eyJwYXJhbXMudXBsb2FkLm1heFNpemVNYiI6MTAwfSwid2hlbiI6eyJvcyI6IndpbmRvd3MifX0seyJzZXQiOnsiY29udGVudC5ob21lLmJhbm5lciI6eyJ0aXRsZSI6IuS9oOWlvSJ9fSwid2hlbiI6eyJsb2NhbGUiOiJ6aCJ9fV0sInBsYXRmb3JtIjoiZGVza3RvcCIsInJvbGxvdXRzIjp7ImZlYXR1cmUuYWlBc3Npc3QiOnsiYmFzaXNQb2ludHMiOjIwMDAsInNhbHQiOiJyNyIsInZhbHVlIjp0cnVlfX0sInNjaGVtYVZlcnNpb24iOjEsInZhbHVlcyI6eyJhcHAuZGlzcGxheU5hbWUiOiJBY21lIFN0dWRpbyIsImNvbnRlbnQuaG9tZS5iYW5uZXIiOnsidGl0bGUiOiJXZWxjb21lIiwidXJsIjoiaHR0cHM6Ly9saW5rY29kZS5haSJ9LCJjb250ZW50Lml0ZW1zIjpbIm9uZSIsInR3byJdLCJmZWF0dXJlLmFpQXNzaXN0IjpmYWxzZSwiZmVhdHVyZS5sZWdhY3kiOnRydWUsInBhcmFtcy51cGxvYWQubWF4U2l6ZU1iIjoyMDAsInVpLnRoZW1lIjp7ImxvZ29WYXJpYW50IjoiZGFyayIsIm5lc3RlZCI6eyJkcm9wIjp0cnVlLCJrZWVwIjp0cnVlfSwicHJpbWFyeSI6IiMxRDlFNzUifSwidWkudGhlbWUucHJpbWFyeSI6ImF0b21pYy1kb3R0ZWQta2V5In19", + "sha256": "2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf", + "sizeBytes": 1059 + }, + "tampered": { + "canonicalPayload": "{\"applyModes\":{\"app.displayName\":\"cold\",\"content.home.banner\":\"hot\",\"content.items\":\"hot\",\"feature.aiAssist\":\"hot\",\"feature.legacy\":\"cold\",\"feature.newEditor\":\"cold\",\"params.upload.maxSizeMb\":\"cold\",\"ui.theme\":\"hot\",\"ui.theme.primary\":\"hot\"},\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-041\",\"contractVersion\":1,\"generatedAt\":\"2026-08-03T09:14:00Z\",\"overrides\":[{\"set\":{\"feature.newEditor\":true},\"when\":{\"appVersion\":\">=2.3.0 <3.0.0\"}},{\"set\":{\"params.upload.maxSizeMb\":100},\"when\":{\"os\":\"windows\"}},{\"set\":{\"content.home.banner\":{\"title\":\"你好\"}},\"when\":{\"locale\":\"zh\"}}],\"platform\":\"desktop\",\"rollouts\":{\"feature.aiAssist\":{\"basisPoints\":2000,\"salt\":\"r7\",\"value\":true}},\"schemaVersion\":1,\"values\":{\"app.displayName\":\"Acme Studio\",\"content.home.banner\":{\"title\":\"Build with LinkCode\",\"url\":\"https://linkcode.ai/docs\"},\"content.items\":[\"one\",\"two\"],\"feature.aiAssist\":true,\"feature.legacy\":false,\"params.upload.maxSizeMb\":200,\"ui.theme\":{\"logoVariant\":\"light\",\"nested\":{\"keep\":true},\"primary\":\"#0066FF\"},\"ui.theme.primary\":\"atomic-dotted-key\"}}", + "canonicalPayloadBase64Url": "eyJhcHBseU1vZGVzIjp7ImFwcC5kaXNwbGF5TmFtZSI6ImNvbGQiLCJjb250ZW50LmhvbWUuYmFubmVyIjoiaG90IiwiY29udGVudC5pdGVtcyI6ImhvdCIsImZlYXR1cmUuYWlBc3Npc3QiOiJob3QiLCJmZWF0dXJlLmxlZ2FjeSI6ImNvbGQiLCJmZWF0dXJlLm5ld0VkaXRvciI6ImNvbGQiLCJwYXJhbXMudXBsb2FkLm1heFNpemVNYiI6ImNvbGQiLCJ1aS50aGVtZSI6ImhvdCIsInVpLnRoZW1lLnByaW1hcnkiOiJob3QifSwiYnJhbmRJZCI6ImFjbWUiLCJjaGFubmVsIjoiY2FuYXJ5IiwiY29uZmlnVmVyc2lvbiI6IjIwMjYuMDguMDMtMDQxIiwiY29udHJhY3RWZXJzaW9uIjoxLCJnZW5lcmF0ZWRBdCI6IjIwMjYtMDgtMDNUMDk6MTQ6MDBaIiwib3ZlcnJpZGVzIjpbeyJzZXQiOnsiZmVhdHVyZS5uZXdFZGl0b3IiOnRydWV9LCJ3aGVuIjp7ImFwcFZlcnNpb24iOiI-PTIuMy4wIDwzLjAuMCJ9fSx7InNldCI6eyJwYXJhbXMudXBsb2FkLm1heFNpemVNYiI6MTAwfSwid2hlbiI6eyJvcyI6IndpbmRvd3MifX0seyJzZXQiOnsiY29udGVudC5ob21lLmJhbm5lciI6eyJ0aXRsZSI6IuS9oOWlvSJ9fSwid2hlbiI6eyJsb2NhbGUiOiJ6aCJ9fV0sInBsYXRmb3JtIjoiZGVza3RvcCIsInJvbGxvdXRzIjp7ImZlYXR1cmUuYWlBc3Npc3QiOnsiYmFzaXNQb2ludHMiOjIwMDAsInNhbHQiOiJyNyIsInZhbHVlIjp0cnVlfX0sInNjaGVtYVZlcnNpb24iOjEsInZhbHVlcyI6eyJhcHAuZGlzcGxheU5hbWUiOiJBY21lIFN0dWRpbyIsImNvbnRlbnQuaG9tZS5iYW5uZXIiOnsidGl0bGUiOiJCdWlsZCB3aXRoIExpbmtDb2RlIiwidXJsIjoiaHR0cHM6Ly9saW5rY29kZS5haS9kb2NzIn0sImNvbnRlbnQuaXRlbXMiOlsib25lIiwidHdvIl0sImZlYXR1cmUuYWlBc3Npc3QiOnRydWUsImZlYXR1cmUubGVnYWN5IjpmYWxzZSwicGFyYW1zLnVwbG9hZC5tYXhTaXplTWIiOjIwMCwidWkudGhlbWUiOnsibG9nb1ZhcmlhbnQiOiJsaWdodCIsIm5lc3RlZCI6eyJrZWVwIjp0cnVlfSwicHJpbWFyeSI6IiMwMDY2RkYifSwidWkudGhlbWUucHJpbWFyeSI6ImF0b21pYy1kb3R0ZWQta2V5In19", + "document": { + "applyModes": { + "app.displayName": "cold", + "content.home.banner": "hot", + "content.items": "hot", + "feature.aiAssist": "hot", + "feature.legacy": "cold", + "feature.newEditor": "cold", + "params.upload.maxSizeMb": "cold", + "ui.theme": "hot", + "ui.theme.primary": "hot" + }, + "brandId": "acme", + "channel": "canary", + "contractVersion": 1, + "overrides": [ + { + "set": { + "feature.newEditor": true + }, + "when": { + "appVersion": ">=2.3.0 <3.0.0" + } + }, + { + "set": { + "params.upload.maxSizeMb": 100 + }, + "when": { + "os": "windows" + } + }, + { + "set": { + "content.home.banner": { + "title": "你好" + } + }, + "when": { + "locale": "zh" + } + } + ], + "platform": "desktop", + "rollouts": { + "feature.aiAssist": { + "basisPoints": 2000, + "salt": "r7", + "value": true + } + }, + "schemaVersion": 1, + "configVersion": "2026.08.03-041", + "generatedAt": "2026-08-03T09:14:00Z", + "values": { + "app.displayName": "Acme Studio", + "content.home.banner": { + "title": "Build with LinkCode", + "url": "https://linkcode.ai/docs" + }, + "content.items": [ + "one", + "two" + ], + "feature.aiAssist": true, + "feature.legacy": false, + "params.upload.maxSizeMb": 200, + "ui.theme": { + "logoVariant": "light", + "nested": { + "keep": true + }, + "primary": "#0066FF" + }, + "ui.theme.primary": "atomic-dotted-key" + } + }, + "expectedPointerSha256": "f3482464e099c416e92feb19f9492458d0c1c9de8f74c89ca5d2d4a4d47600b9", + "tamperedSha256": "713027f9487978b373aba0ef6994f0fe75689fdcf99a389b3221c5a8aab5a348" + } + }, + "pointers": { + "additive": { + "document": { + "activationVersion": "103", + "brandId": "acme", + "channel": "canary", + "configVersion": "2026.08.03-040", + "contractVersion": 1, + "createdAt": "2026-08-03T09:03:00Z", + "keyId": "normal-rfc8032-3", + "platform": "desktop", + "sha256": "2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf", + "sizeBytes": 1059, + "snapshotSchemaVersion": 1, + "futureHint": "optional-and-signed", + "sig": "nq35PGeIJOz0B6hR9KSk8dQR0NA9aAK3rtDIhQWL0SmHfaSMpGNJzF3lmdw8fhgbjFT05-ptbqgXert8i3uhCw" + }, + "canonicalPayload": "{\"activationVersion\":\"103\",\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-040\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T09:03:00Z\",\"futureHint\":\"optional-and-signed\",\"keyId\":\"normal-rfc8032-3\",\"platform\":\"desktop\",\"sha256\":\"2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf\",\"sizeBytes\":1059,\"snapshotSchemaVersion\":1}", + "canonicalPayloadBase64Url": "eyJhY3RpdmF0aW9uVmVyc2lvbiI6IjEwMyIsImJyYW5kSWQiOiJhY21lIiwiY2hhbm5lbCI6ImNhbmFyeSIsImNvbmZpZ1ZlcnNpb24iOiIyMDI2LjA4LjAzLTA0MCIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QwOTowMzowMFoiLCJmdXR1cmVIaW50Ijoib3B0aW9uYWwtYW5kLXNpZ25lZCIsImtleUlkIjoibm9ybWFsLXJmYzgwMzItMyIsInBsYXRmb3JtIjoiZGVza3RvcCIsInNoYTI1NiI6IjJmNmUxMDMxOTMyZGU3OThjMTZiNDU3ZDc0ODY5ZTFmZmQ2ZTJkMDFkMDNlOWIyYjZjMjBlY2Y1ODc2MzhlYWYiLCJzaXplQnl0ZXMiOjEwNTksInNuYXBzaG90U2NoZW1hVmVyc2lvbiI6MX0", + "payloadSha256": "ee7279a59d0de73fcdd8c177daf9b668475ed371188d33fe676dd9eae02f7487" + }, + "normal": { + "document": { + "activationVersion": "100", + "brandId": "acme", + "channel": "canary", + "configVersion": "2026.08.03-041", + "contractVersion": 1, + "createdAt": "2026-08-03T09:00:00Z", + "keyId": "normal-rfc8032-2", + "platform": "desktop", + "sha256": "f3482464e099c416e92feb19f9492458d0c1c9de8f74c89ca5d2d4a4d47600b9", + "sizeBytes": 1066, + "snapshotSchemaVersion": 1, + "sig": "fYcXaWlPfNAI0pw4tTJHbl46OeZHTfvPuUFAoAPEu4Eou4h2jmgamHglIYnQCTBY7_l88AXbgILF9PbxrnfHCQ" + }, + "canonicalPayload": "{\"activationVersion\":\"100\",\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-041\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T09:00:00Z\",\"keyId\":\"normal-rfc8032-2\",\"platform\":\"desktop\",\"sha256\":\"f3482464e099c416e92feb19f9492458d0c1c9de8f74c89ca5d2d4a4d47600b9\",\"sizeBytes\":1066,\"snapshotSchemaVersion\":1}", + "canonicalPayloadBase64Url": "eyJhY3RpdmF0aW9uVmVyc2lvbiI6IjEwMCIsImJyYW5kSWQiOiJhY21lIiwiY2hhbm5lbCI6ImNhbmFyeSIsImNvbmZpZ1ZlcnNpb24iOiIyMDI2LjA4LjAzLTA0MSIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QwOTowMDowMFoiLCJrZXlJZCI6Im5vcm1hbC1yZmM4MDMyLTIiLCJwbGF0Zm9ybSI6ImRlc2t0b3AiLCJzaGEyNTYiOiJmMzQ4MjQ2NGUwOTljNDE2ZTkyZmViMTlmOTQ5MjQ1OGQwYzFjOWRlOGY3NGM4OWNhNWQyZDRhNGQ0NzYwMGI5Iiwic2l6ZUJ5dGVzIjoxMDY2LCJzbmFwc2hvdFNjaGVtYVZlcnNpb24iOjF9", + "payloadSha256": "750c27c7ba6bd58fb6fa20e7431b9012d6d2474b79a3015e8133a4abaca78779" + }, + "rollback": { + "document": { + "activationVersion": "101", + "brandId": "acme", + "channel": "canary", + "configVersion": "2026.08.03-040", + "contractVersion": 1, + "createdAt": "2026-08-03T09:01:00Z", + "keyId": "normal-rfc8032-2", + "platform": "desktop", + "sha256": "2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf", + "sizeBytes": 1059, + "snapshotSchemaVersion": 1, + "sig": "Z5P4paQBhPz22F4K3lfsA65VF0efEUifR2qpOuE9S_VK5ZU4GeGBV0aLMLIYEgRk2UrSHkDd3sysQoidVlAUDA" + }, + "canonicalPayload": "{\"activationVersion\":\"101\",\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-040\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T09:01:00Z\",\"keyId\":\"normal-rfc8032-2\",\"platform\":\"desktop\",\"sha256\":\"2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf\",\"sizeBytes\":1059,\"snapshotSchemaVersion\":1}", + "canonicalPayloadBase64Url": "eyJhY3RpdmF0aW9uVmVyc2lvbiI6IjEwMSIsImJyYW5kSWQiOiJhY21lIiwiY2hhbm5lbCI6ImNhbmFyeSIsImNvbmZpZ1ZlcnNpb24iOiIyMDI2LjA4LjAzLTA0MCIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QwOTowMTowMFoiLCJrZXlJZCI6Im5vcm1hbC1yZmM4MDMyLTIiLCJwbGF0Zm9ybSI6ImRlc2t0b3AiLCJzaGEyNTYiOiIyZjZlMTAzMTkzMmRlNzk4YzE2YjQ1N2Q3NDg2OWUxZmZkNmUyZDAxZDAzZTliMmI2YzIwZWNmNTg3NjM4ZWFmIiwic2l6ZUJ5dGVzIjoxMDU5LCJzbmFwc2hvdFNjaGVtYVZlcnNpb24iOjF9", + "payloadSha256": "003ffe6ec4bc0b42793b0db716b5009f37c1c79c8204f875629900a371ba75a8" + }, + "rotation": { + "document": { + "activationVersion": "102", + "brandId": "acme", + "channel": "canary", + "configVersion": "2026.08.03-040", + "contractVersion": 1, + "createdAt": "2026-08-03T09:02:00Z", + "keyId": "normal-rfc8032-3", + "platform": "desktop", + "sha256": "2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf", + "sizeBytes": 1059, + "snapshotSchemaVersion": 1, + "sig": "upaeClxiaNnYSGjxznQWAzGZekZnE8cHmiozAXMbXIgm8491R8urSlU67ftRnay9wwfohK1T4ZhRYQMnWfalDA" + }, + "canonicalPayload": "{\"activationVersion\":\"102\",\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-040\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T09:02:00Z\",\"keyId\":\"normal-rfc8032-3\",\"platform\":\"desktop\",\"sha256\":\"2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf\",\"sizeBytes\":1059,\"snapshotSchemaVersion\":1}", + "canonicalPayloadBase64Url": "eyJhY3RpdmF0aW9uVmVyc2lvbiI6IjEwMiIsImJyYW5kSWQiOiJhY21lIiwiY2hhbm5lbCI6ImNhbmFyeSIsImNvbmZpZ1ZlcnNpb24iOiIyMDI2LjA4LjAzLTA0MCIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QwOTowMjowMFoiLCJrZXlJZCI6Im5vcm1hbC1yZmM4MDMyLTMiLCJwbGF0Zm9ybSI6ImRlc2t0b3AiLCJzaGEyNTYiOiIyZjZlMTAzMTkzMmRlNzk4YzE2YjQ1N2Q3NDg2OWUxZmZkNmUyZDAxZDAzZTliMmI2YzIwZWNmNTg3NjM4ZWFmIiwic2l6ZUJ5dGVzIjoxMDU5LCJzbmFwc2hvdFNjaGVtYVZlcnNpb24iOjF9", + "payloadSha256": "705727ddbbdc96dd32f51857b073e590fc1b7dbea4967ea93a39168cd753fc42" + }, + "rotationWithoutBump": { + "document": { + "activationVersion": "101", + "brandId": "acme", + "channel": "canary", + "configVersion": "2026.08.03-040", + "contractVersion": 1, + "createdAt": "2026-08-03T09:01:00Z", + "keyId": "normal-rfc8032-3", + "platform": "desktop", + "sha256": "2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf", + "sizeBytes": 1059, + "snapshotSchemaVersion": 1, + "sig": "WLw3jGEsE7Xak5G7zYQpwBYcUUZEJZ9og4AJBuPbNRweBW4VzIUUiKH7CUNliMWXNET3qhSbMM5h6Z9f3lqFDg" + }, + "canonicalPayload": "{\"activationVersion\":\"101\",\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-040\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T09:01:00Z\",\"keyId\":\"normal-rfc8032-3\",\"platform\":\"desktop\",\"sha256\":\"2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf\",\"sizeBytes\":1059,\"snapshotSchemaVersion\":1}", + "canonicalPayloadBase64Url": "eyJhY3RpdmF0aW9uVmVyc2lvbiI6IjEwMSIsImJyYW5kSWQiOiJhY21lIiwiY2hhbm5lbCI6ImNhbmFyeSIsImNvbmZpZ1ZlcnNpb24iOiIyMDI2LjA4LjAzLTA0MCIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QwOTowMTowMFoiLCJrZXlJZCI6Im5vcm1hbC1yZmM4MDMyLTMiLCJwbGF0Zm9ybSI6ImRlc2t0b3AiLCJzaGEyNTYiOiIyZjZlMTAzMTkzMmRlNzk4YzE2YjQ1N2Q3NDg2OWUxZmZkNmUyZDAxZDAzZTliMmI2YzIwZWNmNTg3NjM4ZWFmIiwic2l6ZUJ5dGVzIjoxMDU5LCJzbmFwc2hvdFNjaGVtYVZlcnNpb24iOjF9", + "payloadSha256": "d082b633ee3a9ee2677d5b8d8fdb826122d2e44b6be62e0f55c7f5a3f4f11c5f" + }, + "schemaTooNew": { + "document": { + "activationVersion": "104", + "brandId": "acme", + "channel": "canary", + "configVersion": "2026.08.03-040", + "contractVersion": 1, + "createdAt": "2026-08-03T09:04:00Z", + "keyId": "normal-rfc8032-3", + "platform": "desktop", + "sha256": "2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf", + "sizeBytes": 1059, + "snapshotSchemaVersion": 2, + "sig": "4DDyPTTOC1Jxd6KU-tq3GoBcWstnO29tjhjlQEQC2QKxxjxmy11AgzxtfSFlNLNdy3tHg3kW3POZBEUMf7pECg" + }, + "canonicalPayload": "{\"activationVersion\":\"104\",\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-040\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T09:04:00Z\",\"keyId\":\"normal-rfc8032-3\",\"platform\":\"desktop\",\"sha256\":\"2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf\",\"sizeBytes\":1059,\"snapshotSchemaVersion\":2}", + "canonicalPayloadBase64Url": "eyJhY3RpdmF0aW9uVmVyc2lvbiI6IjEwNCIsImJyYW5kSWQiOiJhY21lIiwiY2hhbm5lbCI6ImNhbmFyeSIsImNvbmZpZ1ZlcnNpb24iOiIyMDI2LjA4LjAzLTA0MCIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QwOTowNDowMFoiLCJrZXlJZCI6Im5vcm1hbC1yZmM4MDMyLTMiLCJwbGF0Zm9ybSI6ImRlc2t0b3AiLCJzaGEyNTYiOiIyZjZlMTAzMTkzMmRlNzk4YzE2YjQ1N2Q3NDg2OWUxZmZkNmUyZDAxZDAzZTliMmI2YzIwZWNmNTg3NjM4ZWFmIiwic2l6ZUJ5dGVzIjoxMDU5LCJzbmFwc2hvdFNjaGVtYVZlcnNpb24iOjJ9", + "payloadSha256": "201ffcd5985c9ed5eabb4792513e1875287095758941917385d595620c8d7fa8" + }, + "tampered": { + "document": { + "activationVersion": "100", + "brandId": "acme", + "channel": "canary", + "configVersion": "2026.08.03-041", + "contractVersion": 1, + "createdAt": "2026-08-03T09:00:00Z", + "keyId": "normal-rfc8032-2", + "platform": "desktop", + "sha256": "f3482464e099c416e92feb19f9492458d0c1c9de8f74c89ca5d2d4a4d47600b9", + "sizeBytes": 1067, + "snapshotSchemaVersion": 1, + "sig": "fYcXaWlPfNAI0pw4tTJHbl46OeZHTfvPuUFAoAPEu4Eou4h2jmgamHglIYnQCTBY7_l88AXbgILF9PbxrnfHCQ" + }, + "canonicalPayload": "{\"activationVersion\":\"100\",\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-041\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T09:00:00Z\",\"keyId\":\"normal-rfc8032-2\",\"platform\":\"desktop\",\"sha256\":\"f3482464e099c416e92feb19f9492458d0c1c9de8f74c89ca5d2d4a4d47600b9\",\"sizeBytes\":1066,\"snapshotSchemaVersion\":1}", + "canonicalPayloadBase64Url": "eyJhY3RpdmF0aW9uVmVyc2lvbiI6IjEwMCIsImJyYW5kSWQiOiJhY21lIiwiY2hhbm5lbCI6ImNhbmFyeSIsImNvbmZpZ1ZlcnNpb24iOiIyMDI2LjA4LjAzLTA0MSIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QwOTowMDowMFoiLCJrZXlJZCI6Im5vcm1hbC1yZmM4MDMyLTIiLCJwbGF0Zm9ybSI6ImRlc2t0b3AiLCJzaGEyNTYiOiJmMzQ4MjQ2NGUwOTljNDE2ZTkyZmViMTlmOTQ5MjQ1OGQwYzFjOWRlOGY3NGM4OWNhNWQyZDRhNGQ0NzYwMGI5Iiwic2l6ZUJ5dGVzIjoxMDY2LCJzbmFwc2hvdFNjaGVtYVZlcnNpb24iOjF9", + "payloadSha256": "750c27c7ba6bd58fb6fa20e7431b9012d6d2474b79a3015e8133a4abaca78779" + }, + "unknownKey": { + "document": { + "activationVersion": "105", + "brandId": "acme", + "channel": "canary", + "configVersion": "2026.08.03-040", + "contractVersion": 1, + "createdAt": "2026-08-03T09:05:00Z", + "keyId": "unknown-normal-key", + "platform": "desktop", + "sha256": "2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf", + "sizeBytes": 1059, + "snapshotSchemaVersion": 1, + "sig": "yizsD3_PD-3r9hrdEfDPAT7PBz8S3kZ_T-lsweOUp_LXUz7Ww_mccJJq3OTRWz4NAyaCG_fxY5_9HW1aMj-NDg" + }, + "canonicalPayload": "{\"activationVersion\":\"105\",\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-040\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T09:05:00Z\",\"keyId\":\"unknown-normal-key\",\"platform\":\"desktop\",\"sha256\":\"2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf\",\"sizeBytes\":1059,\"snapshotSchemaVersion\":1}", + "canonicalPayloadBase64Url": "eyJhY3RpdmF0aW9uVmVyc2lvbiI6IjEwNSIsImJyYW5kSWQiOiJhY21lIiwiY2hhbm5lbCI6ImNhbmFyeSIsImNvbmZpZ1ZlcnNpb24iOiIyMDI2LjA4LjAzLTA0MCIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QwOTowNTowMFoiLCJrZXlJZCI6InVua25vd24tbm9ybWFsLWtleSIsInBsYXRmb3JtIjoiZGVza3RvcCIsInNoYTI1NiI6IjJmNmUxMDMxOTMyZGU3OThjMTZiNDU3ZDc0ODY5ZTFmZmQ2ZTJkMDFkMDNlOWIyYjZjMjBlY2Y1ODc2MzhlYWYiLCJzaXplQnl0ZXMiOjEwNTksInNuYXBzaG90U2NoZW1hVmVyc2lvbiI6MX0", + "payloadSha256": "a4b87a8c170133c367fa4a77068d064a3ee554f5864af55c1fdbd6386332d526" + }, + "unsupportedContract": { + "document": { + "activationVersion": "105", + "brandId": "acme", + "channel": "canary", + "configVersion": "2026.08.03-040", + "contractVersion": 2, + "createdAt": "2026-08-03T09:05:00Z", + "keyId": "normal-rfc8032-3", + "platform": "desktop", + "sha256": "2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf", + "sizeBytes": 1059, + "snapshotSchemaVersion": 1, + "sig": "w-sb-Rkobps_4utawo8SzfI-utlKZ190aWJq3-wzCuO6sK-FofMnuQLkgRYXvn3AV1kOQHSyem1HleqfshoRBA" + }, + "canonicalPayload": "{\"activationVersion\":\"105\",\"brandId\":\"acme\",\"channel\":\"canary\",\"configVersion\":\"2026.08.03-040\",\"contractVersion\":2,\"createdAt\":\"2026-08-03T09:05:00Z\",\"keyId\":\"normal-rfc8032-3\",\"platform\":\"desktop\",\"sha256\":\"2f6e1031932de798c16b457d74869e1ffd6e2d01d03e9b2b6c20ecf587638eaf\",\"sizeBytes\":1059,\"snapshotSchemaVersion\":1}", + "canonicalPayloadBase64Url": "eyJhY3RpdmF0aW9uVmVyc2lvbiI6IjEwNSIsImJyYW5kSWQiOiJhY21lIiwiY2hhbm5lbCI6ImNhbmFyeSIsImNvbmZpZ1ZlcnNpb24iOiIyMDI2LjA4LjAzLTA0MCIsImNvbnRyYWN0VmVyc2lvbiI6MiwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QwOTowNTowMFoiLCJrZXlJZCI6Im5vcm1hbC1yZmM4MDMyLTMiLCJwbGF0Zm9ybSI6ImRlc2t0b3AiLCJzaGEyNTYiOiIyZjZlMTAzMTkzMmRlNzk4YzE2YjQ1N2Q3NDg2OWUxZmZkNmUyZDAxZDAzZTliMmI2YzIwZWNmNTg3NjM4ZWFmIiwic2l6ZUJ5dGVzIjoxMDU5LCJzbmFwc2hvdFNjaGVtYVZlcnNpb24iOjF9", + "payloadSha256": "659acd82a7e1f118fdb1bcc3179c2ea04200a4ead3dd06829e84d5fd0262be29" + } + }, + "emergencies": { + "active": { + "document": { + "brandId": "acme", + "contractVersion": 1, + "createdAt": "2026-08-03T10:07:00Z", + "disabledFeatures": [ + "feature.aiAssist" + ], + "emergencyVersion": "7", + "forceMinVersion": "2.4.0", + "keyId": "emergency-rfc8032-1", + "notice": { + "body": "Update LinkCode to continue using this feature.", + "title": "Update required", + "url": "https://linkcode.ai/download" + }, + "platform": "desktop", + "sig": "vsJryxk6MdMcbrQwO8iQx_qAwV7OaCnFnZGeGuCmayKhVq2uTex0LRNSu7UcG-e7YrMkAzPTN8LmKe7LuTNWCg" + }, + "canonicalPayload": "{\"brandId\":\"acme\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T10:07:00Z\",\"disabledFeatures\":[\"feature.aiAssist\"],\"emergencyVersion\":\"7\",\"forceMinVersion\":\"2.4.0\",\"keyId\":\"emergency-rfc8032-1\",\"notice\":{\"body\":\"Update LinkCode to continue using this feature.\",\"title\":\"Update required\",\"url\":\"https://linkcode.ai/download\"},\"platform\":\"desktop\"}", + "canonicalPayloadBase64Url": "eyJicmFuZElkIjoiYWNtZSIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QxMDowNzowMFoiLCJkaXNhYmxlZEZlYXR1cmVzIjpbImZlYXR1cmUuYWlBc3Npc3QiXSwiZW1lcmdlbmN5VmVyc2lvbiI6IjciLCJmb3JjZU1pblZlcnNpb24iOiIyLjQuMCIsImtleUlkIjoiZW1lcmdlbmN5LXJmYzgwMzItMSIsIm5vdGljZSI6eyJib2R5IjoiVXBkYXRlIExpbmtDb2RlIHRvIGNvbnRpbnVlIHVzaW5nIHRoaXMgZmVhdHVyZS4iLCJ0aXRsZSI6IlVwZGF0ZSByZXF1aXJlZCIsInVybCI6Imh0dHBzOi8vbGlua2NvZGUuYWkvZG93bmxvYWQifSwicGxhdGZvcm0iOiJkZXNrdG9wIn0", + "payloadSha256": "d12d5f3d0661ef9c466c79558b40445a1be7d635ac30c3dd32396933af072cee" + }, + "clear": { + "document": { + "brandId": "acme", + "contractVersion": 1, + "createdAt": "2026-08-03T10:08:00Z", + "disabledFeatures": [], + "emergencyVersion": "8", + "forceMinVersion": null, + "keyId": "emergency-rfc8032-1", + "notice": null, + "platform": "desktop", + "sig": "s7bKTZXRj7L8tucEgofMWMYSLgawgc2CZfcNmsSrz8p1na90xho9unRAr5xoql0NWCF__uNbvGqoBVZj3oKfBg" + }, + "canonicalPayload": "{\"brandId\":\"acme\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T10:08:00Z\",\"disabledFeatures\":[],\"emergencyVersion\":\"8\",\"forceMinVersion\":null,\"keyId\":\"emergency-rfc8032-1\",\"notice\":null,\"platform\":\"desktop\"}", + "canonicalPayloadBase64Url": "eyJicmFuZElkIjoiYWNtZSIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QxMDowODowMFoiLCJkaXNhYmxlZEZlYXR1cmVzIjpbXSwiZW1lcmdlbmN5VmVyc2lvbiI6IjgiLCJmb3JjZU1pblZlcnNpb24iOm51bGwsImtleUlkIjoiZW1lcmdlbmN5LXJmYzgwMzItMSIsIm5vdGljZSI6bnVsbCwicGxhdGZvcm0iOiJkZXNrdG9wIn0", + "payloadSha256": "6ad8ee532e319c0b1ed0539cf4a2bc7d15423c7a1103c6589b030864004f510a" + }, + "equivocation": { + "document": { + "brandId": "acme", + "contractVersion": 1, + "createdAt": "2026-08-03T10:08:00Z", + "disabledFeatures": [ + "feature.newEditor" + ], + "emergencyVersion": "8", + "forceMinVersion": null, + "keyId": "emergency-rfc8032-1", + "notice": null, + "platform": "desktop", + "sig": "ZvNluVESOvBDX_aFn8IewnGsCGN_Wu3UczKtSAxc-JP_RHaoXlbrMv5LQwBrDyWWeAhBrZuswrsAt7FMPZzcBw" + }, + "canonicalPayload": "{\"brandId\":\"acme\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T10:08:00Z\",\"disabledFeatures\":[\"feature.newEditor\"],\"emergencyVersion\":\"8\",\"forceMinVersion\":null,\"keyId\":\"emergency-rfc8032-1\",\"notice\":null,\"platform\":\"desktop\"}", + "canonicalPayloadBase64Url": "eyJicmFuZElkIjoiYWNtZSIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QxMDowODowMFoiLCJkaXNhYmxlZEZlYXR1cmVzIjpbImZlYXR1cmUubmV3RWRpdG9yIl0sImVtZXJnZW5jeVZlcnNpb24iOiI4IiwiZm9yY2VNaW5WZXJzaW9uIjpudWxsLCJrZXlJZCI6ImVtZXJnZW5jeS1yZmM4MDMyLTEiLCJub3RpY2UiOm51bGwsInBsYXRmb3JtIjoiZGVza3RvcCJ9", + "payloadSha256": "eaa9cdb463c558e8ec312a2207b96287344d734e3cb28974b5a2afa667f39ff9" + }, + "tampered": { + "document": { + "brandId": "acme", + "contractVersion": 1, + "createdAt": "2026-08-03T10:07:00Z", + "disabledFeatures": [ + "feature.aiAssist" + ], + "emergencyVersion": "7", + "forceMinVersion": "2.5.0", + "keyId": "emergency-rfc8032-1", + "notice": { + "body": "Update LinkCode to continue using this feature.", + "title": "Update required", + "url": "https://linkcode.ai/download" + }, + "platform": "desktop", + "sig": "vsJryxk6MdMcbrQwO8iQx_qAwV7OaCnFnZGeGuCmayKhVq2uTex0LRNSu7UcG-e7YrMkAzPTN8LmKe7LuTNWCg" + }, + "canonicalPayload": "{\"brandId\":\"acme\",\"contractVersion\":1,\"createdAt\":\"2026-08-03T10:07:00Z\",\"disabledFeatures\":[\"feature.aiAssist\"],\"emergencyVersion\":\"7\",\"forceMinVersion\":\"2.4.0\",\"keyId\":\"emergency-rfc8032-1\",\"notice\":{\"body\":\"Update LinkCode to continue using this feature.\",\"title\":\"Update required\",\"url\":\"https://linkcode.ai/download\"},\"platform\":\"desktop\"}", + "canonicalPayloadBase64Url": "eyJicmFuZElkIjoiYWNtZSIsImNvbnRyYWN0VmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0wOC0wM1QxMDowNzowMFoiLCJkaXNhYmxlZEZlYXR1cmVzIjpbImZlYXR1cmUuYWlBc3Npc3QiXSwiZW1lcmdlbmN5VmVyc2lvbiI6IjciLCJmb3JjZU1pblZlcnNpb24iOiIyLjQuMCIsImtleUlkIjoiZW1lcmdlbmN5LXJmYzgwMzItMSIsIm5vdGljZSI6eyJib2R5IjoiVXBkYXRlIExpbmtDb2RlIHRvIGNvbnRpbnVlIHVzaW5nIHRoaXMgZmVhdHVyZS4iLCJ0aXRsZSI6IlVwZGF0ZSByZXF1aXJlZCIsInVybCI6Imh0dHBzOi8vbGlua2NvZGUuYWkvZG93bmxvYWQifSwicGxhdGZvcm0iOiJkZXNrdG9wIn0", + "payloadSha256": "d12d5f3d0661ef9c466c79558b40445a1be7d635ac30c3dd32396933af072cee" + } + }, + "cases": { + "conditions": [ + { + "condition": { + "appVersion": ">=2.3.0" + }, + "context": { + "appVersion": "2.4.0-beta.1", + "locale": "en-US", + "os": "macos" + }, + "name": "SemVer prerelease uses direct precedence", + "expectedMatch": true + }, + { + "condition": { + "appVersion": ">=2.3.0 <3.0.0", + "os": "windows" + }, + "context": { + "appVersion": "2.5.0", + "locale": "en-US", + "os": "macos" + }, + "name": "conditions are ANDed", + "expectedMatch": false + }, + { + "condition": { + "locale": "zh" + }, + "context": { + "appVersion": "2.5.0", + "locale": "ZH_cn", + "os": "windows" + }, + "name": "locale underscore and case normalization", + "expectedMatch": true + }, + { + "condition": { + "locale": "zh" + }, + "context": { + "appVersion": "2.5.0", + "locale": "zha", + "os": "windows" + }, + "name": "locale subtag boundary", + "expectedMatch": false + }, + { + "condition": { + "appVersion": ">=2.3.0" + }, + "context": { + "appVersion": "not-semver", + "locale": "en", + "os": "linux" + }, + "name": "invalid runtime SemVer skips override", + "expectedMatch": false + } + ], + "emergencyAntiReplay": [ + { + "accepted": null, + "candidate": "active", + "name": "first emergency", + "expectedDecision": "advance" + }, + { + "accepted": "active", + "candidate": "active", + "name": "idempotent emergency", + "expectedDecision": "idempotent" + }, + { + "accepted": "active", + "candidate": "clear", + "name": "explicit clear", + "expectedDecision": "advance" + }, + { + "accepted": "clear", + "candidate": "active", + "name": "emergency replay", + "expectedDecision": "replay" + }, + { + "accepted": "clear", + "candidate": "equivocation", + "name": "emergency equivocation", + "expectedDecision": "equivocation" + } + ], + "keyRotation": { + "expectedWithOldKeyOnly": "unknown-key", + "expectedWithOverlap": "signature-valid", + "newKeyId": "normal-rfc8032-3", + "oldKeyId": "normal-rfc8032-2", + "pointer": "rotation", + "reusesSnapshotFrom": "rollback", + "versionWasIncremented": true + }, + "mergePatch": { + "base": { + "content.items": [ + "one", + "two" + ], + "feature.legacy": true, + "ui.theme": { + "logoVariant": "dark", + "nested": { + "drop": true, + "keep": true + }, + "primary": "#1D9E75" + }, + "ui.theme.primary": "independent" + }, + "patch": { + "content.items": [ + "replacement" + ], + "feature.legacy": null, + "ui.theme": { + "logoVariant": "light", + "nested": { + "add": true, + "drop": null + } + } + }, + "expected": { + "content.items": [ + "replacement" + ], + "ui.theme": { + "logoVariant": "light", + "nested": { + "keep": true, + "add": true + }, + "primary": "#1D9E75" + }, + "ui.theme.primary": "independent" + } + }, + "monotonicVersionErrors": [ + "", + "+1", + "01", + " 1", + "18446744073709551616", + "100000000000000000000" + ], + "pointerAntiReplay": [ + { + "accepted": null, + "candidate": "normal", + "name": "first publication", + "expectedDecision": "advance" + }, + { + "accepted": "normal", + "candidate": "normal", + "name": "idempotent retry", + "expectedDecision": "idempotent" + }, + { + "accepted": "normal", + "candidate": "rollback", + "name": "rollback", + "expectedDecision": "advance" + }, + { + "accepted": "rollback", + "candidate": "normal", + "name": "replay", + "expectedDecision": "replay" + }, + { + "accepted": "rollback", + "candidate": "rotationWithoutBump", + "name": "key change without version bump", + "expectedDecision": "equivocation" + }, + { + "accepted": "rollback", + "candidate": "rotation", + "name": "key rotation with version bump", + "expectedDecision": "advance" + }, + { + "accepted": "rotation", + "candidate": "additive", + "name": "signed additive field", + "expectedDecision": "advance" + }, + { + "accepted": "additive", + "candidate": "schemaTooNew", + "name": "unsupported schema advances trusted high-water", + "expectedDecision": "advance" + } + ], + "rollouts": [ + { + "deviceId": "550E8400-E29B-41D4-A716-446655440000", + "expectedBucket": 3949, + "expectedHitAtBoundary": false, + "expectedHitAboveBoundary": true, + "expectedHitAtZero": false, + "expectedHitAtFull": true, + "salt": "a" + }, + { + "deviceId": "550E8400-E29B-41D4-A716-446655440000", + "expectedBucket": 4782, + "expectedHitAtBoundary": false, + "expectedHitAboveBoundary": true, + "expectedHitAtZero": false, + "expectedHitAtFull": true, + "salt": "é" + }, + { + "deviceId": "550E8400-E29B-41D4-A716-446655440000", + "expectedBucket": 4340, + "expectedHitAtBoundary": false, + "expectedHitAboveBoundary": true, + "expectedHitAtZero": false, + "expectedHitAtFull": true, + "salt": "签" + } + ], + "tamper": [ + { + "expected": "signature-invalid", + "kind": "pointer", + "vector": "tampered" + }, + { + "expected": "raw-sha256-mismatch", + "kind": "snapshot", + "vector": "tampered" + }, + { + "expected": "signature-invalid", + "kind": "emergency", + "vector": "tampered" + } + ], + "targets": [ + { + "document": "normal", + "expectedMatch": true, + "name": "normal target" + }, + { + "document": "normal", + "expectedMatch": false, + "name": "cross-channel replay" + }, + { + "document": "active", + "expectedMatch": false, + "name": "cross-brand emergency replay" + } + ] + } +} diff --git a/packages/foundation/common/src/config/__tests__/contract.test.ts b/packages/foundation/common/src/config/__tests__/contract.test.ts new file mode 100644 index 00000000..0265fa9a --- /dev/null +++ b/packages/foundation/common/src/config/__tests__/contract.test.ts @@ -0,0 +1,300 @@ +import { hashes, verify } from '@noble/ed25519'; +import { sha256, sha512 } from '@noble/hashes/sha2.js'; +import { describe, expect, it } from 'vitest'; +import fixture from '../__fixtures__/contract-v1.json'; +import { + applyConfigPatch, + assertConfigPointer, + assertConfigSnapshot, + assertEmergencyDocument, + assertMonotonicVersion, + canonicalizeJson, + canonicalSignedPayload, + canonicalSignedPayloadBytes, + compareMonotonicVersions, + conditionMatches, + decideAntiReplay, + emergencyTargetMatches, + matchesVersionRange, + murmur3X86_32, + rolloutBucket, + rolloutMatches, + targetMatches, +} from '../contract'; +import { decodeBase64Url, encodeBase64Url, parseIJson } from '../i-json'; +import type { AntiReplayState, ConfigCrypto, OverrideCondition } from '../types'; +import { validateSnapshotBytes, verifyPointerBytes } from '../verification'; + +interface SignedVector { + readonly canonicalPayload: string; + readonly canonicalPayloadBase64Url: string; + readonly document: Record; + readonly payloadSha256: string; +} + +const pointers: Record = fixture.pointers; +const emergencies: Record = fixture.emergencies; +const snapshots = fixture.snapshots; +const normalKeys = fixture.keys.normal as Record; +const emergencyKeys = fixture.keys.emergency as Record; +const encoder = new TextEncoder(); +hashes.sha512 = sha512; +const crypto: ConfigCrypto = { + randomUuid: () => '550e8400-e29b-41d4-a716-446655440000', + sha256: (bytes) => Promise.resolve(sha256(bytes)), + verifyEd25519: (publicKey, signature, message) => + Promise.resolve(verify(signature, message, publicKey, { zip215: false })), +}; + +describe('configuration contract v1 golden fixture', () => { + it('locks exact snapshot bytes, size, and SHA-256', () => { + for (const name of ['current', 'previous'] as const) { + const vector = snapshots[name]; + const document = parseIJson(encoder.encode(vector.canonicalPayload)); + assertConfigSnapshot(document); + expect(document).toEqual(vector.document); + const canonical = canonicalizeJson(document); + const bytes = encoder.encode(canonical); + expect(canonical, name).toBe(vector.canonicalPayload); + expect(encodeBase64Url(bytes), name).toBe(vector.canonicalPayloadBase64Url); + expect(bytes.byteLength, name).toBe(vector.sizeBytes); + expect(toHex(sha256(bytes)), name).toBe(vector.sha256); + } + expect(() => + assertConfigSnapshot({ ...snapshots.current.document, futureHint: 'optional' }), + ).not.toThrow(); + const openOverride: Record = structuredClone(snapshots.current.document); + openOverride.overrides = [{ futureHint: 'optional', set: {}, when: { os: 'windows' } }]; + expect(() => assertConfigSnapshot(openOverride)).not.toThrow(); + }); + + it('cross-checks snapshot metadata against the trusted pointer after raw integrity', async () => { + const bytes = decodeBase64Url(snapshots.current.canonicalPayloadBase64Url); + const pointer: unknown = pointers.normal.document; + assertConfigPointer(pointer); + const target = { brandId: 'acme', channel: 'canary', platform: 'desktop' } as const; + await expect( + validateSnapshotBytes( + bytes, + { ...pointer, configVersion: 'opaque-mismatch' }, + target, + crypto, + ), + ).rejects.toMatchObject({ code: 'schema-invalid' }); + await expect( + validateSnapshotBytes(bytes, { ...pointer, snapshotSchemaVersion: 2 }, target, crypto), + ).rejects.toMatchObject({ code: 'schema-invalid' }); + }); + + it('verifies pointer signatures while retaining additive root fields', () => { + for (const name of [ + 'normal', + 'rollback', + 'rotationWithoutBump', + 'rotation', + 'additive', + 'schemaTooNew', + ]) { + const vector = pointers[name]; + assertConfigPointer(vector.document); + const payload = canonicalSignedPayloadBytes(vector.document); + expect(canonicalSignedPayload(vector.document), name).toBe(vector.canonicalPayload); + expect(encodeBase64Url(payload), name).toBe(vector.canonicalPayloadBase64Url); + expect(toHex(sha256(payload)), name).toBe(vector.payloadSha256); + expect( + verify( + decodeBase64Url(vector.document.sig), + payload, + decodeBase64Url(normalKeys[vector.document.keyId]), + { zip215: false }, + ), + name, + ).toBe(true); + } + expect(pointers.additive.document.futureHint).toBe('optional-and-signed'); + expect(canonicalSignedPayload(pointers.additive.document)).toContain('futureHint'); + expect(() => assertConfigPointer(pointers.unsupportedContract.document)).toThrow( + 'contractVersion', + ); + }); + + it('verifies independent emergency signatures and rejects tampering', () => { + for (const name of ['active', 'clear', 'equivocation']) { + const vector = emergencies[name]; + assertEmergencyDocument(vector.document); + const payload = canonicalSignedPayloadBytes(vector.document); + expect( + verify( + decodeBase64Url(vector.document.sig), + payload, + decodeBase64Url(emergencyKeys[vector.document.keyId]), + { zip215: false }, + ), + ).toBe(true); + } + expect( + verify( + decodeBase64Url(emergencies.tampered.document.sig as string), + canonicalSignedPayloadBytes(emergencies.tampered.document), + decodeBase64Url(emergencyKeys['emergency-rfc8032-1']), + { zip215: false }, + ), + ).toBe(false); + }); + + it('freezes normal and emergency anti-replay decisions', () => { + for (const entry of fixture.cases.pointerAntiReplay) { + const candidate = pointers[entry.candidate]; + const accepted = entry.accepted === null ? null : pointers[entry.accepted]; + expect( + decideAntiReplay( + replayState(candidate, 'activationVersion'), + accepted ? replayState(accepted, 'activationVersion') : null, + ), + entry.name, + ).toBe(entry.expectedDecision); + } + for (const entry of fixture.cases.emergencyAntiReplay) { + const candidate = emergencies[entry.candidate]; + const accepted = entry.accepted === null ? null : emergencies[entry.accepted]; + expect( + decideAntiReplay( + replayState(candidate, 'emergencyVersion'), + accepted ? replayState(accepted, 'emergencyVersion') : null, + ), + entry.name, + ).toBe(entry.expectedDecision); + } + expect(compareMonotonicVersions('9007199254740993', '9007199254740992')).toBeGreaterThan(0); + for (const value of fixture.cases.monotonicVersionErrors) { + expect(() => assertMonotonicVersion(value), value).toThrow('uint64'); + } + }); + + it('applies RFC 7386 independently per atomic dotted key', () => { + const vector = fixture.cases.mergePatch; + expect(applyConfigPatch(vector.base, vector.patch)).toEqual(vector.expected); + expect(vector.expected['ui.theme.primary']).toBe('independent'); + expect(vector.expected['ui.theme']).toEqual({ + logoVariant: 'light', + nested: { add: true, keep: true }, + primary: '#1D9E75', + }); + }); + + it('uses restricted direct SemVer precedence and closed conditions', () => { + expect(matchesVersionRange('2.4.0-beta.1', '>=2.3.0')).toBe(true); + expect(matchesVersionRange('2.4.0-beta.1', '>=2.4.0')).toBe(false); + expect(matchesVersionRange('2.4.0+build.7', '=2.4.0+other')).toBe(true); + expect(matchesVersionRange('2.4.0', '^2.3.0')).toBe(false); + expect( + conditionMatches({ locale: 'k' }, { appVersion: '2.4.0', locale: 'K', os: 'windows' }), + ).toBe(false); + for (const entry of fixture.cases.conditions) { + expect( + conditionMatches( + entry.condition as OverrideCondition, + entry.context as { + appVersion: string; + locale: string; + os: 'android' | 'ios' | 'linux' | 'macos' | 'windows'; + }, + ), + entry.name, + ).toBe(entry.expectedMatch); + } + }); + + it('pins MurmurHash3 x86_32 UTF-8 and rollout boundaries', () => { + expect(murmur3X86_32('', 0)).toBe(0); + expect(murmur3X86_32('foo', 0)).toBe(4_138_058_784); + for (const entry of fixture.cases.rollouts) { + expect(rolloutBucket(entry.salt, entry.deviceId), entry.salt).toBe(entry.expectedBucket); + expect(rolloutMatches(entry.salt, entry.deviceId, entry.expectedBucket)).toBe( + entry.expectedHitAtBoundary, + ); + expect(rolloutMatches(entry.salt, entry.deviceId, entry.expectedBucket + 1)).toBe( + entry.expectedHitAboveBoundary, + ); + expect(rolloutMatches(entry.salt, entry.deviceId, 0)).toBe(entry.expectedHitAtZero); + expect(rolloutMatches(entry.salt, entry.deviceId, 10000)).toBe(entry.expectedHitAtFull); + } + }); + + it('rejects cross-target documents independently of valid signatures', () => { + const pointer: unknown = pointers.normal.document; + const emergency: unknown = emergencies.active.document; + assertConfigPointer(pointer); + assertEmergencyDocument(emergency); + expect( + targetMatches(pointer, { brandId: 'acme', channel: 'canary', platform: 'desktop' }), + ).toBe(true); + expect( + targetMatches(pointer, { brandId: 'acme', channel: 'stable', platform: 'desktop' }), + ).toBe(false); + expect(emergencyTargetMatches(emergency, { brandId: 'other', platform: 'desktop' })).toBe( + false, + ); + }); + + it('classifies malformed keys, byte lengths, and unavailable Ed25519 distinctly', async () => { + const document = pointers.normal.document; + const keyId = document.keyId as string; + const target = { brandId: 'acme', channel: 'canary', platform: 'desktop' } as const; + const rawBytes = encoder.encode(JSON.stringify(document)); + await expect( + verifyPointerBytes(rawBytes, { crypto, keyring: { [keyId]: 'not+padded=' }, target }), + ).rejects.toMatchObject({ code: 'malformed-key' }); + await expect( + verifyPointerBytes(rawBytes, { + crypto, + keyring: { [keyId]: encodeBase64Url(new Uint8Array(31)) }, + target, + }), + ).rejects.toMatchObject({ code: 'invalid-key-length' }); + await expect( + verifyPointerBytes( + encoder.encode(JSON.stringify({ ...document, sig: encodeBase64Url(new Uint8Array(63)) })), + { crypto, keyring: normalKeys, target }, + ), + ).rejects.toMatchObject({ code: 'invalid-signature-length' }); + await expect( + verifyPointerBytes(rawBytes, { + crypto: { ...crypto, verifyEd25519: () => Promise.reject(new Error('unavailable')) }, + keyring: normalKeys, + target, + }), + ).rejects.toMatchObject({ code: 'crypto-unavailable' }); + }); +}); + +describe('I-JSON trust boundary', () => { + it.each([ + ['duplicate names', encoder.encode(String.raw`{"a":1,"\u0061":2}`)], + ['UTF-8 BOM', new Uint8Array([239, 187, 191, 123, 125])], + ['invalid UTF-8', new Uint8Array([195, 40])], + ['lone surrogate', encoder.encode(String.raw`{"value":"\ud800"}`)], + ])('rejects %s', (_name, bytes) => { + expect(() => parseIJson(bytes)).toThrow(); + }); + + it('rejects non-integer signed-envelope extensions', () => { + expect(() => + canonicalSignedPayload({ ...pointers.normal.document, futureNumber: 1.5 }), + ).toThrow('safe integers'); + }); +}); + +function replayState( + vector: SignedVector, + field: 'activationVersion' | 'emergencyVersion', +): AntiReplayState { + return { + payloadSha256: vector.payloadSha256, + version: vector.document[field] as string, + }; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/packages/foundation/common/src/config/__tests__/core.test.ts b/packages/foundation/common/src/config/__tests__/core.test.ts new file mode 100644 index 00000000..aec31d5a --- /dev/null +++ b/packages/foundation/common/src/config/__tests__/core.test.ts @@ -0,0 +1,481 @@ +import { hashes, verify } from '@noble/ed25519'; +import { sha256, sha512 } from '@noble/hashes/sha2.js'; +import { nullthrow } from 'foxts/guard'; +import { describe, expect, it } from 'vitest'; +import fixture from '../__fixtures__/contract-v1.json'; +import { ConfigCore } from '../core'; +import { decodeBase64Url } from '../i-json'; +import type { + ConfigCrypto, + ConfigDefinitions, + ConfigEvent, + ConfigNetwork, + ConfigNetworkRequest, + ConfigNetworkResponse, + ConfigStorage, + ConfigTarget, + ConfigValue, + JsonValue, +} from '../types'; + +const encoder = new TextEncoder(); +const DEVICE_ID = '550e8400-e29b-41d4-a716-446655440000'; +const TARGET: ConfigTarget = { brandId: 'acme', channel: 'canary', platform: 'desktop' }; +hashes.sha512 = sha512; + +const DEFINITIONS = { + 'app.displayName': stringDefinition('Default App'), + 'content.home.banner': objectDefinition({ title: 'Default' }), + 'content.items': arrayDefinition([]), + 'feature.aiAssist': booleanDefinition(true), + 'feature.legacy': booleanDefinition(true), + 'feature.newEditor': booleanDefinition(false), + 'params.upload.maxSizeMb': numberDefinition(10), + 'ui.theme': objectDefinition({ primary: '#000000' }), + 'ui.theme.primary': stringDefinition('default-atomic'), +} satisfies ConfigDefinitions; + +const TEST_CRYPTO: ConfigCrypto = { + randomUuid: () => DEVICE_ID, + sha256: (bytes) => Promise.resolve(sha256(bytes)), + verifyEd25519: (publicKey, signature, message) => + Promise.resolve(verify(signature, message, publicKey, { zip215: false })), +}; + +class MemoryStorage implements ConfigStorage { + readonly values = new Map(); + + get(key: string): Promise { + return Promise.resolve(this.values.get(key) ?? null); + } + + set(key: string, value: string): Promise { + this.values.set(key, value); + return Promise.resolve(); + } +} + +class QueueNetwork implements ConfigNetwork { + readonly requests: Array<{ path: string; request: ConfigNetworkRequest }> = []; + readonly responses: ConfigNetworkResponse[]; + #nextResponse = 0; + + constructor(responses: ConfigNetworkResponse[] = []) { + this.responses = [...responses]; + } + + get(path: string, request: ConfigNetworkRequest): Promise { + this.requests.push({ path, request }); + const response = nullthrow( + this.responses[this.#nextResponse], + `No queued response for ${path}`, + ); + this.#nextResponse += 1; + return Promise.resolve(response); + } +} + +describe('ConfigCore normal state machine', () => { + it('starts from typed defaults without network access', async () => { + const setup = makeSetup(); + const state = await setup.core.initialize(); + expect(state.source).toBe('defaults'); + expect(state.configVersion).toBeNull(); + expect(setup.core.get('feature.aiAssist')).toBe(true); + expect(setup.core.get('params.upload.maxSizeMb')).toBe(10); + expect(setup.normal.requests).toEqual([]); + }); + + it('fetches with ETags, applies hot keys, stages cold keys, and boots the LKG cold', async () => { + const storage = new MemoryStorage(); + const setup = makeSetup({ + storage, + normalResponses: [ + ok(pointerBytes('normal'), '"p100"'), + ok(snapshotBytes('current')), + { status: 304 }, + ], + }); + const updates: unknown[] = []; + setup.core.subscribe((state) => updates.push(state)); + + await setup.core.initialize(); + await expect(setup.core.refresh()).resolves.toEqual({ status: 'updated' }); + expect(setup.core.get('content.home.banner')).toEqual({ + title: '你好', + url: 'https://linkcode.ai/docs', + }); + expect(setup.core.get('app.displayName')).toBe('Default App'); + expect(setup.core.get('feature.legacy')).toBe(true); + expect(setup.core.get('feature.newEditor')).toBe(false); + expect(setup.core.get('params.upload.maxSizeMb')).toBe(10); + expect(setup.core.getState().stagedColdKeys).toEqual([ + 'app.displayName', + 'feature.legacy', + 'feature.newEditor', + 'params.upload.maxSizeMb', + ]); + expect(updates).toHaveLength(1); + + await expect(setup.core.refresh()).resolves.toEqual({ status: 'not-modified' }); + expect(setup.normal.requests[2]?.request).toEqual({ etag: '"p100"' }); + + const restarted = makeSetup({ storage }); + const restartedState = await restarted.core.initialize(); + expect(restartedState.source).toBe('lkg'); + expect(restarted.core.get('app.displayName')).toBe('Acme Studio'); + expect(restarted.core.get('feature.legacy')).toBe(false); + expect(restarted.core.get('feature.newEditor')).toBe(true); + expect(restarted.core.get('params.upload.maxSizeMb')).toBe(100); + }); + + it('commits high-water before fetch failure and finishes an idempotent 304 retry', async () => { + const setup = makeSetup({ + normalResponses: [ + ok(pointerBytes('normal'), '"p100"'), + { status: 503 }, + { status: 304 }, + ok(snapshotBytes('current')), + ok(pointerBytes('normal'), '"p100b"'), + ], + }); + await setup.core.initialize(); + await expectError(setup.core.refresh(), 'fetch'); + expect(setup.core.getState().source).toBe('defaults'); + + await expect(setup.core.refresh()).resolves.toEqual({ status: 'updated' }); + expect(setup.normal.requests[2]?.request).toEqual({ etag: '"p100"' }); + await expect(setup.core.refresh()).resolves.toEqual({ status: 'idempotent' }); + expect(setup.normal.requests).toHaveLength(5); + }); + + it('rejects a pointer 304 when no trusted pointer exists', async () => { + const setup = makeSetup({ normalResponses: [{ status: 304 }] }); + await setup.core.initialize(); + await expectError(setup.core.refresh(), 'fetch'); + }); + + it.each([ + ['malformed', encoder.encode('{'), 'malformed'], + ['unknown key', pointerBytes('unknownKey'), 'unknown-key'], + ['invalid signature', pointerBytes('tampered'), 'invalid-signature'], + ['unsupported contract', pointerBytes('unsupportedContract'), 'unsupported-contract'], + ] as const)('rejects %s without downloading a snapshot', async (_name, body, code) => { + const setup = makeSetup({ normalResponses: [ok(body)] }); + await setup.core.initialize(); + await expectError(setup.core.refresh(), code); + expect(setup.normal.requests).toHaveLength(1); + expect(setup.core.getState().source).toBe('defaults'); + }); + + it('distinguishes malformed signatures and valid cross-target replay', async () => { + const malformed = structuredClone(fixture.pointers.normal.document); + malformed.sig = 'not+padded='; + const malformedSetup = makeSetup({ normalResponses: [ok(documentBytes(malformed))] }); + await malformedSetup.core.initialize(); + await expectError(malformedSetup.core.refresh(), 'malformed-signature'); + + const crossTarget = makeSetup({ + normalResponses: [ok(pointerBytes('normal'))], + target: { ...TARGET, channel: 'stable' }, + }); + await crossTarget.core.initialize(); + await expectError(crossTarget.core.refresh(), 'target-mismatch'); + }); + + it('rejects lower replay and equal-version equivocation', async () => { + const setup = makeSetup({ + normalResponses: [ + ok(pointerBytes('rollback')), + ok(snapshotBytes('previous')), + ok(pointerBytes('normal')), + ok(pointerBytes('rotationWithoutBump')), + ], + }); + await setup.core.initialize(); + await expect(setup.core.refresh()).resolves.toEqual({ status: 'updated' }); + await expectError(setup.core.refresh(), 'replay'); + await expectError(setup.core.refresh(), 'equivocation'); + }); + + it('rejects size and exact raw-byte hash mismatches after advancing high-water', async () => { + const short = snapshotBytes('current').slice(0, -1); + const sizeSetup = makeSetup({ + normalResponses: [ok(pointerBytes('normal')), ok(short)], + }); + await sizeSetup.core.initialize(); + await expectError(sizeSetup.core.refresh(), 'size-mismatch'); + + const changed = snapshotBytes('current'); + changed[changed.length - 2] ^= 1; + const hashSetup = makeSetup({ + normalResponses: [ok(pointerBytes('normal')), ok(changed)], + }); + await hashSetup.core.initialize(); + await expectError(hashSetup.core.refresh(), 'hash-mismatch'); + }); + + it('retains a trusted unsupported-schema high-water and rejects lower retries', async () => { + const setup = makeSetup({ + normalResponses: [ok(pointerBytes('schemaTooNew')), ok(pointerBytes('rotation'))], + }); + await setup.core.initialize(); + await expectError(setup.core.refresh(), 'unsupported-schema'); + expect(setup.normal.requests).toHaveLength(1); + await expectError(setup.core.refresh(), 'replay'); + }); + + it('rejects a known product-schema mismatch without replacing defaults', async () => { + const definitions = { + ...DEFINITIONS, + 'params.upload.maxSizeMb': stringDefinition('10'), + } satisfies ConfigDefinitions; + const setup = makeSetup({ definitions, normalResponses: normalPublication() }); + await setup.core.initialize(); + await expectError(setup.core.refresh(), 'schema-invalid'); + expect(setup.core.get('params.upload.maxSizeMb')).toBe('10'); + }); + + it('drops a corrupted LKG but retains the trusted pointer for a 304 repair', async () => { + const storage = new MemoryStorage(); + const initial = makeSetup({ storage, normalResponses: normalPublication() }); + await initial.core.initialize(); + await initial.core.refresh(); + const key = 'linkcode-config:v1:normal:acme:desktop:canary'; + const stored = JSON.parse(storage.values.get(key) ?? '{}') as { + lkg: { snapshot: string }; + }; + stored.lkg.snapshot = 'bad'; + storage.values.set(key, JSON.stringify(stored)); + + const events: ConfigEvent[] = []; + const repaired = makeSetup({ + events, + storage, + normalResponses: [{ status: 304 }, ok(snapshotBytes('current'))], + }); + const state = await repaired.core.initialize(); + expect(state.source).toBe('defaults'); + expect(events.some((event) => event.type === 'error' && event.error.code === 'storage')).toBe( + true, + ); + await expect(repaired.core.refresh()).resolves.toEqual({ status: 'updated' }); + }); + + it('retains normal replay high-water when a rotated-out key invalidates cached bytes', async () => { + const storage = new MemoryStorage(); + const initial = makeSetup({ + normalResponses: [ok(pointerBytes('rollback')), ok(snapshotBytes('previous'))], + storage, + }); + await initial.core.initialize(); + await initial.core.refresh(); + + const currentKeyring = { + 'normal-rfc8032-3': fixture.keys.normal['normal-rfc8032-3'], + }; + const restarted = makeSetup({ + normalKeyring: currentKeyring, + normalResponses: [ok(pointerBytes('rotationWithoutBump'))], + storage, + }); + expect((await restarted.core.initialize()).source).toBe('defaults'); + await expectError(restarted.core.refresh(), 'equivocation'); + const stored = JSON.parse( + storage.values.get('linkcode-config:v1:normal:acme:desktop:canary') ?? '{}', + ) as { highWater?: { version?: string } }; + expect(stored.highWater?.version).toBe('101'); + }); +}); + +describe('ConfigCore emergency state', () => { + it('rejects a 304 when no accepted emergency state exists', async () => { + const setup = makeSetup({ emergencyResponses: [{ status: 304 }] }); + await setup.core.initialize(); + await expectError(setup.core.refreshEmergency(), 'fetch'); + }); + + it('persists independently, fails open, and clears effects with a newer document', async () => { + const storage = new MemoryStorage(); + const first = makeSetup({ + emergencyResponses: [ok(emergencyBytes('active'), '"e7"')], + storage, + }); + await first.core.initialize(); + await expect(first.core.refreshEmergency()).resolves.toEqual({ status: 'updated' }); + expect(first.core.get('feature.aiAssist')).toBe(false); + expect(first.core.getState().emergency).toMatchObject({ + emergencyVersion: '7', + forceMinVersion: '2.4.0', + }); + + const stable = makeSetup({ + emergencyResponses: [{ status: 503 }, ok(emergencyBytes('clear'), '"e8"'), { status: 304 }], + storage, + target: { ...TARGET, channel: 'stable' }, + }); + await stable.core.initialize(); + expect(stable.core.get('feature.aiAssist')).toBe(false); + await expectError(stable.core.refreshEmergency(), 'fetch'); + expect(stable.core.get('feature.aiAssist')).toBe(false); + await expect(stable.core.refreshEmergency()).resolves.toEqual({ status: 'updated' }); + expect(stable.core.get('feature.aiAssist')).toBe(true); + expect(stable.core.getState().emergency).toMatchObject({ emergencyVersion: '8' }); + await expect(stable.core.refreshEmergency()).resolves.toEqual({ status: 'not-modified' }); + expect(stable.emergency.requests[2]?.request).toEqual({ etag: '"e8"' }); + }); + + it('retains accepted state on replay, equivocation, and signature failure', async () => { + const setup = makeSetup({ + emergencyResponses: [ + ok(emergencyBytes('active')), + ok(emergencyBytes('clear')), + ok(emergencyBytes('active')), + ok(emergencyBytes('equivocation')), + ok(emergencyBytes('tampered')), + ], + }); + await setup.core.initialize(); + await setup.core.refreshEmergency(); + await setup.core.refreshEmergency(); + await expectError(setup.core.refreshEmergency(), 'replay'); + await expectError(setup.core.refreshEmergency(), 'equivocation'); + await expectError(setup.core.refreshEmergency(), 'invalid-signature'); + expect(setup.core.getState().emergency?.emergencyVersion).toBe('8'); + }); + + it('retains emergency replay high-water when a rotated-out key invalidates cached state', async () => { + const storage = new MemoryStorage(); + const initial = makeSetup({ emergencyResponses: [ok(emergencyBytes('clear'))], storage }); + await initial.core.initialize(); + await initial.core.refreshEmergency(); + + const withoutOldKey = makeSetup({ emergencyKeyring: {}, storage }); + expect((await withoutOldKey.core.initialize()).emergency).toBeNull(); + + const restoredKey = makeSetup({ + emergencyResponses: [ok(emergencyBytes('active'))], + storage, + }); + await restoredKey.core.initialize(); + await expectError(restoredKey.core.refreshEmergency(), 'replay'); + const stored = JSON.parse( + storage.values.get('linkcode-config:v1:emergency:acme:desktop') ?? '{}', + ) as { highWater?: { version?: string } }; + expect(stored.highWater?.version).toBe('8'); + }); +}); + +function makeSetup(options?: { + definitions?: Definitions; + emergencyKeyring?: Readonly>; + emergencyResponses?: ConfigNetworkResponse[]; + events?: ConfigEvent[]; + normalKeyring?: Readonly>; + normalResponses?: ConfigNetworkResponse[]; + storage?: MemoryStorage; + target?: ConfigTarget; +}) { + const normal = new QueueNetwork(options?.normalResponses); + const emergency = new QueueNetwork(options?.emergencyResponses); + const storage = options?.storage ?? new MemoryStorage(); + const events = options?.events ?? []; + const definitions = (options?.definitions ?? DEFINITIONS) as Definitions; + const core = new ConfigCore({ + context: { appVersion: '2.5.0', locale: 'ZH_cn', os: 'windows' }, + crypto: TEST_CRYPTO, + definitions, + emergencyKeyring: options?.emergencyKeyring ?? fixture.keys.emergency, + emergencyNetwork: emergency, + maximumSchemaVersion: 1, + network: normal, + normalKeyring: options?.normalKeyring ?? fixture.keys.normal, + report: (event) => events.push(event), + storage, + target: options?.target ?? TARGET, + }); + return { core, emergency, events, normal, storage }; +} + +function normalPublication(): ConfigNetworkResponse[] { + return [ok(pointerBytes('normal'), '"p100"'), ok(snapshotBytes('current'))]; +} + +function pointerBytes(name: keyof typeof fixture.pointers): Uint8Array { + return documentBytes(fixture.pointers[name].document); +} + +function emergencyBytes(name: keyof typeof fixture.emergencies): Uint8Array { + return documentBytes(fixture.emergencies[name].document); +} + +function snapshotBytes(name: 'current' | 'previous'): Uint8Array { + return decodeBase64Url(fixture.snapshots[name].canonicalPayloadBase64Url); +} + +function documentBytes(document: unknown): Uint8Array { + return encoder.encode(JSON.stringify(document)); +} + +function ok(body: Uint8Array, etag?: string): ConfigNetworkResponse { + return { body, ...(etag && { etag }), status: 200 }; +} + +async function expectError( + result: Promise<{ status: string; error?: { code: string } }>, + code: string, +): Promise { + await expect(result).resolves.toMatchObject({ status: 'error', error: { code } }); +} + +function booleanDefinition(defaultValue: boolean) { + return { + defaultValue, + parse(value: ConfigValue): boolean { + if (typeof value !== 'boolean') throw new TypeError('Expected boolean'); + return value; + }, + }; +} + +function numberDefinition(defaultValue: number) { + return { + defaultValue, + parse(value: ConfigValue): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TypeError('Expected number'); + } + return value; + }, + }; +} + +function stringDefinition(defaultValue: string) { + return { + defaultValue, + parse(value: ConfigValue): string { + if (typeof value !== 'string') throw new TypeError('Expected string'); + return value; + }, + }; +} + +function objectDefinition(defaultValue: Record) { + return { + defaultValue, + parse(value: ConfigValue): Record { + if (typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Expected object'); + return Object.fromEntries(Object.entries(value)); + }, + }; +} + +function arrayDefinition(defaultValue: JsonValue[]) { + return { + defaultValue, + parse(value: ConfigValue): JsonValue[] { + if (!Array.isArray(value)) throw new TypeError('Expected array'); + return [...value]; + }, + }; +} diff --git a/packages/foundation/common/src/config/contract.ts b/packages/foundation/common/src/config/contract.ts new file mode 100644 index 00000000..1990441d --- /dev/null +++ b/packages/foundation/common/src/config/contract.ts @@ -0,0 +1,424 @@ +import canonicalize from 'canonicalize'; +import { isObjectEmpty } from 'foxts/is-object-empty'; +import { decodeBase64Url } from './i-json'; +import { isConfigKey, normalizeLocale } from './rules'; +import { isValidSemver, isValidVersionRange } from './semver'; +import type { + AntiReplayDecision, + AntiReplayState, + ConfigChannel, + ConfigPlatform, + ConfigPointer, + ConfigSnapshot, + ConfigTarget, + EmergencyDocument, + JsonValue, +} from './types'; +import { + APPLY_MODES, + CONFIG_CHANNELS, + CONFIG_CONTRACT_VERSION, + CONFIG_PLATFORMS, + MAX_MONOTONIC_VERSION, + MAX_SNAPSHOT_SIZE_BYTES, + OPERATING_SYSTEMS, +} from './types'; + +const RE_BRAND_ID = /^[a-z][a-z0-9-]{0,62}$/; +const RE_CONFIG_VERSION = /^[\dA-Z][\w.-]{0,127}$/i; +const RE_DECIMAL = /^(?:0|[1-9]\d*)$/; +const RE_HEX_SHA256 = /^[0-9a-f]{64}$/; +const RE_KEY_ID = /^[\dA-Z][\w.-]{0,127}$/i; +const RE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; + +const CONFIG_PLATFORM_SET = new Set(CONFIG_PLATFORMS); +const CONFIG_CHANNEL_SET = new Set(CONFIG_CHANNELS); +const APPLY_MODE_SET = new Set(APPLY_MODES); +const OPERATING_SYSTEM_SET = new Set(OPERATING_SYSTEMS); + +function fail(message: string): never { + throw new TypeError(message); +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) fail(`${label} must be an object`); + return value; +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string') fail(`${label} must be a string`); + return value; +} + +function requireSafeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value)) fail(`${label} must be a safe integer`); + return value as number; +} + +function assertAllowedKeys( + value: Record, + allowed: ReadonlySet, + label: string, +): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail(`${label} contains unsupported field ${key}`); + } +} + +function assertTimestamp(value: unknown, label: string): asserts value is string { + const timestamp = requireString(value, label); + if (!RE_TIMESTAMP.test(timestamp)) fail(`${label} must use RFC 3339 UTC seconds precision`); + const parsed = Date.parse(timestamp); + if (Number.isNaN(parsed) || new Date(parsed).toISOString() !== timestamp.replace('Z', '.000Z')) { + fail(`${label} is not a valid timestamp`); + } +} + +function assertConfigVersion(value: unknown, label: string): asserts value is string { + const version = requireString(value, label); + if (!RE_CONFIG_VERSION.test(version)) fail(`${label} is invalid`); +} + +function assertKeyId(value: unknown, label: string): asserts value is string { + const keyId = requireString(value, label); + if (!RE_KEY_ID.test(keyId)) fail(`${label} is invalid`); +} + +function assertSha256(value: unknown, label: string): asserts value is string { + const digest = requireString(value, label); + if (!RE_HEX_SHA256.test(digest)) fail(`${label} must be a lowercase SHA-256 digest`); +} + +function assertSignature(value: unknown, label: string): asserts value is string { + const signature = requireString(value, label); + if (decodeBase64Url(signature).byteLength !== 64) { + fail(`${label} must encode a raw 64-byte Ed25519 signature`); + } +} + +function assertContractVersion(value: unknown): asserts value is 1 { + if (value !== CONFIG_CONTRACT_VERSION) fail(`Unsupported contractVersion ${String(value)}`); +} + +function assertPlatform(value: unknown, label: string): asserts value is ConfigPlatform { + const platform = requireString(value, label); + if (!CONFIG_PLATFORM_SET.has(platform)) fail(`${label} is invalid`); +} + +function assertChannel(value: unknown, label: string): asserts value is ConfigChannel { + const channel = requireString(value, label); + if (!CONFIG_CHANNEL_SET.has(channel)) fail(`${label} is invalid`); +} + +function assertBrandId(value: unknown, label: string): asserts value is string { + const brandId = requireString(value, label); + if (!RE_BRAND_ID.test(brandId)) fail(`${label} is invalid`); +} + +function assertSchemaVersion(value: unknown, label: string): asserts value is number { + const version = requireSafeInteger(value, label); + if (version < 1) fail(`${label} must be positive`); +} + +function assertSignedEnvelopeValue(value: unknown, label: string): void { + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) fail(`${label} numbers must be safe integers`); + return; + } + if (value === null || typeof value === 'boolean' || typeof value === 'string') return; + if (Array.isArray(value)) { + for (const [index, entry] of value.entries()) { + assertSignedEnvelopeValue(entry, `${label}[${index}]`); + } + return; + } + const object = requireRecord(value, label); + for (const [key, entry] of Object.entries(object)) { + assertSignedEnvelopeValue(entry, `${label}.${key}`); + } +} + +function assertTarget(value: Record, label: string): void { + assertBrandId(value.brandId, `${label}.brandId`); + assertPlatform(value.platform, `${label}.platform`); + assertChannel(value.channel, `${label}.channel`); +} + +function compareDecimal(left: string, right: string): number { + if (left.length !== right.length) return left.length < right.length ? -1 : 1; + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function assertCondition(value: unknown, label: string): void { + const condition = requireRecord(value, label); + assertAllowedKeys(condition, new Set(['appVersion', 'locale', 'os']), label); + if (isObjectEmpty(condition)) fail(`${label} must not be empty`); + if ( + condition.appVersion !== undefined && + (typeof condition.appVersion !== 'string' || !isValidVersionRange(condition.appVersion)) + ) { + fail(`${label}.appVersion is invalid`); + } + if ( + condition.locale !== undefined && + (typeof condition.locale !== 'string' || normalizeLocale(condition.locale) === null) + ) { + fail(`${label}.locale is invalid`); + } + if ( + condition.os !== undefined && + (typeof condition.os !== 'string' || !OPERATING_SYSTEM_SET.has(condition.os)) + ) { + fail(`${label}.os is invalid`); + } +} + +function assertNotice(value: unknown, label: string): void { + if (value === null) return; + const notice = requireRecord(value, label); + assertAllowedKeys(notice, new Set(['body', 'title', 'url']), label); + const title = requireString(notice.title, `${label}.title`); + const body = requireString(notice.body, `${label}.body`); + if (title.length === 0 || title.length > 120) fail(`${label}.title length is invalid`); + if (body.length === 0 || body.length > 1000) fail(`${label}.body length is invalid`); + if (notice.url !== null) { + const url = requireString(notice.url, `${label}.url`); + if (url.length > 2048 || !url.startsWith('https://')) { + fail(`${label}.url must be an HTTPS URL`); + } + } +} + +export function canonicalizeJson(value: JsonValue): string { + const result = canonicalize(value); + if (result === undefined) fail('value cannot be represented as canonical JSON'); + return result; +} + +export function assertMonotonicVersion(value: string, label = 'version'): void { + if ( + !RE_DECIMAL.test(value) || + value.length > MAX_MONOTONIC_VERSION.length || + (value.length === MAX_MONOTONIC_VERSION.length && value > MAX_MONOTONIC_VERSION) + ) { + fail(`${label} must be a canonical uint64 decimal string`); + } +} + +export function compareMonotonicVersions(left: string, right: string): number { + assertMonotonicVersion(left, 'left version'); + assertMonotonicVersion(right, 'right version'); + return compareDecimal(left, right); +} + +export function decideAntiReplay( + candidate: AntiReplayState, + accepted: AntiReplayState | null, +): AntiReplayDecision { + assertMonotonicVersion(candidate.version, 'candidate version'); + assertSha256(candidate.payloadSha256, 'candidate payloadSha256'); + if (!accepted) return 'advance'; + assertMonotonicVersion(accepted.version, 'accepted version'); + assertSha256(accepted.payloadSha256, 'accepted payloadSha256'); + const comparison = compareMonotonicVersions(candidate.version, accepted.version); + if (comparison < 0) return 'replay'; + if (comparison > 0) return 'advance'; + return candidate.payloadSha256 === accepted.payloadSha256 ? 'idempotent' : 'equivocation'; +} + +export function canonicalSignedPayload(document: unknown): string { + const envelope = requireRecord(document, 'signed envelope'); + assertSignature(envelope.sig, 'signed envelope.sig'); + const unsigned = Object.fromEntries(Object.entries(envelope).filter(([key]) => key !== 'sig')); + assertSignedEnvelopeValue(unsigned, 'signed envelope payload'); + return canonicalizeJson(unsigned as JsonValue); +} + +export function canonicalSignedPayloadBytes(document: unknown): Uint8Array { + return new TextEncoder().encode(canonicalSignedPayload(document)); +} + +export function assertConfigPointer(value: unknown): asserts value is ConfigPointer { + const pointer = requireRecord(value, 'pointer'); + canonicalSignedPayload(pointer); + assertContractVersion(pointer.contractVersion); + assertTarget(pointer, 'pointer'); + assertConfigVersion(pointer.configVersion, 'pointer.configVersion'); + assertSchemaVersion(pointer.snapshotSchemaVersion, 'pointer.snapshotSchemaVersion'); + const activationVersion = requireString(pointer.activationVersion, 'pointer.activationVersion'); + assertMonotonicVersion(activationVersion, 'pointer.activationVersion'); + assertSha256(pointer.sha256, 'pointer.sha256'); + const sizeBytes = requireSafeInteger(pointer.sizeBytes, 'pointer.sizeBytes'); + if (sizeBytes < 1 || sizeBytes > MAX_SNAPSHOT_SIZE_BYTES) { + fail(`pointer.sizeBytes must be between 1 and ${MAX_SNAPSHOT_SIZE_BYTES}`); + } + assertTimestamp(pointer.createdAt, 'pointer.createdAt'); + assertKeyId(pointer.keyId, 'pointer.keyId'); + assertSignature(pointer.sig, 'pointer.sig'); +} + +export function assertConfigSnapshot(value: unknown): asserts value is ConfigSnapshot { + const snapshot = requireRecord(value, 'snapshot'); + canonicalizeJson(snapshot as JsonValue); + assertContractVersion(snapshot.contractVersion); + assertTarget(snapshot, 'snapshot'); + assertConfigVersion(snapshot.configVersion, 'snapshot.configVersion'); + assertSchemaVersion(snapshot.schemaVersion, 'snapshot.schemaVersion'); + assertTimestamp(snapshot.generatedAt, 'snapshot.generatedAt'); + + const coveredKeys = new Set(); + const values = requireRecord(snapshot.values, 'snapshot.values'); + for (const [key, entry] of Object.entries(values)) { + if (!isConfigKey(key)) fail(`snapshot.values key ${key} is invalid`); + if (entry === null) fail(`snapshot.values.${key} must not be null`); + canonicalizeJson(entry as JsonValue); + coveredKeys.add(key); + } + + if (!Array.isArray(snapshot.overrides)) fail('snapshot.overrides must be an array'); + for (const [index, entry] of snapshot.overrides.entries()) { + const override = requireRecord(entry, `snapshot.overrides[${index}]`); + assertCondition(override.when, `snapshot.overrides[${index}].when`); + const patch = requireRecord(override.set, `snapshot.overrides[${index}].set`); + for (const [key, patchValue] of Object.entries(patch)) { + if (!isConfigKey(key)) fail(`snapshot.overrides[${index}].set key ${key} is invalid`); + canonicalizeJson(patchValue as JsonValue); + coveredKeys.add(key); + } + } + + const rollouts = requireRecord(snapshot.rollouts, 'snapshot.rollouts'); + for (const [key, entry] of Object.entries(rollouts)) { + if (!key.startsWith('feature.') || !isConfigKey(key)) { + fail(`snapshot.rollouts key ${key} must be a feature key`); + } + const rollout = requireRecord(entry, `snapshot.rollouts.${key}`); + assertAllowedKeys( + rollout, + new Set(['basisPoints', 'salt', 'value']), + `snapshot.rollouts.${key}`, + ); + const basisPoints = requireSafeInteger( + rollout.basisPoints, + `snapshot.rollouts.${key}.basisPoints`, + ); + if (basisPoints < 0 || basisPoints > 10000) { + fail(`snapshot.rollouts.${key}.basisPoints is invalid`); + } + const salt = requireString(rollout.salt, `snapshot.rollouts.${key}.salt`); + const saltLength = new TextEncoder().encode(salt).byteLength; + if (saltLength < 1 || saltLength > 128) { + fail(`snapshot.rollouts.${key}.salt length is invalid`); + } + if (typeof rollout.value !== 'boolean') { + fail(`snapshot.rollouts.${key}.value must be boolean`); + } + coveredKeys.add(key); + } + + const applyModes = requireRecord(snapshot.applyModes, 'snapshot.applyModes'); + for (const [key, mode] of Object.entries(applyModes)) { + if (!isConfigKey(key)) fail(`snapshot.applyModes key ${key} is invalid`); + if (typeof mode !== 'string' || !APPLY_MODE_SET.has(mode)) { + fail(`snapshot.applyModes.${key} is invalid`); + } + } + for (const key of coveredKeys) { + if (!(key in applyModes)) fail(`snapshot.applyModes is missing ${key}`); + } +} + +export function assertEmergencyDocument(value: unknown): asserts value is EmergencyDocument { + const emergency = requireRecord(value, 'emergency'); + canonicalSignedPayload(emergency); + assertContractVersion(emergency.contractVersion); + assertBrandId(emergency.brandId, 'emergency.brandId'); + assertPlatform(emergency.platform, 'emergency.platform'); + const emergencyVersion = requireString(emergency.emergencyVersion, 'emergency.emergencyVersion'); + assertMonotonicVersion(emergencyVersion, 'emergency.emergencyVersion'); + assertTimestamp(emergency.createdAt, 'emergency.createdAt'); + assertKeyId(emergency.keyId, 'emergency.keyId'); + assertSignature(emergency.sig, 'emergency.sig'); + if (!Array.isArray(emergency.disabledFeatures)) { + fail('emergency.disabledFeatures must be an array'); + } + const disabledFeatures = emergency.disabledFeatures.map((entry, index) => { + const key = requireString(entry, `emergency.disabledFeatures[${index}]`); + if (!key.startsWith('feature.') || !isConfigKey(key)) { + fail(`emergency.disabledFeatures[${index}] must be a feature key`); + } + return key; + }); + const sortedFeatures = [...new Set(disabledFeatures)].sort(); + if ( + sortedFeatures.length !== disabledFeatures.length || + sortedFeatures.some((entry, index) => entry !== disabledFeatures[index]) + ) { + fail('emergency.disabledFeatures must be sorted and unique'); + } + if ( + emergency.forceMinVersion !== null && + (typeof emergency.forceMinVersion !== 'string' || !isValidSemver(emergency.forceMinVersion)) + ) { + fail('emergency.forceMinVersion must be SemVer or null'); + } + assertNotice(emergency.notice, 'emergency.notice'); +} + +export function targetMatches( + document: Pick, + target: ConfigTarget, +): boolean { + return ( + document.brandId === target.brandId && + document.platform === target.platform && + document.channel === target.channel + ); +} + +export function emergencyTargetMatches( + document: Pick, + target: Pick, +): boolean { + return document.brandId === target.brandId && document.platform === target.platform; +} + +export function configPointerPath(target: ConfigTarget): string { + assertBrandId(target.brandId, 'target.brandId'); + assertPlatform(target.platform, 'target.platform'); + assertChannel(target.channel, 'target.channel'); + return `/v1/${target.brandId}/${target.platform}/${target.channel}/latest.json`; +} + +export function configSnapshotPath(target: ConfigTarget, sha256: string): string { + assertBrandId(target.brandId, 'target.brandId'); + assertPlatform(target.platform, 'target.platform'); + assertChannel(target.channel, 'target.channel'); + assertSha256(sha256, 'sha256'); + return `/v1/${target.brandId}/${target.platform}/${target.channel}/s/${sha256}.json`; +} + +export function emergencyPath(target: Pick): string { + assertBrandId(target.brandId, 'target.brandId'); + assertPlatform(target.platform, 'target.platform'); + return `/v1/${target.brandId}/${target.platform}/emergency.json`; +} + +export { + applyConfigPatch, + applyMergePatch, + conditionMatches, + isConfigKey, + isUuidV4, + localeMatches, + murmur3X86_32, + normalizeLocale, + rolloutBucket, + rolloutMatches, +} from './rules'; +export { isValidSemver, isValidVersionRange, matchesVersionRange } from './semver'; diff --git a/packages/foundation/common/src/config/core.ts b/packages/foundation/common/src/config/core.ts new file mode 100644 index 00000000..de81ab80 --- /dev/null +++ b/packages/foundation/common/src/config/core.ts @@ -0,0 +1,438 @@ +import { noop } from 'foxts/noop'; +import { configPointerPath, configSnapshotPath, decideAntiReplay, emergencyPath } from './contract'; +import { applyEmergency, defaultValues, evaluateSnapshot, jsonEqual } from './evaluation'; +import { cloneJson } from './i-json'; +import type { EmergencyPersistentState, LoadedLkg, NormalPersistentState } from './persistence'; +import { + loadDeviceId, + loadEmergencyState, + loadNormalState, + saveEmergencyState, + saveNormalState, +} from './persistence'; +import type { + ConfigCrypto, + ConfigDefinitions, + ConfigEmergencyState, + ConfigEvent, + ConfigNetwork, + ConfigRefreshResult, + ConfigRuntimeState, + ConfigStorage, + ConfigTarget, + ConfigValue, + ConfigValues, + EvaluationContext, +} from './types'; +import { ConfigCoreError } from './types'; +import type { VerifiedPointer } from './verification'; +import { validateSnapshotBytes, verifyEmergencyBytes, verifyPointerBytes } from './verification'; + +export interface ConfigCoreOptions { + readonly context: EvaluationContext; + readonly crypto: ConfigCrypto; + readonly definitions: Definitions; + readonly emergencyKeyring: Readonly>; + readonly emergencyNetwork: ConfigNetwork; + readonly maximumSchemaVersion: number; + readonly network: ConfigNetwork; + readonly normalKeyring: Readonly>; + readonly report?: (event: ConfigEvent) => void; + readonly storage: ConfigStorage; + readonly target: ConfigTarget; +} + +type Listener = ( + state: ConfigRuntimeState>, +) => void; + +export class ConfigCore { + readonly #options: ConfigCoreOptions; + readonly #listeners = new Set>(); + #initialization: Promise | null = null; + #operationQueue = Promise.resolve(); + #deviceId = ''; + #normal: NormalPersistentState = {}; + #emergency: EmergencyPersistentState = {}; + #source: ConfigRuntimeState>['source'] = 'defaults'; + #configVersion: string | null = null; + #stagedColdKeys: Array> = []; + #coldPinned: ConfigValues; + #baseActive: ConfigValues; + #active: ConfigValues; + + constructor(options: ConfigCoreOptions) { + if (!Number.isSafeInteger(options.maximumSchemaVersion) || options.maximumSchemaVersion < 1) { + throw new TypeError('maximumSchemaVersion must be a positive safe integer'); + } + configPointerPath(options.target); + const definitions = Object.fromEntries( + Object.entries(options.definitions).map(([key, definition]) => [ + key, + { defaultValue: cloneJson(definition.defaultValue), parse: definition.parse }, + ]), + ) as Definitions; + this.#options = { + ...options, + context: { ...options.context }, + definitions, + emergencyKeyring: { ...options.emergencyKeyring }, + normalKeyring: { ...options.normalKeyring }, + target: { ...options.target }, + }; + const defaults = defaultValues(definitions); + this.#coldPinned = defaults; + this.#baseActive = defaults; + this.#active = defaults; + } + + async initialize(): Promise>> { + if (!this.#initialization) { + const initialization = this.#initialize(); + this.#initialization = initialization; + void initialization.catch(() => { + if (this.#initialization === initialization) this.#initialization = null; + }); + } + await this.#initialization; + return this.getState(); + } + + async refresh(): Promise { + try { + await this.initialize(); + } catch (error) { + return this.#failure('normal-refresh', error); + } + return this.#enqueue(() => this.#refreshNormal()); + } + + async refreshEmergency(): Promise { + try { + await this.initialize(); + } catch (error) { + return this.#failure('emergency-refresh', error); + } + return this.#enqueue(() => this.#refreshEmergency()); + } + + get>(key: Key): ConfigValues[Key] { + return cloneJson(this.#active[key]); + } + + getAll(): ConfigValues { + return cloneJson(this.#active as ConfigValue) as ConfigValues; + } + + getState(): ConfigRuntimeState> { + return { + configVersion: this.#configVersion, + emergency: this.#emergencyView(), + source: this.#source, + stagedColdKeys: [...this.#stagedColdKeys], + values: this.getAll(), + }; + } + + subscribe(listener: Listener): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + async #initialize(): Promise { + try { + this.#deviceId = await loadDeviceId(this.#options.storage, this.#options.crypto); + const persistenceOptions = { + context: this.#options.context, + crypto: this.#options.crypto, + definitions: this.#options.definitions, + deviceId: this.#deviceId, + emergencyKeyring: this.#options.emergencyKeyring, + maximumSchemaVersion: this.#options.maximumSchemaVersion, + normalKeyring: this.#options.normalKeyring, + report: (event: ConfigEvent) => this.#report(event), + storage: this.#options.storage, + target: this.#options.target, + }; + const [normal, emergency] = await Promise.all([ + loadNormalState(persistenceOptions), + loadEmergencyState(persistenceOptions), + ]); + this.#normal = normal; + this.#emergency = emergency; + const values = this.#normal.lkg?.values ?? defaultValues(this.#options.definitions); + this.#source = this.#normal.lkg ? 'lkg' : 'defaults'; + this.#configVersion = this.#normal.lkg?.pointer.document.configVersion ?? null; + this.#coldPinned = cloneJson(values as ConfigValue) as ConfigValues; + this.#baseActive = cloneJson(values as ConfigValue) as ConfigValues; + this.#active = applyEmergency( + this.#baseActive, + this.#emergency.document?.document ?? null, + this.#options.definitions, + ); + } catch (error_) { + const error = asConfigError(error_, 'storage', 'Failed to initialize configuration core'); + this.#report({ type: 'error', operation: 'initialize', error }); + throw error; + } + } + + async #refreshNormal(): Promise { + try { + const response = await getResponse( + this.#options.network, + configPointerPath(this.#options.target), + this.#normal.etag, + ); + let trusted = this.#normal.trusted; + let responseWasNotModified = false; + + if (response.status === 304) { + responseWasNotModified = true; + if (!trusted) throw new ConfigCoreError('fetch', 'Received 304 without a trusted pointer'); + } else { + const body = requireBody(response.body, 'pointer'); + const candidate = await verifyPointerBytes(body, { + crypto: this.#options.crypto, + keyring: this.#options.normalKeyring, + target: this.#options.target, + }); + const antiReplay = decideAntiReplay( + pointerReplay(candidate), + this.#normal.highWater ?? null, + ); + if (antiReplay === 'replay' || antiReplay === 'equivocation') { + throw new ConfigCoreError(antiReplay, `Pointer rejected as ${antiReplay}`); + } + const nextState = { + etag: response.etag, + highWater: pointerReplay(candidate), + lkg: this.#normal.lkg, + trusted: candidate, + }; + await saveNormalState(this.#options.storage, this.#options.target, nextState); + this.#normal = nextState; + trusted = candidate; + } + + const lkgRepresentsTrusted = + this.#normal.lkg?.pointer.payloadSha256 === trusted.payloadSha256; + if (lkgRepresentsTrusted) { + return { status: responseWasNotModified ? 'not-modified' : 'idempotent' }; + } + if (trusted.document.snapshotSchemaVersion > this.#options.maximumSchemaVersion) { + throw new ConfigCoreError( + 'unsupported-schema', + `Unsupported schemaVersion ${trusted.document.snapshotSchemaVersion}`, + ); + } + + const snapshotResponse = await getResponse( + this.#options.network, + configSnapshotPath(this.#options.target, trusted.document.sha256), + ); + if (snapshotResponse.status !== 200) { + throw new ConfigCoreError('fetch', `Snapshot request returned ${snapshotResponse.status}`); + } + const snapshot = await validateSnapshotBytes( + requireBody(snapshotResponse.body, 'snapshot'), + trusted.document, + this.#options.target, + this.#options.crypto, + ); + const values = evaluateSnapshot( + snapshot.document, + this.#options.definitions, + this.#options.context, + this.#deviceId, + (event) => this.#report(event), + ); + const lkg: LoadedLkg = { pointer: trusted, snapshot, values }; + const nextState = { + etag: this.#normal.etag, + highWater: this.#normal.highWater, + lkg, + trusted, + }; + await saveNormalState(this.#options.storage, this.#options.target, nextState); + this.#normal = nextState; + this.#projectRemote(lkg); + return { status: 'updated' }; + } catch (error) { + return this.#failure('normal-refresh', error); + } + } + + async #refreshEmergency(): Promise { + try { + const response = await getResponse( + this.#options.emergencyNetwork, + emergencyPath(this.#options.target), + this.#emergency.etag, + ); + if (response.status === 304) { + if (!this.#emergency.document) { + throw new ConfigCoreError('fetch', 'Received 304 without accepted emergency state'); + } + return { status: 'not-modified' }; + } + const candidate = await verifyEmergencyBytes(requireBody(response.body, 'emergency'), { + crypto: this.#options.crypto, + keyring: this.#options.emergencyKeyring, + target: this.#options.target, + }); + const decision = decideAntiReplay( + emergencyReplay(candidate), + this.#emergency.highWater ?? null, + ); + if (decision === 'replay' || decision === 'equivocation') { + throw new ConfigCoreError(decision, `Emergency document rejected as ${decision}`); + } + const before = this.#stateIdentity(); + const nextState = { + document: candidate, + etag: response.etag, + highWater: emergencyReplay(candidate), + }; + await saveEmergencyState(this.#options.storage, this.#options.target, nextState); + this.#emergency = nextState; + this.#active = applyEmergency( + this.#baseActive, + candidate.document, + this.#options.definitions, + ); + this.#emitIfChanged(before); + return { status: decision === 'advance' ? 'updated' : 'idempotent' }; + } catch (error) { + return this.#failure('emergency-refresh', error); + } + } + + #projectRemote(lkg: LoadedLkg): void { + const before = this.#stateIdentity(); + const next: Record = {}; + const staged: Array> = []; + for (const key of Object.keys(this.#options.definitions)) { + const candidate = lkg.values[key]; + const pinned = this.#coldPinned[key]; + if (lkg.snapshot.document.applyModes[key] === 'hot') { + next[key] = cloneJson(candidate); + } else { + next[key] = cloneJson(pinned); + if (!jsonEqual(candidate, pinned)) staged.push(key); + } + } + this.#baseActive = next as ConfigValues; + this.#active = applyEmergency( + this.#baseActive, + this.#emergency.document?.document ?? null, + this.#options.definitions, + ); + this.#source = 'remote'; + this.#configVersion = lkg.pointer.document.configVersion; + this.#stagedColdKeys = staged; + this.#emitIfChanged(before); + } + + #emergencyView(): ConfigEmergencyState | null { + const emergency = this.#emergency.document?.document; + return emergency + ? { + emergencyVersion: emergency.emergencyVersion, + forceMinVersion: emergency.forceMinVersion, + notice: emergency.notice + ? { + body: emergency.notice.body, + title: emergency.notice.title, + url: emergency.notice.url, + } + : null, + } + : null; + } + + #failure( + operation: 'emergency-refresh' | 'normal-refresh', + cause: unknown, + ): { readonly status: 'error'; readonly error: ConfigCoreError } { + const error = asConfigError(cause, 'malformed', 'Configuration operation failed'); + this.#report({ type: 'error', operation, error }); + return { status: 'error', error }; + } + + #report(event: ConfigEvent): void { + try { + this.#options.report?.(event); + } catch { + // Telemetry must not influence configuration acceptance. + } + } + + #stateIdentity(): string { + return JSON.stringify(this.getState()); + } + + #emitIfChanged(before: string): void { + if (this.#stateIdentity() === before) return; + for (const listener of this.#listeners) { + try { + listener(this.getState()); + } catch { + // Subscribers must not influence configuration acceptance. + } + } + } + + #enqueue(operation: () => Promise): Promise { + const result = this.#operationQueue.then(operation); + this.#operationQueue = result.then(noop).catch(noop); + return result; + } +} + +function pointerReplay(pointer: VerifiedPointer) { + return { + payloadSha256: pointer.payloadSha256, + version: pointer.document.activationVersion, + }; +} + +function emergencyReplay(emergency: { + document: { emergencyVersion: string }; + payloadSha256: string; +}) { + return { + payloadSha256: emergency.payloadSha256, + version: emergency.document.emergencyVersion, + }; +} + +async function getResponse(network: ConfigNetwork, path: string, etag?: string) { + try { + const response = await network.get(path, etag ? { etag } : {}); + if (response.status !== 200 && response.status !== 304) { + throw new ConfigCoreError('fetch', `Configuration request returned ${response.status}`); + } + return response; + } catch (error) { + if (error instanceof ConfigCoreError) throw error; + throw new ConfigCoreError('fetch', `Configuration request failed for ${path}`, { + cause: error, + }); + } +} + +function requireBody(body: Uint8Array | undefined, kind: string): Uint8Array { + if (!(body instanceof Uint8Array)) { + throw new ConfigCoreError('fetch', `${kind} response did not contain raw bytes`); + } + return body; +} + +function asConfigError( + cause: unknown, + code: ConstructorParameters[0], + message: string, +): ConfigCoreError { + return cause instanceof ConfigCoreError ? cause : new ConfigCoreError(code, message, { cause }); +} diff --git a/packages/foundation/common/src/config/evaluation.ts b/packages/foundation/common/src/config/evaluation.ts new file mode 100644 index 00000000..854da8c2 --- /dev/null +++ b/packages/foundation/common/src/config/evaluation.ts @@ -0,0 +1,131 @@ +import { + applyConfigPatch, + canonicalizeJson, + conditionMatches, + isConfigKey, + isValidSemver, + rolloutMatches, +} from './contract'; +import { cloneJson } from './i-json'; +import type { + ConfigDefinitions, + ConfigEvent, + ConfigSnapshot, + ConfigValue, + ConfigValueDefinition, + ConfigValues, + EmergencyDocument, + EvaluationContext, + JsonValue, +} from './types'; +import { ConfigCoreError } from './types'; + +export function defaultValues( + definitions: Definitions, +): ConfigValues { + const values: Record = {}; + for (const [key, definition] of Object.entries(definitions)) { + if (!isConfigKey(key)) throw new ConfigCoreError('schema-invalid', `Invalid known key ${key}`); + values[key] = parseKnownValue(definition.parse, definition.defaultValue, `default.${key}`); + if (key.startsWith('feature.')) { + const disabled = parseKnownValue(definition.parse, false, `disabled.${key}`); + if (disabled !== false) { + throw new ConfigCoreError('schema-invalid', `Known feature ${key} must be boolean`); + } + } + } + return values as ConfigValues; +} + +export function evaluateSnapshot( + snapshot: ConfigSnapshot, + definitions: Definitions, + context: EvaluationContext, + deviceId: string, + report: ((event: ConfigEvent) => void) | undefined, +): ConfigValues { + validateKnownValues(snapshot.values, definitions, 'snapshot.values'); + if ( + !isValidSemver(context.appVersion) && + snapshot.overrides.some((entry) => entry.when.appVersion !== undefined) + ) { + report?.({ type: 'invalid-runtime-app-version', value: context.appVersion }); + } + + let evaluated: Record = { ...snapshot.values }; + for (const override of snapshot.overrides) { + if (conditionMatches(override.when, context)) { + evaluated = applyConfigPatch(evaluated, override.set); + } + } + for (const [key, rollout] of Object.entries(snapshot.rollouts)) { + if (rolloutMatches(rollout.salt, deviceId, rollout.basisPoints)) { + evaluated[key] = rollout.value; + } + } + + const values = defaultValues(definitions) as Record; + for (const [key, value] of Object.entries(evaluated)) { + const definition = definitionFor(definitions, key); + if (!definition) continue; + values[key] = parseKnownValue(definition.parse, value, key); + } + return values as ConfigValues; +} + +export function applyEmergency( + values: ConfigValues, + emergency: EmergencyDocument | null, + definitions: Definitions, +): ConfigValues { + const projected = cloneJson(values as ConfigValue) as Record; + for (const key of emergency?.disabledFeatures ?? []) { + if (definitionFor(definitions, key)) projected[key] = false; + } + return projected as ConfigValues; +} + +export function jsonEqual(left: JsonValue, right: JsonValue): boolean { + return canonicalizeJson(left) === canonicalizeJson(right); +} + +function validateKnownValues( + values: Readonly>, + definitions: ConfigDefinitions, + label: string, +): void { + for (const [key, value] of Object.entries(values)) { + const definition = definitionFor(definitions, key); + if (definition) parseKnownValue(definition.parse, value, `${label}.${key}`); + } +} + +function parseKnownValue( + parse: (value: ConfigValue) => ConfigValue, + value: JsonValue, + label: string, +): ConfigValue { + const configValue = value ?? schemaInvalid(`${label} must not be null`); + let parsed: ConfigValue; + try { + parsed = parse(cloneJson(configValue)); + canonicalizeJson(parsed); + } catch (error) { + if (error instanceof ConfigCoreError) throw error; + throw new ConfigCoreError('schema-invalid', `${label} has the wrong product type`, { + cause: error, + }); + } + return cloneJson(parsed); +} + +function definitionFor( + definitions: ConfigDefinitions, + key: string, +): ConfigValueDefinition | undefined { + return Object.hasOwn(definitions, key) ? definitions[key] : undefined; +} + +function schemaInvalid(message: string): never { + throw new ConfigCoreError('schema-invalid', message); +} diff --git a/packages/foundation/common/src/config/i-json.ts b/packages/foundation/common/src/config/i-json.ts new file mode 100644 index 00000000..f758bf3f --- /dev/null +++ b/packages/foundation/common/src/config/i-json.ts @@ -0,0 +1,242 @@ +import { setBit } from 'foxts/bitwise'; +import type { JsonValue } from './types'; + +const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const RE_HEX_ESCAPE = /^[\dA-F]{4}$/i; +const NUMBER_PATTERN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:e[+-]?\d+)?/iy; + +export function encodeBase64Url(bytes: Uint8Array): string { + let encoded = ''; + for (let index = 0; index < bytes.length; index += 3) { + const remaining = bytes.length - index; + const first = bytes[index]; + const second = remaining > 1 ? bytes[index + 1] : 0; + const third = remaining > 2 ? bytes[index + 2] : 0; + encoded += BASE64URL_ALPHABET[first >> 2]; + encoded += BASE64URL_ALPHABET[setBit((first & 3) << 4, second >> 4)]; + if (remaining > 1) encoded += BASE64URL_ALPHABET[setBit((second & 15) << 2, third >> 6)]; + if (remaining > 2) encoded += BASE64URL_ALPHABET[third & 63]; + } + return encoded; +} + +export function decodeBase64Url(encoded: string): Uint8Array { + if (encoded.length % 4 === 1) throw new TypeError('Invalid Base64URL length'); + const decoded = new Uint8Array(Math.floor((encoded.length * 6) / 8)); + let accumulator = 0; + let accumulatedBits = 0; + let offset = 0; + for (const character of encoded) { + const value = BASE64URL_ALPHABET.indexOf(character); + if (value === -1) throw new TypeError('Base64URL must be unpadded and URL-safe'); + accumulator = setBit(accumulator << 6, value); + accumulatedBits += 6; + if (accumulatedBits < 8) continue; + accumulatedBits -= 8; + decoded[offset] = (accumulator >> accumulatedBits) & 255; + offset += 1; + accumulator &= (1 << accumulatedBits) - 1; + } + if (accumulator !== 0 || offset !== decoded.length) { + throw new TypeError('Base64URL has non-canonical trailing bits'); + } + return decoded; +} + +export function cloneJson(value: Value): Value; +export function cloneJson(value: JsonValue): JsonValue { + if (Array.isArray(value)) return value.map((entry) => cloneJson(entry)); + if (typeof value === 'object' && value !== null) { + const cloned: Record = {}; + for (const [key, entry] of Object.entries(value)) cloned[key] = cloneJson(entry); + return cloned; + } + return value; +} + +function assertIJsonValue(value: unknown, label: string): asserts value is JsonValue { + if (typeof value === 'string') { + assertUnicodeScalarString(value, label); + return; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError(`${label} contains a non-finite number`); + return; + } + if (value === null || typeof value === 'boolean') return; + if (Array.isArray(value)) { + for (const [index, entry] of value.entries()) assertIJsonValue(entry, `${label}[${index}]`); + return; + } + if (typeof value !== 'object') throw new TypeError(`${label} is not an I-JSON value`); + for (const [key, entry] of Object.entries(value)) { + assertUnicodeScalarString(key, `${label} key`); + assertIJsonValue(entry, `${label}.${key}`); + } +} + +function assertUnicodeScalarString(value: string, label: string): void { + for (let index = 0; index < value.length; index += 1) { + const code = value.codePointAt(index); + if (code !== undefined && code > 65535) { + index += 1; + } else if (code !== undefined && code >= 55296 && code <= 57343) { + throw new TypeError(`${label} contains a lone surrogate`); + } + } +} + +class DuplicateKeyScanner { + readonly #text: string; + #index = 0; + + constructor(text: string) { + this.#text = text; + } + + scan(): void { + this.#skipWhitespace(); + this.#scanValue(); + this.#skipWhitespace(); + if (this.#index !== this.#text.length) throw new TypeError('Invalid JSON'); + } + + #scanValue(): void { + const character = this.#text[this.#index]; + switch (character) { + case '{': { + this.#scanObject(); + break; + } + case '[': { + this.#scanArray(); + break; + } + case '"': { + this.#scanString(); + break; + } + case 't': { + this.#scanLiteral('true'); + break; + } + case 'f': { + this.#scanLiteral('false'); + break; + } + case 'n': { + this.#scanLiteral('null'); + break; + } + default: + this.#scanNumber(); + } + } + + #scanObject(): void { + this.#index += 1; + this.#skipWhitespace(); + if (this.#consume('}')) return; + const keys = new Set(); + while (true) { + if (this.#text[this.#index] !== '"') throw new TypeError('Invalid JSON object key'); + const key = this.#scanString(); + if (keys.has(key)) throw new TypeError(`I-JSON contains duplicate object member ${key}`); + keys.add(key); + this.#skipWhitespace(); + if (!this.#consume(':')) throw new TypeError('Invalid JSON object'); + this.#skipWhitespace(); + this.#scanValue(); + this.#skipWhitespace(); + if (this.#consume('}')) return; + if (!this.#consume(',')) throw new TypeError('Invalid JSON object'); + this.#skipWhitespace(); + } + } + + #scanArray(): void { + this.#index += 1; + this.#skipWhitespace(); + if (this.#consume(']')) return; + while (true) { + this.#scanValue(); + this.#skipWhitespace(); + if (this.#consume(']')) return; + if (!this.#consume(',')) throw new TypeError('Invalid JSON array'); + this.#skipWhitespace(); + } + } + + #scanString(): string { + const start = this.#index; + this.#index += 1; + while (this.#index < this.#text.length) { + const character = this.#text[this.#index]; + if (character === '"') { + this.#index += 1; + return JSON.parse(this.#text.slice(start, this.#index)) as string; + } + if (character === '\\') { + this.#index += 1; + const escape = this.#text[this.#index]; + if (escape === 'u') { + const hex = this.#text.slice(this.#index + 1, this.#index + 5); + if (!RE_HEX_ESCAPE.test(hex)) throw new TypeError('Invalid JSON string escape'); + this.#index += 5; + continue; + } + if (!String.raw`"\/bfnrt`.includes(escape)) { + throw new TypeError('Invalid JSON string escape'); + } + this.#index += 1; + continue; + } + if ((character.codePointAt(0) ?? 0) < 32) throw new TypeError('Invalid JSON string'); + this.#index += 1; + } + throw new TypeError('Unterminated JSON string'); + } + + #scanLiteral(literal: string): void { + if (!this.#text.startsWith(literal, this.#index)) throw new TypeError('Invalid JSON literal'); + this.#index += literal.length; + } + + #scanNumber(): void { + NUMBER_PATTERN.lastIndex = this.#index; + const match = NUMBER_PATTERN.exec(this.#text); + if (!match) throw new TypeError('Invalid JSON number'); + this.#index = NUMBER_PATTERN.lastIndex; + } + + #skipWhitespace(): void { + while (' \n\r\t'.includes(this.#text[this.#index] ?? 'x')) this.#index += 1; + } + + #consume(character: string): boolean { + if (this.#text[this.#index] !== character) return false; + this.#index += 1; + return true; + } +} + +export function parseIJson(bytes: Uint8Array): JsonValue { + if (bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) { + throw new TypeError('I-JSON must not contain a UTF-8 BOM'); + } + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (error) { + throw new TypeError('I-JSON must contain valid UTF-8', { cause: error }); + } + new DuplicateKeyScanner(text).scan(); + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + throw new TypeError('Invalid JSON', { cause: error }); + } + assertIJsonValue(value, 'document'); + return value; +} diff --git a/packages/foundation/common/src/config/index.ts b/packages/foundation/common/src/config/index.ts new file mode 100644 index 00000000..a9161706 --- /dev/null +++ b/packages/foundation/common/src/config/index.ts @@ -0,0 +1,73 @@ +export { + applyConfigPatch, + applyMergePatch, + assertConfigPointer, + assertConfigSnapshot, + assertEmergencyDocument, + assertMonotonicVersion, + canonicalSignedPayload, + canonicalSignedPayloadBytes, + compareMonotonicVersions, + conditionMatches, + configPointerPath, + configSnapshotPath, + decideAntiReplay, + emergencyPath, + emergencyTargetMatches, + isConfigKey, + isValidSemver, + isValidVersionRange, + localeMatches, + matchesVersionRange, + murmur3X86_32, + normalizeLocale, + rolloutBucket, + rolloutMatches, + targetMatches, +} from './contract'; +export type { ConfigCoreOptions } from './core'; +export { ConfigCore } from './core'; +export { decodeBase64Url, encodeBase64Url, parseIJson } from './i-json'; +export type { + AntiReplayDecision, + AntiReplayState, + ApplyMode, + ConfigChannel, + ConfigCrypto, + ConfigDefinitions, + ConfigEmergencyState, + ConfigErrorCode, + ConfigEvent, + ConfigNetwork, + ConfigNetworkRequest, + ConfigNetworkResponse, + ConfigOverride, + ConfigPlatform, + ConfigPointer, + ConfigRefreshResult, + ConfigRollout, + ConfigRuntimeState, + ConfigSnapshot, + ConfigStorage, + ConfigTarget, + ConfigValue, + ConfigValueDefinition, + ConfigValues, + EmergencyDocument, + EmergencyNotice, + EvaluationContext, + JsonPrimitive, + JsonValue, + OperatingSystem, + OverrideCondition, +} from './types'; +export { + APPLY_MODES, + CONFIG_CHANNELS, + CONFIG_CONTRACT_VERSION, + CONFIG_PLATFORMS, + ConfigCoreError, + MAX_MONOTONIC_VERSION, + MAX_SNAPSHOT_SIZE_BYTES, + OPERATING_SYSTEMS, +} from './types'; diff --git a/packages/foundation/common/src/config/persistence.ts b/packages/foundation/common/src/config/persistence.ts new file mode 100644 index 00000000..565ce0bd --- /dev/null +++ b/packages/foundation/common/src/config/persistence.ts @@ -0,0 +1,326 @@ +import { decideAntiReplay, isRecord, isUuidV4 } from './contract'; +import { evaluateSnapshot } from './evaluation'; +import { decodeBase64Url, encodeBase64Url } from './i-json'; +import type { + AntiReplayState, + ConfigCrypto, + ConfigDefinitions, + ConfigEvent, + ConfigStorage, + ConfigTarget, + ConfigValues, + EvaluationContext, +} from './types'; +import { ConfigCoreError } from './types'; +import type { ValidatedSnapshot, VerifiedEmergency, VerifiedPointer } from './verification'; +import { validateSnapshotBytes, verifyEmergencyBytes, verifyPointerBytes } from './verification'; + +export interface LoadedLkg { + readonly pointer: VerifiedPointer; + readonly snapshot: ValidatedSnapshot; + readonly values: ConfigValues; +} + +export interface NormalPersistentState { + readonly etag?: string; + readonly highWater?: AntiReplayState; + readonly lkg?: LoadedLkg; + readonly trusted?: VerifiedPointer; +} + +export interface EmergencyPersistentState { + readonly document?: VerifiedEmergency; + readonly etag?: string; + readonly highWater?: AntiReplayState; +} + +interface PersistenceOptions { + readonly context: EvaluationContext; + readonly crypto: ConfigCrypto; + readonly definitions: Definitions; + readonly deviceId: string; + readonly emergencyKeyring: Readonly>; + readonly maximumSchemaVersion: number; + readonly normalKeyring: Readonly>; + readonly report?: (event: ConfigEvent) => void; + readonly storage: ConfigStorage; + readonly target: ConfigTarget; +} + +export function normalStorageKey(target: ConfigTarget): string { + return `linkcode-config:v1:normal:${target.brandId}:${target.platform}:${target.channel}`; +} + +export function emergencyStorageKey(target: ConfigTarget): string { + return `linkcode-config:v1:emergency:${target.brandId}:${target.platform}`; +} + +export const DEVICE_ID_STORAGE_KEY = 'linkcode-config:v1:device-id'; + +export async function loadDeviceId(storage: ConfigStorage, crypto: ConfigCrypto): Promise { + const stored = await storageGet(storage, DEVICE_ID_STORAGE_KEY); + if (stored !== null && isUuidV4(stored)) return stored; + let deviceId: string; + try { + deviceId = await crypto.randomUuid(); + } catch (error) { + throw new ConfigCoreError('crypto-unavailable', 'UUIDv4 generation is unavailable', { + cause: error, + }); + } + if (!isUuidV4(deviceId)) { + throw new ConfigCoreError('crypto-unavailable', 'UUID generator did not return a UUIDv4'); + } + await storageSet(storage, DEVICE_ID_STORAGE_KEY, deviceId); + return deviceId; +} + +export async function loadNormalState( + options: PersistenceOptions, +): Promise> { + const key = normalStorageKey(options.target); + const stored = await storageGet(options.storage, key); + if (stored === null) return {}; + let value: unknown; + try { + value = JSON.parse(stored); + } catch (error) { + await discardCorrupt(options, key, 'Stored normal state is malformed', error); + return {}; + } + if (!isRecord(value) || value.version !== 1) { + await discardCorrupt(options, key, 'Stored normal state is malformed'); + return {}; + } + if (value.highWater === undefined && value.trusted === undefined) return {}; + let highWater: AntiReplayState; + try { + highWater = parseReplayState(value.highWater); + } catch (error) { + await discardCorrupt(options, key, 'Stored normal replay state is malformed', error); + return {}; + } + if (!isRecord(value.trusted)) { + reportCorruption(options, 'Stored trusted pointer is missing'); + await saveNormalState(options.storage, options.target, { highWater }); + return { highWater }; + } + + let trusted: VerifiedPointer; + try { + trusted = await verifyStoredPointer(value.trusted.pointer, options); + if (decideAntiReplay(pointerReplay(trusted), highWater) !== 'idempotent') { + throw new TypeError('Stored trusted pointer does not match replay state'); + } + } catch (error) { + reportCorruption(options, 'Stored trusted pointer failed verification', error); + await saveNormalState(options.storage, options.target, { highWater }); + return { highWater }; + } + const etag = typeof value.trusted.etag === 'string' ? value.trusted.etag : undefined; + let lkg: LoadedLkg | undefined; + if (value.lkg !== undefined) { + try { + if (!isRecord(value.lkg)) throw new TypeError('LKG must be an object'); + const pointer = await verifyStoredPointer(value.lkg.pointer, options); + const replay = decideAntiReplay(pointerReplay(pointer), highWater); + if (replay === 'advance' || replay === 'equivocation') { + throw new TypeError('LKG pointer is inconsistent with trusted high-water'); + } + if (pointer.document.snapshotSchemaVersion > options.maximumSchemaVersion) { + throw new TypeError('LKG schema is unsupported'); + } + const snapshotBytes = decodeStoredBytes(value.lkg.snapshot, 'LKG snapshot'); + const snapshot = await validateSnapshotBytes( + snapshotBytes, + pointer.document, + options.target, + options.crypto, + ); + const values = evaluateSnapshot( + snapshot.document, + options.definitions, + options.context, + options.deviceId, + options.report, + ); + lkg = { pointer, snapshot, values }; + } catch (error) { + reportCorruption(options, 'Stored LKG failed verification', error); + await saveNormalState(options.storage, options.target, { etag, highWater, trusted }); + } + } + return { etag, highWater, lkg, trusted }; +} + +export async function saveNormalState( + storage: ConfigStorage, + target: ConfigTarget, + state: NormalPersistentState, +): Promise { + const value = { + version: 1, + ...(state.highWater && { highWater: state.highWater }), + ...(state.trusted && { + trusted: { + pointer: encodeBase64Url(state.trusted.rawBytes), + ...(state.etag && { etag: state.etag }), + }, + }), + ...(state.lkg && { + lkg: { + pointer: encodeBase64Url(state.lkg.pointer.rawBytes), + snapshot: encodeBase64Url(state.lkg.snapshot.rawBytes), + }, + }), + }; + await storageSet(storage, normalStorageKey(target), JSON.stringify(value)); +} + +export async function loadEmergencyState( + options: PersistenceOptions, +): Promise { + const key = emergencyStorageKey(options.target); + const stored = await storageGet(options.storage, key); + if (stored === null) return {}; + let value: Record; + let highWater: AntiReplayState; + try { + const parsed = JSON.parse(stored) as unknown; + if (!isRecord(parsed) || parsed.version !== 1) { + throw new TypeError('Emergency state must be an object'); + } + value = parsed; + if (value.highWater === undefined && value.document === undefined) return {}; + highWater = parseReplayState(value.highWater); + } catch (error) { + await discardCorrupt(options, key, 'Stored emergency state failed verification', error); + return {}; + } + if (!isRecord(value.document)) { + reportCorruption(options, 'Stored emergency document is missing'); + await saveEmergencyState(options.storage, options.target, { highWater }); + return { highWater }; + } + let document: VerifiedEmergency; + try { + const rawBytes = decodeStoredBytes(value.document.raw, 'Emergency document'); + document = await verifyEmergencyBytes(rawBytes, { + crypto: options.crypto, + keyring: options.emergencyKeyring, + target: options.target, + }); + if (decideAntiReplay(emergencyReplay(document), highWater) !== 'idempotent') { + throw new TypeError('Stored emergency document does not match replay state'); + } + } catch (error) { + reportCorruption(options, 'Stored emergency document failed verification', error); + await saveEmergencyState(options.storage, options.target, { highWater }); + return { highWater }; + } + return { + document, + etag: typeof value.document.etag === 'string' ? value.document.etag : undefined, + highWater, + }; +} + +export async function saveEmergencyState( + storage: ConfigStorage, + target: ConfigTarget, + state: EmergencyPersistentState, +): Promise { + const value = { + version: 1, + ...(state.highWater && { highWater: state.highWater }), + ...(state.document && { + document: { + raw: encodeBase64Url(state.document.rawBytes), + ...(state.etag && { etag: state.etag }), + }, + }), + }; + await storageSet(storage, emergencyStorageKey(target), JSON.stringify(value)); +} + +async function verifyStoredPointer( + value: unknown, + options: PersistenceOptions, +): Promise { + const rawBytes = decodeStoredBytes(value, 'Stored pointer'); + return verifyPointerBytes(rawBytes, { + crypto: options.crypto, + keyring: options.normalKeyring, + target: options.target, + }); +} + +function decodeStoredBytes(value: unknown, label: string): Uint8Array { + if (typeof value !== 'string') throw new TypeError(`${label} must be Base64URL`); + return decodeBase64Url(value); +} + +function parseReplayState(value: unknown): AntiReplayState { + if ( + !isRecord(value) || + typeof value.version !== 'string' || + typeof value.payloadSha256 !== 'string' + ) { + throw new TypeError('Replay state must contain string version and payloadSha256 fields'); + } + const highWater = { payloadSha256: value.payloadSha256, version: value.version }; + decideAntiReplay(highWater, null); + return highWater; +} + +function pointerReplay(pointer: VerifiedPointer): AntiReplayState { + return { + payloadSha256: pointer.payloadSha256, + version: pointer.document.activationVersion, + }; +} + +function emergencyReplay(document: VerifiedEmergency): AntiReplayState { + return { + payloadSha256: document.payloadSha256, + version: document.document.emergencyVersion, + }; +} + +async function discardCorrupt( + options: PersistenceOptions, + key: string, + message: string, + cause?: unknown, +): Promise { + reportCorruption(options, message, cause); + await storageSet(options.storage, key, JSON.stringify({ version: 1 })); +} + +function reportCorruption( + options: PersistenceOptions, + message: string, + cause?: unknown, +): void { + options.report?.({ + type: 'error', + operation: 'initialize', + error: new ConfigCoreError('storage', message, { cause }), + }); +} + +async function storageGet(storage: ConfigStorage, key: string): Promise { + try { + return await storage.get(key); + } catch (error) { + throw new ConfigCoreError('storage', `Failed to read ${key}`, { cause: error }); + } +} + +async function storageSet(storage: ConfigStorage, key: string, value: string): Promise { + try { + await storage.set(key, value); + } catch (error) { + throw new ConfigCoreError('storage', `Failed to write ${key}`, { cause: error }); + } +} diff --git a/packages/foundation/common/src/config/rules.ts b/packages/foundation/common/src/config/rules.ts new file mode 100644 index 00000000..50237e3f --- /dev/null +++ b/packages/foundation/common/src/config/rules.ts @@ -0,0 +1,142 @@ +import { setBit } from 'foxts/bitwise'; +import { cloneJson } from './i-json'; +import { matchesVersionRange } from './semver'; +import type { EvaluationContext, JsonValue, OverrideCondition } from './types'; + +const RE_CONFIG_KEY = /^(?:app|content|feature|modules|params|ui)(?:\.[a-z][A-Za-z0-9]*)+$/; +const RE_LOCALE_SUBTAG = /^[\dA-Z]{1,8}$/i; +const RE_UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const RE_ASCII_UPPER = /[A-Z]/g; + +function fail(message: string): never { + throw new TypeError(message); +} + +export function isConfigKey(value: string): boolean { + return RE_CONFIG_KEY.test(value); +} + +export function isUuidV4(value: string): boolean { + return RE_UUID_V4.test(value); +} + +export function applyMergePatch( + target: JsonValue | undefined, + patch: JsonValue, +): JsonValue | undefined { + if (patch === null) return undefined; + if (typeof patch !== 'object' || Array.isArray(patch)) return cloneJson(patch); + const result: Record = + typeof target === 'object' && target !== null && !Array.isArray(target) + ? (cloneJson(target) as Record) + : {}; + for (const [key, patchValue] of Object.entries(patch)) { + const merged = applyMergePatch(result[key], patchValue); + if (merged === undefined) Reflect.deleteProperty(result, key); + else result[key] = merged; + } + return result; +} + +export function applyConfigPatch( + values: Readonly>, + patch: Readonly>, +): Record { + const result = Object.fromEntries( + Object.entries(values).map(([key, value]) => [key, cloneJson(value)]), + ); + for (const [key, patchValue] of Object.entries(patch)) { + if (!isConfigKey(key)) fail(`patch key ${key} is invalid`); + const merged = applyMergePatch(result[key], patchValue); + if (merged === undefined) Reflect.deleteProperty(result, key); + else result[key] = merged; + } + return result; +} + +export function normalizeLocale(value: string): string | null { + const normalized = value + .replaceAll('_', '-') + .replaceAll(RE_ASCII_UPPER, (character) => + String.fromCodePoint((character.codePointAt(0) ?? 0) + 32), + ); + const subtags = normalized.split('-'); + if (subtags.some((subtag) => !RE_LOCALE_SUBTAG.test(subtag))) return null; + return normalized; +} + +export function localeMatches(locale: string, prefix: string): boolean { + const normalizedLocale = normalizeLocale(locale); + const normalizedPrefix = normalizeLocale(prefix); + if (!normalizedLocale || !normalizedPrefix) return false; + return ( + normalizedLocale === normalizedPrefix || normalizedLocale.startsWith(`${normalizedPrefix}-`) + ); +} + +export function conditionMatches( + condition: OverrideCondition, + context: EvaluationContext, +): boolean { + if ( + condition.appVersion !== undefined && + !matchesVersionRange(context.appVersion, condition.appVersion) + ) { + return false; + } + if (condition.os !== undefined && condition.os !== context.os) return false; + return !(condition.locale !== undefined && !localeMatches(context.locale, condition.locale)); +} + +// eslint-disable-next-line @typescript-eslint/naming-convention -- x86_32 is the standardized variant name. +export function murmur3X86_32(value: string, seed = 0): number { + const bytes = new TextEncoder().encode(value); + let hash = seed >>> 0; + const blockEnd = bytes.length - (bytes.length % 4); + for (let index = 0; index < blockEnd; index += 4) { + let block = setBit( + setBit(setBit(bytes[index], bytes[index + 1] << 8), bytes[index + 2] << 16), + bytes[index + 3] << 24, + ); + block = Math.imul(block, 3_432_918_353); + block = setBit(block << 15, block >>> 17); + block = Math.imul(block, 461_845_907); + hash ^= block; + hash = setBit(hash << 13, hash >>> 19); + hash = Math.imul(hash, 5) + 3_864_292_196; + } + let tail = 0; + const remainder = bytes.length % 4; + if (remainder === 3) tail ^= bytes[blockEnd + 2] << 16; + if (remainder >= 2) tail ^= bytes[blockEnd + 1] << 8; + if (remainder >= 1) { + tail ^= bytes[blockEnd]; + tail = Math.imul(tail, 3_432_918_353); + tail = setBit(tail << 15, tail >>> 17); + tail = Math.imul(tail, 461_845_907); + hash ^= tail; + } + hash ^= bytes.length; + hash ^= hash >>> 16; + hash = Math.imul(hash, 2_246_822_507); + hash ^= hash >>> 13; + hash = Math.imul(hash, 3_266_489_909); + hash ^= hash >>> 16; + return hash >>> 0; +} + +export function rolloutBucket(salt: string, deviceId: string): number { + if (!RE_UUID_V4.test(deviceId)) fail('deviceId must be a UUIDv4'); + const saltBytes = new TextEncoder().encode(salt); + if (saltBytes.byteLength < 1 || saltBytes.byteLength > 128) { + fail('salt must contain 1 to 128 UTF-8 bytes'); + } + return murmur3X86_32(`${salt}:${deviceId.toLowerCase()}`, 0) % 10000; +} + +export function rolloutMatches(salt: string, deviceId: string, basisPoints: number): boolean { + if (!Number.isSafeInteger(basisPoints) || basisPoints < 0 || basisPoints > 10000) { + fail('basisPoints must be an integer from 0 through 10000'); + } + return rolloutBucket(salt, deviceId) < basisPoints; +} diff --git a/packages/foundation/common/src/config/semver.ts b/packages/foundation/common/src/config/semver.ts new file mode 100644 index 00000000..a23d1446 --- /dev/null +++ b/packages/foundation/common/src/config/semver.ts @@ -0,0 +1,131 @@ +interface ParsedSemver { + readonly major: string; + readonly minor: string; + readonly patch: string; + readonly prerelease: readonly string[]; +} + +interface VersionComparator { + readonly operator: '<' | '<=' | '=' | '>' | '>='; + readonly version: ParsedSemver; +} + +const RE_DECIMAL = /^(?:0|[1-9]\d*)$/; +const RE_NUMERIC_IDENTIFIER = /^\d+$/; +const RE_SEMVER_IDENTIFIER = /^[\dA-Z-]+$/i; + +function compareDecimal(left: string, right: string): number { + if (left.length !== right.length) return left.length < right.length ? -1 : 1; + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function parseSemver(value: string): ParsedSemver | null { + const plusParts = value.split('+'); + if (plusParts.length > 2) return null; + const versionAndPrerelease = plusParts[0]; + const build = plusParts.length === 2 ? plusParts[1] : null; + if ( + build !== null && + (build.length === 0 || build.split('.').some((part) => !RE_SEMVER_IDENTIFIER.test(part))) + ) { + return null; + } + const dashIndex = versionAndPrerelease.indexOf('-'); + const core = dashIndex === -1 ? versionAndPrerelease : versionAndPrerelease.slice(0, dashIndex); + const prereleaseText = dashIndex === -1 ? undefined : versionAndPrerelease.slice(dashIndex + 1); + const coreParts = core.split('.'); + if (coreParts.length !== 3 || coreParts.some((part) => !RE_DECIMAL.test(part))) return null; + const prerelease = prereleaseText?.split('.') ?? []; + if ( + prereleaseText === '' || + prerelease.some( + (part) => + !RE_SEMVER_IDENTIFIER.test(part) || + (RE_NUMERIC_IDENTIFIER.test(part) && !RE_DECIMAL.test(part)), + ) + ) { + return null; + } + return { + major: coreParts[0], + minor: coreParts[1], + patch: coreParts[2], + prerelease, + }; +} + +function compareSemver(left: ParsedSemver, right: ParsedSemver): number { + for (const field of ['major', 'minor', 'patch'] as const) { + const comparison = compareDecimal(left[field], right[field]); + if (comparison !== 0) return comparison; + } + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + if (left.prerelease.length === right.prerelease.length) return 0; + return left.prerelease.length === 0 ? 1 : -1; + } + const length = Math.min(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftPart = left.prerelease[index]; + const rightPart = right.prerelease[index]; + if (leftPart === rightPart) continue; + const leftNumeric = RE_NUMERIC_IDENTIFIER.test(leftPart); + const rightNumeric = RE_NUMERIC_IDENTIFIER.test(rightPart); + if (leftNumeric && rightNumeric) return compareDecimal(leftPart, rightPart); + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1; + return leftPart < rightPart ? -1 : 1; + } + if (left.prerelease.length === right.prerelease.length) return 0; + return left.prerelease.length < right.prerelease.length ? -1 : 1; +} + +function parseVersionRange(value: string): readonly VersionComparator[] | null { + if (value.length === 0 || value.trim() !== value) return null; + const parts = value.split(' '); + if (parts.some((part) => part.length === 0)) return null; + const comparators: VersionComparator[] = []; + for (const part of parts) { + const operator = (['>=', '<=', '>', '<', '='] as const).find((candidate) => + part.startsWith(candidate), + ); + if (!operator) return null; + const version = parseSemver(part.slice(operator.length)); + if (!version) return null; + comparators.push({ operator, version }); + } + return comparators; +} + +function comparatorMatches(comparison: number, operator: VersionComparator['operator']): boolean { + switch (operator) { + case '<': + return comparison < 0; + case '<=': + return comparison <= 0; + case '=': + return comparison === 0; + case '>': + return comparison > 0; + case '>=': + return comparison >= 0; + default: + return false; + } +} + +export function isValidSemver(value: string): boolean { + return parseSemver(value) !== null; +} + +export function isValidVersionRange(value: string): boolean { + return parseVersionRange(value) !== null; +} + +export function matchesVersionRange(appVersion: string, range: string): boolean { + const parsedVersion = parseSemver(appVersion); + const comparators = parseVersionRange(range); + if (!parsedVersion || !comparators) return false; + return comparators.every((comparator) => + comparatorMatches(compareSemver(parsedVersion, comparator.version), comparator.operator), + ); +} diff --git a/packages/foundation/common/src/config/types.ts b/packages/foundation/common/src/config/types.ts new file mode 100644 index 00000000..ffffadbc --- /dev/null +++ b/packages/foundation/common/src/config/types.ts @@ -0,0 +1,204 @@ +export const CONFIG_CONTRACT_VERSION = 1; +export const MAX_SNAPSHOT_SIZE_BYTES = 1024 * 1024; +export const MAX_MONOTONIC_VERSION = '18446744073709551615'; + +export const CONFIG_PLATFORMS = ['desktop', 'ios', 'android'] as const; +export const CONFIG_CHANNELS = ['canary', 'stable'] as const; +export const APPLY_MODES = ['hot', 'cold'] as const; +export const OPERATING_SYSTEMS = ['windows', 'macos', 'linux', 'ios', 'android'] as const; + +export type ConfigPlatform = (typeof CONFIG_PLATFORMS)[number]; +export type ConfigChannel = (typeof CONFIG_CHANNELS)[number]; +export type ApplyMode = (typeof APPLY_MODES)[number]; +export type OperatingSystem = (typeof OPERATING_SYSTEMS)[number]; + +export type JsonPrimitive = boolean | number | string | null; +export type JsonValue = + | JsonPrimitive + | { readonly [key: string]: JsonValue } + | readonly JsonValue[]; +export type ConfigValue = Exclude; + +export interface ConfigTarget { + readonly brandId: string; + readonly platform: ConfigPlatform; + readonly channel: ConfigChannel; +} + +export interface OverrideCondition { + readonly appVersion?: string; + readonly locale?: string; + readonly os?: OperatingSystem; +} + +export interface ConfigOverride { + readonly when: OverrideCondition; + readonly set: Readonly>; +} + +export interface ConfigRollout { + readonly basisPoints: number; + readonly salt: string; + readonly value: boolean; +} + +export interface ConfigSnapshot extends ConfigTarget { + readonly applyModes: Readonly>; + readonly configVersion: string; + readonly contractVersion: 1; + readonly generatedAt: string; + readonly overrides: readonly ConfigOverride[]; + readonly rollouts: Readonly>; + readonly schemaVersion: number; + readonly values: Readonly>; +} + +export interface ConfigPointer extends ConfigTarget { + readonly activationVersion: string; + readonly configVersion: string; + readonly contractVersion: 1; + readonly createdAt: string; + readonly keyId: string; + readonly sha256: string; + readonly sig: string; + readonly sizeBytes: number; + readonly snapshotSchemaVersion: number; +} + +export interface EmergencyNotice { + readonly body: string; + readonly title: string; + readonly url: string | null; +} + +export interface EmergencyDocument { + readonly brandId: string; + readonly contractVersion: 1; + readonly createdAt: string; + readonly disabledFeatures: readonly string[]; + readonly emergencyVersion: string; + readonly forceMinVersion: string | null; + readonly keyId: string; + readonly notice: EmergencyNotice | null; + readonly platform: ConfigPlatform; + readonly sig: string; +} + +export interface EvaluationContext { + readonly appVersion: string; + readonly locale: string; + readonly os: OperatingSystem; +} + +export interface AntiReplayState { + readonly payloadSha256: string; + readonly version: string; +} + +export type AntiReplayDecision = 'advance' | 'equivocation' | 'idempotent' | 'replay'; + +export interface ConfigNetworkRequest { + readonly etag?: string; +} + +export interface ConfigNetworkResponse { + readonly status: number; + // The body must be the exact response bytes, without text decoding. + readonly body?: Uint8Array; + readonly etag?: string; +} + +export interface ConfigNetwork { + get(path: string, request: ConfigNetworkRequest): Promise; +} + +export interface ConfigStorage { + get(key: string): Promise; + // Implementations must atomically replace the complete value. + set(key: string, value: string): Promise; +} + +export interface ConfigCrypto { + randomUuid(): Promise | string; + sha256(bytes: Uint8Array): Promise; + verifyEd25519( + publicKey: Uint8Array, + signature: Uint8Array, + message: Uint8Array, + ): Promise; +} + +export interface ConfigValueDefinition { + readonly defaultValue: Value; + readonly parse: (value: ConfigValue) => Value; +} + +export type ConfigDefinitions = Readonly>; + +export type ConfigValues = { + readonly [Key in keyof Definitions]: Definitions[Key] extends ConfigValueDefinition + ? Value + : never; +}; + +export type ConfigErrorCode = + | 'crypto-unavailable' + | 'equivocation' + | 'fetch' + | 'hash-mismatch' + | 'invalid-key-length' + | 'invalid-signature' + | 'invalid-signature-length' + | 'malformed' + | 'malformed-key' + | 'malformed-signature' + | 'replay' + | 'schema-invalid' + | 'size-mismatch' + | 'storage' + | 'target-mismatch' + | 'unknown-key' + | 'unsupported-contract' + | 'unsupported-schema'; + +export class ConfigCoreError extends Error { + readonly code: ConfigErrorCode; + + // eslint-disable-next-line sukka/unicorn/custom-error-definition -- The mandatory code precedes the diagnostic. + constructor(code: ConfigErrorCode, message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ConfigCoreError'; + this.code = code; + } +} + +export interface ConfigErrorEvent { + readonly type: 'error'; + readonly operation: 'emergency-refresh' | 'initialize' | 'normal-refresh'; + readonly error: ConfigCoreError; +} + +export interface InvalidRuntimeVersionEvent { + readonly type: 'invalid-runtime-app-version'; + readonly value: string; +} + +export type ConfigEvent = ConfigErrorEvent | InvalidRuntimeVersionEvent; + +export type ConfigRefreshResult = + | { readonly status: 'idempotent' | 'not-modified' | 'updated' } + | { readonly status: 'error'; readonly error: ConfigCoreError }; + +export interface ConfigEmergencyState { + readonly emergencyVersion: string; + readonly forceMinVersion: string | null; + readonly notice: EmergencyNotice | null; +} + +export interface ConfigRuntimeState { + readonly configVersion: string | null; + readonly emergency: ConfigEmergencyState | null; + readonly source: 'defaults' | 'lkg' | 'remote'; + readonly stagedColdKeys: ReadonlyArray; + readonly values: Values; +} diff --git a/packages/foundation/common/src/config/verification.ts b/packages/foundation/common/src/config/verification.ts new file mode 100644 index 00000000..0e49410e --- /dev/null +++ b/packages/foundation/common/src/config/verification.ts @@ -0,0 +1,206 @@ +import { + assertConfigPointer, + assertConfigSnapshot, + assertEmergencyDocument, + canonicalSignedPayloadBytes, + emergencyTargetMatches, + isRecord, + targetMatches, +} from './contract'; +import { decodeBase64Url, parseIJson } from './i-json'; +import type { + ConfigCrypto, + ConfigPointer, + ConfigSnapshot, + ConfigTarget, + EmergencyDocument, +} from './types'; +import { CONFIG_CONTRACT_VERSION, ConfigCoreError, MAX_SNAPSHOT_SIZE_BYTES } from './types'; + +export interface VerifiedPointer { + readonly document: ConfigPointer; + readonly payloadSha256: string; + readonly rawBytes: Uint8Array; +} + +export interface VerifiedEmergency { + readonly document: EmergencyDocument; + readonly payloadSha256: string; + readonly rawBytes: Uint8Array; +} + +export interface ValidatedSnapshot { + readonly document: ConfigSnapshot; + readonly rawBytes: Uint8Array; +} + +interface VerificationOptions { + readonly crypto: ConfigCrypto; + readonly keyring: Readonly>; + readonly target: ConfigTarget; +} + +export async function verifyPointerBytes( + rawBytes: Uint8Array, + options: VerificationOptions, +): Promise { + const value = parseDocument(rawBytes); + assertSupportedContract(value); + assertSignatureEncoding(value); + try { + assertConfigPointer(value); + } catch (error) { + throw new ConfigCoreError('malformed', 'Invalid signed pointer', { cause: error }); + } + if (!targetMatches(value, options.target)) { + throw new ConfigCoreError('target-mismatch', 'Pointer target does not match bootstrap'); + } + const payloadBytes = canonicalSignedPayloadBytes(value); + await verifySignature(value.keyId, value.sig, payloadBytes, options); + return { + document: value, + payloadSha256: await sha256Hex(payloadBytes, options.crypto), + rawBytes: rawBytes.slice(), + }; +} + +export async function verifyEmergencyBytes( + rawBytes: Uint8Array, + options: VerificationOptions, +): Promise { + const value = parseDocument(rawBytes); + assertSupportedContract(value); + assertSignatureEncoding(value); + try { + assertEmergencyDocument(value); + } catch (error) { + throw new ConfigCoreError('malformed', 'Invalid signed emergency document', { cause: error }); + } + if (!emergencyTargetMatches(value, options.target)) { + throw new ConfigCoreError('target-mismatch', 'Emergency target does not match bootstrap'); + } + const payloadBytes = canonicalSignedPayloadBytes(value); + await verifySignature(value.keyId, value.sig, payloadBytes, options); + return { + document: value, + payloadSha256: await sha256Hex(payloadBytes, options.crypto), + rawBytes: rawBytes.slice(), + }; +} + +export async function validateSnapshotBytes( + rawBytes: Uint8Array, + pointer: ConfigPointer, + target: ConfigTarget, + crypto: ConfigCrypto, +): Promise { + if (rawBytes.byteLength > MAX_SNAPSHOT_SIZE_BYTES || rawBytes.byteLength !== pointer.sizeBytes) { + throw new ConfigCoreError( + 'size-mismatch', + `Snapshot size ${rawBytes.byteLength} does not match ${pointer.sizeBytes}`, + ); + } + const digest = await sha256Hex(rawBytes, crypto); + if (digest !== pointer.sha256) { + throw new ConfigCoreError('hash-mismatch', 'Snapshot SHA-256 does not match pointer'); + } + const value = parseDocument(rawBytes); + assertSupportedContract(value); + try { + assertConfigSnapshot(value); + } catch (error) { + throw new ConfigCoreError('schema-invalid', 'Snapshot violates configuration contract', { + cause: error, + }); + } + if (!targetMatches(value, target)) { + throw new ConfigCoreError('target-mismatch', 'Snapshot target does not match bootstrap'); + } + if ( + value.configVersion !== pointer.configVersion || + value.schemaVersion !== pointer.snapshotSchemaVersion + ) { + throw new ConfigCoreError('schema-invalid', 'Snapshot metadata does not match pointer'); + } + return { document: value, rawBytes: rawBytes.slice() }; +} + +export async function sha256Hex(bytes: Uint8Array, crypto: ConfigCrypto): Promise { + let digest: Uint8Array; + try { + digest = await crypto.sha256(bytes.slice()); + } catch (error) { + throw new ConfigCoreError('crypto-unavailable', 'SHA-256 is unavailable', { cause: error }); + } + if (digest.byteLength !== 32) { + throw new ConfigCoreError('crypto-unavailable', 'SHA-256 returned the wrong digest length'); + } + return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function parseDocument(rawBytes: Uint8Array): unknown { + try { + return parseIJson(rawBytes); + } catch (error) { + throw new ConfigCoreError('malformed', 'Response is not valid I-JSON', { cause: error }); + } +} + +function assertSupportedContract(value: unknown): void { + if (isRecord(value) && value.contractVersion !== CONFIG_CONTRACT_VERSION) { + throw new ConfigCoreError( + 'unsupported-contract', + `Unsupported contractVersion ${String(value.contractVersion)}`, + ); + } +} + +function assertSignatureEncoding(value: unknown): void { + if (!isRecord(value) || typeof value.sig !== 'string') { + throw new ConfigCoreError('malformed-signature', 'Signature must be a Base64URL string'); + } + let signature: Uint8Array; + try { + signature = decodeBase64Url(value.sig); + } catch (error) { + throw new ConfigCoreError('malformed-signature', 'Signature is not canonical Base64URL', { + cause: error, + }); + } + if (signature.byteLength !== 64) { + throw new ConfigCoreError('invalid-signature-length', 'Signature must contain 64 bytes'); + } +} + +async function verifySignature( + keyId: string, + encodedSignature: string, + payloadBytes: Uint8Array, + options: VerificationOptions, +): Promise { + if (!Object.hasOwn(options.keyring, keyId)) { + throw new ConfigCoreError('unknown-key', `Unknown signing key ${keyId}`); + } + const encodedKey = options.keyring[keyId]; + let publicKey: Uint8Array; + try { + publicKey = decodeBase64Url(encodedKey); + } catch (error) { + throw new ConfigCoreError('malformed-key', `Signing key ${keyId} is not canonical Base64URL`, { + cause: error, + }); + } + if (publicKey.byteLength !== 32) { + throw new ConfigCoreError('invalid-key-length', `Signing key ${keyId} must contain 32 bytes`); + } + const signature = decodeBase64Url(encodedSignature); + let valid: boolean; + try { + valid = await options.crypto.verifyEd25519(publicKey, signature, payloadBytes.slice()); + } catch (error) { + throw new ConfigCoreError('crypto-unavailable', 'Ed25519 verification is unavailable', { + cause: error, + }); + } + if (!valid) throw new ConfigCoreError('invalid-signature', 'Ed25519 signature is invalid'); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 558767ee..468a4450 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -883,6 +883,9 @@ importers: canonicalize: specifier: 3.0.0 version: 3.0.0 + foxts: + specifier: ^5.8.1 + version: 5.8.1 zod: specifier: 'catalog:' version: 4.4.3