diff --git a/CHANGELOG.md b/CHANGELOG.md index fcc5953..05ccf13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The format is based on Keep a Changelog and the project follows Semantic Version ### Added +- Added an experimental, one-way Control D regional DNS integration to local + and beta builds. It previews managed rule changes, requires confirmation + before the first sync, preserves unrelated Control D resources, and guides + users through manual browser DoH setup. The dedicated four-step flow can + explicitly recover setups created with the v2 Privacy Thing naming scheme + after reinstalling, while release builds preserve but never load its private + storage namespace. - A “Site not working?” assistant offers one temporary Service Worker or SharedWorker policy test for the exact top-document host. Failed, cancelled and expired tests restore current settings; saving a host exception requires @@ -61,6 +68,25 @@ The format is based on Keep a Changelog and the project follows Semantic Version ### Fixed +- Control D keeps saved route overrides when all rules for a region are disabled, + and status colors follow integration state in every interface language. + +- Control D previews now expire when local or remote inputs change, and background + actions serialize with disconnect. Automatic sync never approves approximate + routes; moving a hostname between exit folders preserves the rule. API keys + migrate to private extension storage. The setup uses concise labels in all five + languages, and release bundle checks still run when a display version is set. + +- Preserve optional profile country codes through saving, export and import, + including later edits, so Control D keeps selecting exits in the confirmed + country. Lowercase codes still normalize to uppercase; older profiles without + a country code remain valid. +- Keep Control D synchronization attached to resources by their saved IDs when + the API normalizes display names, restore automatic sync after the obsolete + name conflict, and keep preview and sync on one prepared regional-route + snapshot without rewriting Control D hostname patterns. Hide automatic route + matches until a fallback needs confirmation or the user opens overrides. + - Saved worker exceptions no longer defer Firefox early protection in cross-origin frames of unrelated sites. Frames with an unknown top host start with the protected baseline until tab-aware state arrives. diff --git a/config/e2e-lanes.ts b/config/e2e-lanes.ts index b46ee92..6e8a341 100644 --- a/config/e2e-lanes.ts +++ b/config/e2e-lanes.ts @@ -30,6 +30,7 @@ export const E2E_OWNERSHIP_LANES = { "firefox-runtime-bootstrap.spec.ts", "firefox-runtime-core.spec.ts", "firefox-runtime-host-pause.spec.ts", + "firefox-runtime-control-d.spec.ts", "firefox-runtime-worker-test.spec.ts", "firefox-runtime-edge.spec.ts", "firefox-runtime-transport-refresh.spec.ts", diff --git a/config/manifest.ts b/config/manifest.ts index b125856..d0bee44 100644 --- a/config/manifest.ts +++ b/config/manifest.ts @@ -9,10 +9,12 @@ const displayVersion = process.env.PT_DISPLAY_VERSION ?? ""; export const createManifest = ({ browserTarget = process.env.PT_BROWSER_TARGET, + buildChannel = process.env.PT_BUILD_CHANNEL ?? "local", version = manifestVersion, versionName = displayVersion, }: { browserTarget?: string | undefined; + buildChannel?: string | undefined; version?: string | undefined; versionName?: string | undefined; } = {}) => { @@ -70,6 +72,11 @@ export const createManifest = ({ } : {}), host_permissions: [""], + ...(buildChannel === "release" + ? {} + : { + optional_host_permissions: ["https://api.controld.com/*"], + }), background: { service_worker: "src/background/index.ts", type: "module", diff --git a/config/tailwind.config.ts b/config/tailwind.config.ts index c418f97..db3b764 100644 --- a/config/tailwind.config.ts +++ b/config/tailwind.config.ts @@ -1,5 +1,10 @@ import type { Config } from "tailwindcss"; +const experimentalSources = + process.env.PT_BUILD_CHANNEL === "release" + ? [] + : ["../src/experimental/**/*.{ts,tsx,html}"]; + export default { darkMode: ["selector", '[data-theme="dark"]'], // Tailwind resolves plain content globs against the process cwd, not this @@ -11,6 +16,7 @@ export default { "../.storybook/**/*.{ts,tsx,mdx}", "../src/ui/**/*.{ts,tsx,html}", "../packages/ui/src/**/*.{ts,tsx}", + ...experimentalSources, ], }, theme: { diff --git a/config/vite.config.ts b/config/vite.config.ts index 6260f0d..ab69628 100644 --- a/config/vite.config.ts +++ b/config/vite.config.ts @@ -497,6 +497,24 @@ export default defineConfig({ }, resolve: { alias: [ + { + find: "@/experimental/control-d/background-entry", + replacement: path.resolve( + repositoryRootDirectory, + buildChannel === "release" + ? "src/stubs/experimental-control-d-background.ts" + : "src/experimental/control-d/background-entry.ts", + ), + }, + { + find: "@/experimental/control-d/ui-entry", + replacement: path.resolve( + repositoryRootDirectory, + buildChannel === "release" + ? "src/stubs/experimental-control-d-ui.tsx" + : "src/experimental/control-d/ui-entry.tsx", + ), + }, ...(buildTarget === "chromium" ? [ { diff --git a/package.json b/package.json index 719fa97..9b445d3 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-sonarjs": "^4.2.2", + "fake-indexeddb": "^6.2.5", "globals": "^17.12.0", "jsdom": "^29.1.1", "locale-codes": "1.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42d4235..5ca008c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,9 @@ importers: eslint-plugin-sonarjs: specifier: ^4.2.2 version: 4.2.2(eslint@10.11.0(jiti@1.21.7)) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 globals: specifier: ^17.12.0 version: 17.12.0 @@ -3026,6 +3029,10 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -7883,6 +7890,8 @@ snapshots: expect-type@1.4.0: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: diff --git a/scripts/run-firefox-runtime-test.mjs b/scripts/run-firefox-runtime-test.mjs index 96f5f0b..8b33f7e 100644 --- a/scripts/run-firefox-runtime-test.mjs +++ b/scripts/run-firefox-runtime-test.mjs @@ -33,6 +33,7 @@ const result = spawnSync( "tests/e2e/firefox-runtime-transport.spec.ts", "tests/e2e/firefox-runtime-core.spec.ts", "tests/e2e/firefox-runtime-host-pause.spec.ts", + "tests/e2e/firefox-runtime-control-d.spec.ts", "tests/e2e/firefox-runtime-worker-test.spec.ts", "tests/e2e/firefox-runtime-import.spec.ts", "tests/e2e/firefox-runtime-xray-report.spec.ts", diff --git a/src/background/index.ts b/src/background/index.ts index d4c4a9f..7debf9a 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -32,7 +32,10 @@ import { createPopupHandlers } from "@/background/popup-commands"; import type { PreparedRuntimeDecisions } from "@/background/prepared-runtime-decisions"; import { applyPrivacyDefaults } from "@/background/privacy"; import { createRuntimeConfig } from "@/background/runtime-config-controller"; -import { registerRuntimeObservers } from "@/background/runtime-observers"; +import { + registerRuntimeObservers, + registerSurfaceUsage, +} from "@/background/runtime-observers"; import { createRuntimeResolverCtl } from "@/background/runtime-resolution-controller"; import { createRuntimeState } from "@/background/runtime-state"; import { createSettingsHandlers } from "@/background/settings-commands"; @@ -58,7 +61,10 @@ import { resolvePopupNotification as resolvePopupNotificationStore, syncUpdateNotices, } from "@/background/storage/popup-notifications"; -import { getOnboardingCompleted } from "@/background/storage/preferences"; +import { + getOnboardingCompleted, + getPreferences, +} from "@/background/storage/preferences"; import { setTrustedSiteEnabled, upsertTrustedSite, @@ -82,10 +88,10 @@ import { } from "@/background/surface-evidence-tracker"; import { createTabReloader, enableSessionStorage } from "@/background/tab-reload"; import { createXRayHandlers, createWorkerTestCtl } from "@/background/xray-commands"; +import { registerControlD } from "@/experimental/control-d/background-entry"; import { fireAndForget } from "@/shared/async"; import { BRAND_DISPLAY_NAME } from "@/shared/brand"; import { BUILD_BROWSER_TARGET, BUILD_CHANNEL } from "@/shared/build-flags"; -import { CMD_GET_SURFACE_USAGE } from "@/shared/extension-contract"; import { getAllReleaseNotices } from "@/shared/release-notification"; const runtimeState = createRuntimeState(); @@ -363,6 +369,11 @@ const { // messages can reach the background router. registerRewriteListeners(); +registerControlD({ + getDebugMode: async () => + runtimeState.getLastKnownDebugMode() ?? (await getPreferences()).debugMode, +}); + registerMessageRouter({ isSupportedWebUrl, workerTest: createWorkerTestCtl(getXRayState, hostPauseController.activate), @@ -613,15 +624,6 @@ registerRuntimeObservers({ setLastKnownRules: runtimeState.setLastKnownRules, }); -chrome.webNavigation.onCompleted.addListener((details) => { - if (details.frameId !== 0) return; - const { tabId } = details; - fireAndForget( - chrome.tabs - .sendMessage(tabId, { type: CMD_GET_SURFACE_USAGE }) - .catch(() => undefined), - ); - fireAndForget(refreshBadgeCountForTab(tabId)); -}); +registerSurfaceUsage(refreshBadgeCountForTab); registerImportExpiry(); diff --git a/src/background/location-drafts.ts b/src/background/location-drafts.ts index 721250d..f33768d 100644 --- a/src/background/location-drafts.ts +++ b/src/background/location-drafts.ts @@ -258,6 +258,9 @@ export const buildDraftFromCandidate = ( label: candidate.label, latitude: candidate.latitude, longitude: candidate.longitude, + ...(candidate.address?.country_code + ? { countryCode: candidate.address.country_code.toUpperCase() } + : {}), accuracy: 25, noiseRadius: 50, language: selectedLanguageOption.language, diff --git a/src/background/runtime-observers.ts b/src/background/runtime-observers.ts index 26381bd..45359da 100644 --- a/src/background/runtime-observers.ts +++ b/src/background/runtime-observers.ts @@ -6,6 +6,7 @@ import { import { LOCATIONS_STORAGE_KEY } from "@/background/storage/locations"; import { RULES_STORAGE_KEY } from "@/background/storage/rules"; import { fireAndForget } from "@/shared/async"; +import { CMD_GET_SURFACE_USAGE } from "@/shared/extension-contract"; import type { DomainRule } from "@/shared/types"; export type RuntimeObserverDeps = { @@ -63,3 +64,18 @@ export const registerRuntimeObservers = (deps: RuntimeObserverDeps): void => { fireAndForget(withConfigurationLock(deps.handleConfigMutation)); }); }; + +export const registerSurfaceUsage = ( + refreshBadgeCountForTab: (tabId: number) => Promise, +): void => { + chrome.webNavigation.onCompleted.addListener((details) => { + if (details.frameId !== 0) return; + const { tabId } = details; + fireAndForget( + chrome.tabs + .sendMessage(tabId, { type: CMD_GET_SURFACE_USAGE }) + .catch(() => undefined), + ); + fireAndForget(refreshBadgeCountForTab(tabId)); + }); +}; diff --git a/src/background/settings-country-code.target.test.ts b/src/background/settings-country-code.target.test.ts new file mode 100644 index 0000000..efed1ee --- /dev/null +++ b/src/background/settings-country-code.target.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { validateImportedSettings, validateSettings } from "@/background/settings"; +import type { SettingsCommandDeps } from "@/background/settings-command-types"; +import { createSettingsHandlers } from "@/background/settings-commands"; +import { loadLocations, LOCATIONS_STORAGE_KEY } from "@/background/storage/locations"; +import { compileControlDState } from "@/experimental/control-d/compiler"; +import type { ControlDProxyLocation } from "@/experimental/control-d/contracts"; +import { EXTENSION_COMMAND_TYPES } from "@/shared/extension-contract"; +import type { ExportedSettings, Location } from "@/shared/types"; + +vi.mock("@/background/logger", () => ({ + clearExtensionLogs: vi.fn(), + logExtensionEvent: vi.fn(), +})); + +const profile: Location = { + id: "custom-border-profile", + label: "Polish border", + latitude: 51.15, + longitude: 15, + accuracy: 25, + noiseRadius: 50, + language: "pl", + languages: ["pl"], + timeZone: "Europe/Warsaw", +}; + +const proxies: ControlDProxyLocation[] = [ + { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.23, + longitude: 21.01, + }, + { + pk: "DE-BORDER", + city: "German border", + countryCode: "DE", + countryName: "Germany", + latitude: 51.15, + longitude: 14.99, + }, +]; + +const storageState: Record = {}; + +beforeEach(() => { + for (const key of Object.keys(storageState)) { + Reflect.deleteProperty(storageState, key); + } + vi.stubGlobal("chrome", { + storage: { + local: { + get: vi.fn(async (key: string | string[]) => { + const keys = typeof key === "string" ? [key] : key; + return structuredClone( + Object.fromEntries(keys.map((entry) => [entry, storageState[entry]])), + ); + }), + set: vi.fn(async (entries: Record) => { + Object.assign(storageState, structuredClone(entries)); + }), + remove: vi.fn(async (key: string) => { + Reflect.deleteProperty(storageState, key); + }), + }, + }, + }); +}); + +const createHandlers = () => + createSettingsHandlers({ + ensureStorageMigration: vi.fn(async () => undefined), + syncPreloadedState: vi.fn(async () => undefined), + resyncActiveHeaderRules: vi.fn(async () => undefined), + refreshFxInjectionMode: vi.fn(async () => undefined), + getActiveTabContexts: () => [], + reloadTabs: vi.fn(async () => undefined), + getCachedValues: vi.fn(), + setCachedValues: vi.fn(), + }); + +describe("profile country persistence", () => { + it.each(["PL", "pl", undefined])( + "keeps country %s through save, storage, export, import and a later edit", + async (countryCode) => { + const handlers = createHandlers(); + const locations = [ + { ...profile, ...(countryCode === undefined ? {} : { countryCode }) }, + ]; + const rules = [{ pattern: "example.com", enabled: true, locationId: profile.id }]; + const saved = await handlers.saveLocationModel({ + type: EXTENSION_COMMAND_TYPES.saveLocationModel, + locations, + rules, + containerAssignments: [], + }); + expect(saved.ok).toBe(true); + if (!saved.ok) throw new Error(saved.error); + const expectedCountry = countryCode?.toUpperCase(); + expect(saved.locations?.[0]?.countryCode).toBe(expectedCountry); + expect((await loadLocations())[0]?.countryCode).toBe(expectedCountry); + expect(storageState[LOCATIONS_STORAGE_KEY]).toEqual(saved.locations); + + const exported = await handlers.exportSettings(); + expect(exported.settings.locations[0]?.countryCode).toBe(expectedCountry); + const backup = JSON.parse(JSON.stringify(exported.settings)) as ExportedSettings; + for (const key of Object.keys(storageState)) { + Reflect.deleteProperty(storageState, key); + } + const imported = await handlers.importSettings({ + type: EXTENSION_COMMAND_TYPES.importSettings, + settings: backup, + }); + expect(imported.ok).toBe(true); + const restored = await loadLocations(); + expect(restored[0]?.countryCode).toBe(expectedCountry); + if (countryCode === undefined) { + expect(restored[0]).not.toHaveProperty("countryCode"); + } + + const compilation = compileControlDState({ + locations: restored, + rules: backup.rules, + proxies, + storedMappings: {}, + }); + expect(compilation.rules[0]?.proxyPk).toBe( + countryCode === undefined ? "DE-BORDER" : "WAW", + ); + expect(compilation.mappings[profile.id]).toMatchObject({ + status: countryCode === undefined ? "approximate" : "exact", + confirmed: countryCode !== undefined, + }); + expect(compilation.warnings.map((warning) => warning.code)).toEqual( + countryCode === undefined ? ["missing-country"] : [], + ); + + const edited = await handlers.saveLocationModel({ + type: EXTENSION_COMMAND_TYPES.saveLocationModel, + locations: restored.map((location) => ({ ...location, label: "Renamed" })), + rules: backup.rules, + containerAssignments: [], + }); + expect(edited.ok).toBe(true); + const afterEdit = await loadLocations(); + expect(afterEdit[0]?.countryCode).toBe(expectedCountry); + expect(afterEdit[0]?.label).toBe("Renamed"); + expect( + compileControlDState({ + locations: afterEdit, + rules: backup.rules, + proxies, + storedMappings: compilation.mappings, + }).rules, + ).toEqual(compilation.rules); + }, + ); + + it.each(["PL", "pl", undefined])( + "normalizes country %s in an imported backup", + (countryCode) => { + const result = validateImportedSettings({ + version: 3, + exportedAt: "2026-10-01T00:00:00.000Z", + locations: [ + { ...profile, ...(countryCode === undefined ? {} : { countryCode }) }, + ], + rules: [], + }); + expect(result.locations[0]?.countryCode).toBe(countryCode?.toUpperCase()); + if (countryCode === undefined) { + expect(result.locations[0]).not.toHaveProperty("countryCode"); + } + }, + ); + + it.each(["", "P", "POL", "P1", " PL "])( + "rejects invalid country %s during save and import validation", + (countryCode) => { + const locations = [{ ...profile, countryCode }]; + expect(() => validateSettings(locations, [])).toThrow(); + expect(() => + validateImportedSettings({ + version: 3, + exportedAt: "2026-10-01T00:00:00.000Z", + locations, + rules: [], + }), + ).toThrow(); + }, + ); +}); diff --git a/src/background/settings.ts b/src/background/settings.ts index a707db0..df6430e 100644 --- a/src/background/settings.ts +++ b/src/background/settings.ts @@ -129,6 +129,9 @@ const sanitizeLocations = (locations: readonly Location[]): Location[] => label: location.label, latitude: location.latitude, longitude: location.longitude, + ...(location.countryCode !== undefined + ? { countryCode: location.countryCode } + : {}), accuracy: location.accuracy, language: location.language, languages: location.languages, diff --git a/src/background/storage-namespace-migration.test.ts b/src/background/storage-namespace-migration.test.ts new file mode 100644 index 0000000..59cdbcf --- /dev/null +++ b/src/background/storage-namespace-migration.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { migrateRetiredNamespace } from "./storage-namespace-migration"; + +const localState: Record = {}; +const sessionState: Record = {}; + +const select = (state: Record, keys: readonly string[]) => + Object.fromEntries( + keys.filter((key) => key in state).map((key) => [key, state[key]]), + ); + +beforeEach(() => { + for (const key of Object.keys(localState)) Reflect.deleteProperty(localState, key); + for (const key of Object.keys(sessionState)) + Reflect.deleteProperty(sessionState, key); + vi.stubGlobal("chrome", { + storage: { + local: { + get: vi.fn(async (keys: readonly string[]) => select(localState, keys)), + set: vi.fn(async (values: Record) => + Object.assign(localState, values), + ), + remove: vi.fn(async (keys: readonly string[]) => { + for (const key of keys) Reflect.deleteProperty(localState, key); + }), + }, + session: { + get: vi.fn(async (keys: readonly string[]) => select(sessionState, keys)), + remove: vi.fn(async (keys: readonly string[]) => { + for (const key of keys) Reflect.deleteProperty(sessionState, key); + }), + }, + }, + }); +}); + +describe("production storage compatibility", () => { + it("preserves an unknown experimental namespace byte-for-byte", async () => { + const experimental = { version: 2, nested: { keep: true } }; + localState["pt.experimental.control-d.v2.config"] = experimental; + + await migrateRetiredNamespace(); + + expect(localState["pt.experimental.control-d.v2.config"]).toBe(experimental); + expect(chrome.storage.local.remove).not.toHaveBeenCalledWith( + expect.arrayContaining(["pt.experimental.control-d.v2.config"]), + ); + }); +}); diff --git a/src/background/storage/locations.target.test.ts b/src/background/storage/locations.target.test.ts index 0ca358e..646e4a0 100644 --- a/src/background/storage/locations.target.test.ts +++ b/src/background/storage/locations.target.test.ts @@ -77,6 +77,9 @@ describe("loadLocations", () => { "spf-berlin", "spf-madrid", ]); + expect( + EXAMPLE_LOCATIONS.every((profile) => profile.countryCode?.length === 2), + ).toBe(true); }); it("randomizes preset coordinates inside the requested radius without changing privacy radius", () => { @@ -152,6 +155,40 @@ describe("loadLocations", () => { ]); }); + it("restores country codes for older built-in regional presets", async () => { + const withoutCountryCode = EXAMPLE_LOCATIONS.map( + ({ countryCode: _countryCode, ...profile }) => profile, + ); + storageState[LOCATIONS_STORAGE_KEY] = [ + withoutCountryCode[0], + withoutCountryCode[1], + withoutCountryCode[3], + ]; + + const profiles = await loadLocations(); + + expect(profiles.map((profile) => profile.countryCode)).toEqual(["PL", "FR", "CA"]); + }); + + it("keeps older profiles valid when they have no country code", async () => { + storageState[LOCATIONS_STORAGE_KEY] = [ + { + id: "legacy-countryless", + label: "Legacy", + latitude: 1, + longitude: 2, + accuracy: 25, + noiseRadius: 50, + language: "en", + languages: ["en"], + timeZone: "UTC", + }, + ]; + + const [profile] = await loadLocations(); + expect(profile).not.toHaveProperty("countryCode"); + }); + it("does not read a retired namespace outside the startup migrator", async () => { const retiredProfilesKey = `${["geo", "warp"].join("")}.profiles`; storageState[retiredProfilesKey] = [ diff --git a/src/background/storage/locations.ts b/src/background/storage/locations.ts index 2b66b8b..26d3f5b 100644 --- a/src/background/storage/locations.ts +++ b/src/background/storage/locations.ts @@ -28,6 +28,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Warsaw", latitude: 52.2297, longitude: 21.0122, + countryCode: "PL", accuracy: 25, noiseRadius: 50, language: "pl", @@ -39,6 +40,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Paris", latitude: 48.8566, longitude: 2.3522, + countryCode: "FR", accuracy: 25, noiseRadius: 50, language: "fr-FR", @@ -50,6 +52,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "London", latitude: 51.5074, longitude: -0.1278, + countryCode: "GB", accuracy: 25, noiseRadius: 50, language: "en-GB", @@ -61,6 +64,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Ottawa", latitude: 45.4215, longitude: -75.6972, + countryCode: "CA", accuracy: 25, noiseRadius: 50, language: "en-CA", @@ -72,6 +76,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "New York", latitude: 40.7128, longitude: -74.006, + countryCode: "US", accuracy: 25, noiseRadius: 50, language: "en-US", @@ -83,6 +88,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Las Vegas", latitude: 36.1699, longitude: -115.1398, + countryCode: "US", accuracy: 25, noiseRadius: 50, language: "en-US", @@ -94,6 +100,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "San Francisco", latitude: 37.7749, longitude: -122.4194, + countryCode: "US", accuracy: 25, noiseRadius: 50, language: "en-US", @@ -105,6 +112,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Sydney", latitude: -33.8688, longitude: 151.2093, + countryCode: "AU", accuracy: 25, noiseRadius: 50, language: "en-AU", @@ -116,6 +124,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Beijing", latitude: 39.9042, longitude: 116.4074, + countryCode: "CN", accuracy: 25, noiseRadius: 50, language: "zh-CN", @@ -127,6 +136,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Hong Kong", latitude: 22.3193, longitude: 114.1694, + countryCode: "HK", accuracy: 25, noiseRadius: 50, language: "zh-HK", @@ -138,6 +148,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "New Delhi", latitude: 28.6139, longitude: 77.209, + countryCode: "IN", accuracy: 25, noiseRadius: 50, language: "hi", @@ -149,6 +160,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Cairo", latitude: 30.0444, longitude: 31.2357, + countryCode: "EG", accuracy: 25, noiseRadius: 50, language: "ar", @@ -160,6 +172,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Lagos", latitude: 6.5244, longitude: 3.3792, + countryCode: "NG", accuracy: 25, noiseRadius: 50, language: "en", @@ -171,6 +184,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Kyiv", latitude: 50.4501, longitude: 30.5234, + countryCode: "UA", accuracy: 25, noiseRadius: 50, language: "uk", @@ -182,6 +196,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Kinshasa", latitude: -4.4419, longitude: 15.2663, + countryCode: "CD", accuracy: 25, noiseRadius: 50, language: "fr", @@ -193,6 +208,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Sao Paulo", latitude: -23.5558, longitude: -46.6396, + countryCode: "BR", accuracy: 25, noiseRadius: 50, language: "pt-BR", @@ -204,6 +220,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Buenos Aires", latitude: -34.6037, longitude: -58.3816, + countryCode: "AR", accuracy: 25, noiseRadius: 50, language: "es", @@ -215,6 +232,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Lima", latitude: -12.0464, longitude: -77.0428, + countryCode: "PE", accuracy: 25, noiseRadius: 50, language: "es", @@ -226,6 +244,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Rio de Janeiro", latitude: -22.9068, longitude: -43.1729, + countryCode: "BR", accuracy: 25, noiseRadius: 50, language: "pt-BR", @@ -237,6 +256,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Caracas", latitude: 10.4806, longitude: -66.9036, + countryCode: "VE", accuracy: 25, noiseRadius: 50, language: "es", @@ -248,6 +268,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Berlin", latitude: 52.52, longitude: 13.405, + countryCode: "DE", accuracy: 25, noiseRadius: 50, language: "de-DE", @@ -259,6 +280,7 @@ export const EXAMPLE_LOCATIONS: Location[] = [ label: "Madrid", latitude: 40.4168, longitude: -3.7038, + countryCode: "ES", accuracy: 25, noiseRadius: 50, language: "es", @@ -309,19 +331,34 @@ export const DEFAULT_LOCATIONS: Location[] = FX_RUNTIME_TEST_HOST ? EXAMPLE_LOCATIONS.map((location) => ({ ...location })) : []; +const EXAMPLE_COUNTRY_CODES = new Map( + EXAMPLE_LOCATIONS.flatMap((location) => + location.countryCode ? [[location.id, location.countryCode] as const] : [], + ), +); + +const enrichKnownCountryCodes = (locations: readonly Location[]): Location[] => + locations.map((location) => { + if (location.countryCode) return location; + const countryCode = EXAMPLE_COUNTRY_CODES.get(location.id); + return countryCode ? { ...location, countryCode } : location; + }); + export const loadLocations = async (): Promise => { const stored = await chrome.storage.local.get(LOCATIONS_STORAGE_KEY); const locations = stored[LOCATIONS_STORAGE_KEY]; - return Array.isArray(locations) - ? normalizeLocations(stripLegacyRefs(locations) as Location[]) - : normalizeLocations(DEFAULT_LOCATIONS); + return enrichKnownCountryCodes( + Array.isArray(locations) + ? normalizeLocations(stripLegacyRefs(locations) as Location[]) + : normalizeLocations(DEFAULT_LOCATIONS), + ); }; export const saveLocations = async (locations: readonly Location[]): Promise => { const stored = await chrome.storage.local.get(LOCATIONS_STORAGE_KEY); await chrome.storage.local.set({ [LOCATIONS_STORAGE_KEY]: mergeLegacyRefs( - normalizeLocations(locations), + enrichKnownCountryCodes(normalizeLocations(locations)), stored[LOCATIONS_STORAGE_KEY], ), }); diff --git a/src/experimental/control-d/api-key-store.ts b/src/experimental/control-d/api-key-store.ts new file mode 100644 index 0000000..971fbef --- /dev/null +++ b/src/experimental/control-d/api-key-store.ts @@ -0,0 +1,43 @@ +// Content scripts use the page's IndexedDB origin; this database belongs only to +// trusted extension pages and the background, on both Chromium and Firefox. +const DATABASE = "pt.experimental.control-d.credentials"; +const STORE = "keys"; +const KEY = "api-key"; + +const openKeyDatabase = (): Promise => + new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + +const accessApiKey = async ( + mode: IDBTransactionMode, + action: (store: IDBObjectStore) => IDBRequest, +): Promise => { + const database = await openKeyDatabase(); + try { + return await new Promise((resolve, reject) => { + const transaction = database.transaction(STORE, mode); + const request = action(transaction.objectStore(STORE)); + transaction.oncomplete = () => resolve(request.result); + transaction.onabort = () => reject(transaction.error ?? request.error); + }); + } finally { + database.close(); + } +}; + +export const readPrivateApiKey = async (): Promise => { + const value = await accessApiKey("readonly", (store) => store.get(KEY)); + return typeof value === "string" && value.trim() ? value : null; +}; + +export const writePrivateApiKey = async (value: string): Promise => { + await accessApiKey("readwrite", (store) => store.put(value, KEY)); +}; + +export const deletePrivateApiKey = async (): Promise => { + await accessApiKey("readwrite", (store) => store.delete(KEY)); +}; diff --git a/src/experimental/control-d/background-entry.target.test.ts b/src/experimental/control-d/background-entry.target.test.ts new file mode 100644 index 0000000..f53a4f5 --- /dev/null +++ b/src/experimental/control-d/background-entry.target.test.ts @@ -0,0 +1,582 @@ +import { IDBFactory } from "fake-indexeddb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { registerControlD } from "./background-entry"; +import { + CONTROL_D_COMMANDS, + type ControlDConfig, + type ControlDPreparedSnapshot, +} from "./contracts"; +import { + hashControlDInputs, + applyControlDSync, + prepareControlDSync, +} from "./reconcile"; +import { CONTROL_D_STORE_KEYS } from "./storage"; + +import { logExtensionEvent } from "@/background/logger"; +import { LOCATIONS_STORAGE_KEY } from "@/background/storage/locations"; +import { RULES_STORAGE_KEY } from "@/background/storage/rules"; + +vi.mock("@/background/logger", () => ({ + logExtensionEvent: vi.fn(), +})); + +type ReconcileModule = { + applyControlDSync: typeof applyControlDSync; + prepareControlDSync: typeof prepareControlDSync; + [key: string]: unknown; +}; + +vi.mock("./reconcile", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + applyControlDSync: vi.fn(actual.applyControlDSync), + prepareControlDSync: vi.fn(actual.prepareControlDSync), + }; +}); + +type MessageListener = ( + message: unknown, + sender: { id?: string; url?: string }, + sendResponse: (response: unknown) => void, +) => boolean; + +type StorageListener = ( + changes: Record, + areaName: string, +) => void; + +const storageState: Record = {}; +let messageListener: MessageListener; +let storageListener: StorageListener; + +beforeEach(() => { + vi.stubGlobal("indexedDB", new IDBFactory()); + vi.clearAllMocks(); + vi.mocked(prepareControlDSync).mockReset(); + vi.mocked(applyControlDSync).mockReset(); + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"], + }); + for (const key of Object.keys(storageState)) + Reflect.deleteProperty(storageState, key); + + vi.stubGlobal("chrome", { + runtime: { + id: "extension-id", + getURL: (path: string) => `chrome-extension://extension-id${path}`, + onMessage: { + addListener: vi.fn((listener: MessageListener) => { + messageListener = listener; + }), + }, + }, + storage: { + local: { + get: vi.fn(async (key: string) => + key in storageState ? { [key]: storageState[key] } : {}, + ), + set: vi.fn(async (values: Record) => { + Object.assign(storageState, values); + }), + remove: vi.fn(async (key: string) => Reflect.deleteProperty(storageState, key)), + }, + onChanged: { + addListener: vi.fn((listener: StorageListener) => { + storageListener = listener; + }), + }, + }, + }); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +const deferred = () => { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; +const fixtureConfig = (): ControlDConfig => ({ + version: 2, + enabled: true, + connected: true, + autoSyncEnabled: false, + status: "ready", + resourceIdentity: { code: "ABCDE-FGHJK" }, + profileId: "profile-id", + endpointId: "endpoint-id", + resolverDoh: "https://example.test/private-resolver", + dnsVerification: null, + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: "hash", + lastAttemptAt: "2026-09-10T10:00:00.000Z", + lastSuccessAt: "2026-09-10T10:00:00.000Z", + lastError: null, +}); +describe("Control D background entry", () => { + const request = (message: unknown) => + new Promise>((resolve) => { + messageListener( + message, + { + id: "extension-id", + url: "chrome-extension://extension-id/src/ui/options/index.html", + }, + (response) => resolve(response as Record), + ); + }); + + it("rejects Control D commands from content scripts before reading credentials", () => { + registerControlD({ getDebugMode: () => false }); + const respond = vi.fn(); + expect( + messageListener( + { type: CONTROL_D_COMMANDS.disconnect }, + { id: "extension-id", url: "https://example.com/" }, + respond, + ), + ).toBe(false); + expect(respond).not.toHaveBeenCalled(); + expect(chrome.storage.local.remove).not.toHaveBeenCalled(); + }); + + it("returns the same prepared snapshot after preview and synchronization", async () => { + const config: ControlDConfig = { + version: 2, + enabled: true, + connected: true, + autoSyncEnabled: false, + status: "ready", + resourceIdentity: { code: "ABCDE-FGHJK" }, + profileId: "profile-id", + endpointId: "endpoint-id", + resolverDoh: "https://example.test/private-resolver", + dnsVerification: null, + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: "hash", + lastAttemptAt: "2026-09-10T10:00:00.000Z", + lastSuccessAt: "2026-09-10T10:00:00.000Z", + lastError: null, + }; + const prepared: Awaited> = { + inputHash: await hashControlDInputs(config, [], []), + remoteHash: "remote-state", + compilation: { rules: [], warnings: [], mappings: {} }, + proxies: [ + { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.23, + longitude: 21.01, + }, + ], + diff: { + createProfile: false, + createEndpoint: false, + createFolders: 0, + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 1, + warnings: [], + mappings: [ + { + locationId: "warsaw", + locationLabel: "Warsaw", + ruleCount: 1, + proxyPk: "WAW", + status: "exact", + confirmed: true, + }, + ], + requiresApproximationConfirmation: false, + }, + }; + const expectedSnapshot: ControlDPreparedSnapshot = { + token: expect.any(String), + diff: prepared.diff, + proxies: prepared.proxies, + }; + storageState[CONTROL_D_STORE_KEYS[0]] = config; + storageState[CONTROL_D_STORE_KEYS[1]] = "api-key"; + vi.mocked(prepareControlDSync) + .mockResolvedValueOnce(prepared) + .mockResolvedValueOnce(prepared); + vi.mocked(applyControlDSync).mockResolvedValueOnce(config); + registerControlD({ getDebugMode: async () => false }); + + const previewResponse = await request({ type: CONTROL_D_COMMANDS.preview }); + expect(previewResponse).toMatchObject({ ok: true, snapshot: expectedSnapshot }); + expect(previewResponse).not.toHaveProperty("diff"); + expect(storageState[CONTROL_D_STORE_KEYS[0]]).toMatchObject({ + lastAttemptAt: "2026-09-10T10:00:00.000Z", + lastError: null, + }); + + const token = (previewResponse.snapshot as ControlDPreparedSnapshot).token; + const syncResponse = await request({ + type: CONTROL_D_COMMANDS.apply, + confirmApproximate: false, + previewToken: token, + }); + expect(prepareControlDSync).toHaveBeenCalledTimes(1); + expect( + await request({ + type: CONTROL_D_COMMANDS.apply, + confirmApproximate: true, + previewToken: token, + }), + ).toMatchObject({ ok: false }); + + expect(syncResponse).toMatchObject({ ok: true, snapshot: expectedSnapshot }); + expect(syncResponse).not.toHaveProperty("diff"); + }); + + it("clears a stale profile conflict after a successful preview", async () => { + storageState[CONTROL_D_STORE_KEYS[0]] = { + version: 2, + enabled: true, + connected: true, + autoSyncEnabled: false, + status: "conflict", + resourceIdentity: { code: "ABCDE-FGHJK" }, + profileId: "profile-id", + endpointId: "endpoint-id", + resolverDoh: null, + dnsVerification: null, + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: null, + lastAttemptAt: "2026-09-10T10:00:00.000Z", + lastSuccessAt: null, + lastError: "The managed Control D endpoint uses another profile.", + }; + storageState[CONTROL_D_STORE_KEYS[1]] = "api-key"; + vi.mocked(prepareControlDSync).mockResolvedValueOnce({ + inputHash: await hashControlDInputs( + storageState[CONTROL_D_STORE_KEYS[0]] as ControlDConfig, + [], + [], + ), + remoteHash: "remote-state", + compilation: { rules: [], warnings: [], mappings: {} }, + proxies: [], + diff: { + createProfile: false, + createEndpoint: false, + createFolders: 0, + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 0, + warnings: [], + mappings: [], + requiresApproximationConfirmation: false, + }, + }); + registerControlD({ getDebugMode: async () => false }); + + const response = await request({ type: CONTROL_D_COMMANDS.preview }); + + expect(response).toMatchObject({ + ok: true, + state: { status: "ready", lastError: null }, + }); + expect(storageState[CONTROL_D_STORE_KEYS[0]]).toMatchObject({ + status: "ready", + lastError: null, + }); + }); + + it("creates a new resource identity without running synchronization", async () => { + storageState[CONTROL_D_STORE_KEYS[1]] = "api-key"; + registerControlD({ getDebugMode: async () => false }); + + const response = await request({ type: CONTROL_D_COMMANDS.selectNew }); + + expect(response).toMatchObject({ + ok: true, + state: { setupStatus: "selected", profileId: null, endpointId: null }, + }); + expect(storageState[CONTROL_D_STORE_KEYS[0]]).toMatchObject({ + version: 2, + resourceIdentity: { + code: expect.stringMatching(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/), + }, + profileId: null, + endpointId: null, + }); + expect(prepareControlDSync).not.toHaveBeenCalled(); + expect(applyControlDSync).not.toHaveBeenCalled(); + }); + + it("binds DNS confirmation to the current endpoint and clears it on disconnect", async () => { + storageState[CONTROL_D_STORE_KEYS[0]] = { + version: 2, + enabled: true, + connected: true, + autoSyncEnabled: true, + status: "ready", + resourceIdentity: { code: "ABCDE-FGHJK" }, + profileId: "profile-id", + endpointId: "endpoint-id", + resolverDoh: "https://dns.controld.com/private", + dnsVerification: null, + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: "hash", + lastAttemptAt: null, + lastSuccessAt: "2026-09-10T10:00:00.000Z", + lastError: null, + }; + storageState[CONTROL_D_STORE_KEYS[1]] = "api-key"; + registerControlD({ getDebugMode: async () => false }); + + const confirmed = await request({ + type: CONTROL_D_COMMANDS.confirmDns, + verified: true, + }); + expect(confirmed).toMatchObject({ ok: true, state: { dnsStatus: "verified" } }); + + const disconnected = await request({ type: CONTROL_D_COMMANDS.disconnect }); + expect(disconnected).toMatchObject({ + ok: true, + state: { connected: false, dnsStatus: "pending" }, + }); + expect(storageState[CONTROL_D_STORE_KEYS[0]]).toMatchObject({ + profileId: "profile-id", + endpointId: "endpoint-id", + dnsVerification: null, + }); + expect(storageState[CONTROL_D_STORE_KEYS[1]]).toBeUndefined(); + }); + + it("persists a toggle action in extension logs when debug mode comes from storage", async () => { + registerControlD({ getDebugMode: async () => true }); + + const response = await new Promise((resolve) => { + expect( + messageListener( + { type: CONTROL_D_COMMANDS.setEnabled, enabled: true }, + { + id: "extension-id", + url: "chrome-extension://extension-id/src/ui/options/index.html", + }, + resolve, + ), + ).toBe(true); + }); + + expect(response).toMatchObject({ ok: true, state: { enabled: true } }); + await vi.waitFor(() => { + expect(logExtensionEvent).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + event: "control-d.integration.toggled", + }), + ); + }); + }); + + it("logs regional mapping changes for View Logs in debug mode", async () => { + registerControlD({ getDebugMode: async () => true }); + + const response = await new Promise((resolve) => { + messageListener( + { + type: CONTROL_D_COMMANDS.updateMapping, + mapping: { + locationId: "ottawa", + proxyPk: "YUL", + status: "approximate", + confirmed: false, + }, + }, + { + id: "extension-id", + url: "chrome-extension://extension-id/src/ui/options/index.html", + }, + resolve, + ); + }); + + expect(response).toMatchObject({ ok: true }); + await vi.waitFor(() => { + expect(logExtensionEvent).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + event: "control-d.mapping.updated", + payload: { + details: { + locationId: "ottawa", + proxyPk: "YUL", + status: "approximate", + }, + }, + }), + ); + }); + }); + + it("schedules automatic reconciliation when an applied integration starts", async () => { + storageState[CONTROL_D_STORE_KEYS[0]] = { + version: 2, + enabled: true, + connected: true, + autoSyncEnabled: true, + status: "ready", + resourceIdentity: { code: "ABCDE-FGHJK" }, + profileId: "profile-id", + endpointId: "endpoint-id", + resolverDoh: "https://example.test/private-resolver", + dnsVerification: null, + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: "hash", + lastAttemptAt: "2026-09-10T10:00:00.000Z", + lastSuccessAt: "2026-09-10T10:00:00.000Z", + lastError: null, + }; + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + registerControlD({ getDebugMode: async () => true }); + + await vi.waitFor(() => { + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1_500); + }); + timeoutSpy.mockRestore(); + }); + + it("debounces every saved rule or location mutation into automatic reconciliation", () => { + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + registerControlD({ getDebugMode: async () => false }); + + const ruleSnapshots = [ + [{ pattern: "added.example", enabled: true }], + [{ pattern: "edited.example", enabled: true }], + [], + ]; + for (const rules of ruleSnapshots) { + storageListener({ [RULES_STORAGE_KEY]: { newValue: rules } }, "local"); + } + storageListener( + { [LOCATIONS_STORAGE_KEY]: { newValue: [{ id: "warsaw" }] } }, + "local", + ); + + expect(timeoutSpy).toHaveBeenCalledTimes(4); + expect(timeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 1_500); + expect(clearTimeoutSpy).toHaveBeenCalledTimes(3); + timeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + }); + it("waits for in-flight sync before acknowledging disconnect and cannot reconnect afterward", async () => { + const config = fixtureConfig(); + storageState[CONTROL_D_STORE_KEYS[0]] = config; + storageState[CONTROL_D_STORE_KEYS[1]] = "api-key"; + const started = deferred(); + const pending = deferred>>(); + vi.mocked(prepareControlDSync).mockImplementationOnce(() => { + started.resolve(); + return pending.promise; + }); + vi.mocked(applyControlDSync).mockResolvedValueOnce(config); + registerControlD({ getDebugMode: async () => false }); + const sync = request({ type: CONTROL_D_COMMANDS.syncNow }); + await started.promise; + let disconnected = false; + const disconnect = request({ type: CONTROL_D_COMMANDS.disconnect }).then( + (value) => { + disconnected = true; + return value; + }, + ); + await Promise.resolve(); + expect(disconnected).toBe(false); + pending.resolve({ + inputHash: "", + remoteHash: "", + compilation: { rules: [], warnings: [], mappings: {} }, + proxies: [], + diff: { + createProfile: false, + createEndpoint: false, + createFolders: 0, + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 0, + warnings: [], + mappings: [], + requiresApproximationConfirmation: false, + }, + }); + await sync; + expect(await disconnect).toMatchObject({ ok: true }); + expect(storageState[CONTROL_D_STORE_KEYS[0]]).toMatchObject({ + connected: false, + autoSyncEnabled: false, + status: "disconnected", + }); + expect(storageState[CONTROL_D_STORE_KEYS[1]]).toBeUndefined(); + expect(applyControlDSync).toHaveBeenCalledTimes(1); + }); + it("requires another preview when local rules change after consent", async () => { + const config = fixtureConfig(); + storageState[CONTROL_D_STORE_KEYS[0]] = config; + storageState[CONTROL_D_STORE_KEYS[1]] = "api-key"; + vi.mocked(prepareControlDSync).mockResolvedValueOnce({ + inputHash: await hashControlDInputs(config, [], []), + remoteHash: "remote", + compilation: { rules: [], warnings: [], mappings: {} }, + proxies: [], + diff: { + createProfile: false, + createEndpoint: false, + createFolders: 0, + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 0, + warnings: [], + mappings: [], + requiresApproximationConfirmation: false, + }, + }); + registerControlD({ getDebugMode: async () => false }); + const preview = await request({ type: CONTROL_D_COMMANDS.preview }); + storageState[RULES_STORAGE_KEY] = [ + { + pattern: "changed.example", + enabled: true, + locationId: "changed", + ruleSeedKey: "seed01", + authKey: "auth0001", + }, + ]; + expect( + await request({ + type: CONTROL_D_COMMANDS.apply, + confirmApproximate: true, + previewToken: (preview.snapshot as ControlDPreparedSnapshot).token, + }), + ).toMatchObject({ + ok: false, + error: "Preview changed. Refresh and review before applying.", + }); + expect(applyControlDSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/experimental/control-d/background-entry.ts b/src/experimental/control-d/background-entry.ts new file mode 100644 index 0000000..97839b0 --- /dev/null +++ b/src/experimental/control-d/background-entry.ts @@ -0,0 +1,635 @@ +/* eslint-disable max-params -- Logging keeps redaction context explicit. */ +import { ControlDApiError, ControlDClient } from "./client"; +import { + CONTROL_D_COMMANDS, + isControlDCommand, + type ControlDCommand, + type ControlDConfig, + type ControlDMapping, + type ControlDPreparedSnapshot, +} from "./contracts"; +import { + hashControlDInputs, + applyControlDSync, + ControlDConflictError, + isControlDAuthError, + prepareControlDSync, + type ControlDPreparedSync, +} from "./reconcile"; +import { adoptRecoverySet, discoverRecoverySets } from "./recovery"; +import { redactControlDLogValue } from "./redaction"; +import { generateResourceCode } from "./resource-names"; +import { + forgetControlDApiKey, + loadControlDApiKey, + loadControlDConfig, + saveControlDApiKey, + saveControlDConfig, + toControlDPublicState, +} from "./storage"; +import { createControlDSyncQueue } from "./sync-queue"; + +import { logExtensionEvent } from "@/background/logger"; +import { loadLocations, LOCATIONS_STORAGE_KEY } from "@/background/storage/locations"; +import { loadRules, RULES_STORAGE_KEY } from "@/background/storage/rules"; +import { fireAndForget } from "@/shared/async"; +import { ExtensionLogLevel, LogCategory } from "@/shared/types"; + +type BackgroundEntryDeps = { + getDebugMode: () => boolean | Promise; +}; + +type SyncResult = + | { + ok: true; + next: ControlDConfig; + prepared: ControlDPreparedSync; + } + | { ok: false; failed: ControlDConfig; error: unknown } + | null; + +const toPreparedSnapshot = ( + { diff, proxies }: ControlDPreparedSync, + token: string, +): ControlDPreparedSnapshot => ({ diff, proxies, token }); + +const log = ( + deps: BackgroundEntryDeps, + event: string, + details: Record, + level = ExtensionLogLevel.Info, + apiKey?: string, +): void => { + fireAndForget( + Promise.resolve(deps.getDebugMode()).then((enabled) => { + logExtensionEvent({ + enabled, + category: LogCategory.System, + event, + level, + payload: { + details: redactControlDLogValue(details, apiKey) as Record, + }, + }); + }), + ); +}; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : "Control D integration failed."; + +const apiErrorDetails = (error: unknown): Record => { + if (!(error instanceof ControlDApiError)) { + return { + status: null, + requestId: null, + retryAfter: null, + cause: null, + operation: null, + apiCode: null, + }; + } + return { + status: error.status, + requestId: error.requestId, + retryAfter: error.retryAfterSeconds, + cause: error.causeMessage, + operation: error.operation, + apiCode: error.apiCode, + }; +}; + +const withoutResolvedConflict = async ( + config: ControlDConfig, +): Promise => { + if (!config.lastError && config.status !== "conflict") return config; + const resolved: ControlDConfig = { + ...config, + lastError: null, + status: config.status === "conflict" ? "ready" : config.status, + }; + await saveControlDConfig(resolved); + return resolved; +}; + +const saveFailure = async ( + config: ControlDConfig, + error: unknown, +): Promise => { + const authError = isControlDAuthError(error); + const conflict = error instanceof ControlDConflictError; + let status: ControlDConfig["status"] = "error"; + if (authError) status = "auth-error"; + else if (conflict) status = "conflict"; + const failed: ControlDConfig = { + ...config, + status, + autoSyncEnabled: authError || conflict ? false : config.autoSyncEnabled, + lastAttemptAt: new Date().toISOString(), + lastError: errorMessage(error), + }; + await saveControlDConfig(failed); + return failed; +}; + +const isMapping = (value: unknown): value is ControlDMapping => { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.locationId === "string" && + (typeof candidate.proxyPk === "string" || candidate.proxyPk === null) && + ["exact", "approximate", "skipped"].includes(candidate.status ?? "") && + typeof candidate.confirmed === "boolean" + ); +}; + +// eslint-disable-next-line max-lines-per-function -- Owns one single-flight lifecycle. +const createController = (deps: BackgroundEntryDeps) => { + let debounceTimer: ReturnType | null = null; + const syncQueue = createControlDSyncQueue(); + let rerunRequested = false; + let preview: { + token: string; + inputHash: string; + prepared: ControlDPreparedSync; + } | null = null; + const inputHash = async (config: ControlDConfig) => + hashControlDInputs(config, await loadRules(), await loadLocations()); + const createClient = (apiKey: string): ControlDClient => + new ControlDClient(apiKey, fetch, 12_000, (retry) => + log(deps, "control-d.api.retry", retry, undefined, apiKey), + ); + + const runSync = async ({ + confirmApproximate, + repair, + automatic, + reviewed, + }: { + reviewed?: ControlDPreparedSync; + confirmApproximate: boolean; + repair: boolean; + automatic: boolean; + }): Promise => { + let config = await loadControlDConfig(); + const apiKey = await loadControlDApiKey(); + if (!apiKey) throw new Error("Connect a Control D API key first."); + if ( + automatic && + (!config.enabled || !config.autoSyncEnabled || !config.lastSyncedHash) + ) + return null; + + config = { + ...config, + status: "syncing", + lastAttemptAt: new Date().toISOString(), + lastError: null, + }; + await saveControlDConfig(config); + log(deps, "control-d.sync.start", { automatic, repair }, undefined, apiKey); + + try { + const client = createClient(apiKey); + const prepared = reviewed ?? (await prepareControlDSync(client, config)); + const next = await applyControlDSync({ + client, + config, + prepared, + confirmApproximate, + repair, + }); + await saveControlDConfig(next); + log( + deps, + "control-d.sync.success", + { + automatic, + addRules: prepared.diff.addRules, + updateRules: prepared.diff.updateRules, + deleteRules: prepared.diff.deleteRules, + unchangedRules: prepared.diff.unchangedRules, + }, + undefined, + apiKey, + ); + return { ok: true, next, prepared }; + } catch (error) { + const failed = await saveFailure(config, error); + log( + deps, + "control-d.sync.failure", + { + automatic, + error: errorMessage(error), + ...apiErrorDetails(error), + conflict: error instanceof ControlDConflictError, + }, + ExtensionLogLevel.Error, + apiKey, + ); + return { ok: false, failed, error }; + } + }; + + const runExclusive = async ({ + confirmApproximate, + repair, + automatic, + }: { + confirmApproximate: boolean; + repair: boolean; + automatic: boolean; + }): Promise => { + try { + return await syncQueue.run(() => + runSync({ confirmApproximate, repair, automatic }), + ); + } finally { + if (rerunRequested) { + rerunRequested = false; + scheduleAutomatic(); + } + } + }; + + const runAutomatic = async (): Promise => { + if (syncQueue.isBusy()) { + rerunRequested = true; + return; + } + await runExclusive({ + confirmApproximate: false, + repair: false, + automatic: true, + }); + }; + + const scheduleAutomatic = (): void => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + fireAndForget(runAutomatic()); + }, 1_500); + }; + + // eslint-disable-next-line max-lines-per-function, sonarjs/cognitive-complexity -- Command boundary keeps secrets in background. + const respond = async (command: ControlDCommand): Promise => { + if ( + ![ + CONTROL_D_COMMANDS.getState, + CONTROL_D_COMMANDS.preview, + CONTROL_D_COMMANDS.apply, + CONTROL_D_COMMANDS.repair, + CONTROL_D_COMMANDS.dnsAction, + ].includes(command.type as never) + ) + preview = null; + if (command.type === CONTROL_D_COMMANDS.getState) { + return { + ok: true, + state: await toControlDPublicState(await loadControlDConfig()), + }; + } + + if (command.type === CONTROL_D_COMMANDS.setEnabled) { + const config = await loadControlDConfig(); + const next = { ...config, enabled: command.enabled }; + await saveControlDConfig(next); + log(deps, "control-d.integration.toggled", { enabled: command.enabled }); + return { ok: true, state: await toControlDPublicState(next) }; + } + + if (command.type === CONTROL_D_COMMANDS.connect) { + const apiKey = command.apiKey.trim(); + if (!apiKey) return { ok: false, error: "Enter a Control D API key." }; + const config = await loadControlDConfig(); + try { + const client = createClient(apiKey); + const [candidates, proxies] = await Promise.all([ + discoverRecoverySets(client), + client.listProxies(), + ]); + if (proxies.length === 0) throw new Error("No usable proxy locations found."); + await saveControlDApiKey(apiKey); + const next: ControlDConfig = { + ...config, + connected: true, + status: "ready", + lastAttemptAt: new Date().toISOString(), + lastError: null, + }; + await saveControlDConfig(next); + log( + deps, + "control-d.connection.success", + { recoveryCandidates: candidates.length, proxyCount: proxies.length }, + undefined, + apiKey, + ); + return { + ok: true, + state: await toControlDPublicState(next), + candidates, + }; + } catch (error) { + const failed = await saveFailure(config, error); + log( + deps, + "control-d.connection.failure", + { error: errorMessage(error) }, + ExtensionLogLevel.Error, + apiKey, + ); + return { + ok: false, + error: errorMessage(error), + state: await toControlDPublicState(failed), + }; + } + } + + if (command.type === CONTROL_D_COMMANDS.disconnect) { + const config = await loadControlDConfig(); + await forgetControlDApiKey(); + const next: ControlDConfig = { + ...config, + connected: false, + autoSyncEnabled: false, + status: "disconnected", + dnsVerification: null, + lastError: null, + }; + await saveControlDConfig(next); + log(deps, "control-d.disconnected", { resourcesPreserved: true }); + return { ok: true, state: await toControlDPublicState(next) }; + } + + if (command.type === CONTROL_D_COMMANDS.updateMapping) { + if (!isMapping(command.mapping)) { + return { ok: false, error: "Invalid Control D location mapping." }; + } + const config = await loadControlDConfig(); + const next: ControlDConfig = { + ...config, + locationMappings: { + ...config.locationMappings, + [command.mapping.locationId]: command.mapping, + }, + }; + await saveControlDConfig(next); + log(deps, "control-d.mapping.updated", { + locationId: command.mapping.locationId, + proxyPk: command.mapping.proxyPk, + status: command.mapping.status, + }); + return { ok: true, state: await toControlDPublicState(next) }; + } + + if (command.type === CONTROL_D_COMMANDS.dnsAction) { + log(deps, "control-d.dns.action", { + action: command.action, + outcome: command.outcome, + }); + return { + ok: true, + state: await toControlDPublicState(await loadControlDConfig()), + }; + } + + const config = await loadControlDConfig(); + const apiKey = await loadControlDApiKey(); + if (!apiKey) { + return { + ok: false, + error: "Connect a Control D API key first.", + state: await toControlDPublicState(config), + }; + } + + if (command.type === CONTROL_D_COMMANDS.discover) { + try { + const candidates = await discoverRecoverySets(createClient(apiKey)); + return { + ok: true, + state: await toControlDPublicState(config), + candidates, + }; + } catch (error) { + return { + ok: false, + error: errorMessage(error), + state: await toControlDPublicState(config), + }; + } + } + + if (command.type === CONTROL_D_COMMANDS.selectNew) { + const next: ControlDConfig = { + ...config, + resourceIdentity: { code: generateResourceCode() }, + profileId: null, + endpointId: null, + resolverDoh: null, + dnsVerification: null, + managedFolders: {}, + locationMappings: {}, + autoSyncEnabled: false, + status: "ready", + lastSyncedHash: null, + lastSuccessAt: null, + lastError: null, + }; + await saveControlDConfig(next); + return { ok: true, state: await toControlDPublicState(next) }; + } + + if (command.type === CONTROL_D_COMMANDS.adopt) { + try { + const next = await adoptRecoverySet({ + client: createClient(apiKey), + config, + profileId: command.profileId, + endpointId: command.endpointId, + code: command.code, + }); + await saveControlDConfig(next); + return { ok: true, state: await toControlDPublicState(next) }; + } catch (error) { + return { + ok: false, + error: errorMessage(error), + state: await toControlDPublicState(config), + }; + } + } + + if (command.type === CONTROL_D_COMMANDS.confirmDns) { + if (!config.endpointId || !config.resolverDoh) { + return { + ok: false, + error: "Synchronize an endpoint before confirming browser DNS.", + state: await toControlDPublicState(config), + }; + } + const next: ControlDConfig = { + ...config, + dnsVerification: command.verified + ? { endpointId: config.endpointId, verifiedAt: new Date().toISOString() } + : null, + }; + await saveControlDConfig(next); + return { ok: true, state: await toControlDPublicState(next) }; + } + + if (command.type === CONTROL_D_COMMANDS.preview) { + try { + const prepared = await prepareControlDSync(createClient(apiKey), config); + log( + deps, + "control-d.diff.ready", + { + addRules: prepared.diff.addRules, + updateRules: prepared.diff.updateRules, + deleteRules: prepared.diff.deleteRules, + warnings: prepared.diff.warnings.length, + }, + undefined, + apiKey, + ); + const resolved = await withoutResolvedConflict(config); + const token = crypto.randomUUID(); + preview = { token, inputHash: prepared.inputHash, prepared }; + return { + ok: true, + state: await toControlDPublicState(resolved), + snapshot: toPreparedSnapshot(prepared, token), + }; + } catch (error) { + preview = null; + const failed = await saveFailure(config, error); + log( + deps, + "control-d.diff.failure", + { + error: errorMessage(error), + ...apiErrorDetails(error), + conflict: error instanceof ControlDConflictError, + }, + ExtensionLogLevel.Error, + apiKey, + ); + return { + ok: false, + error: errorMessage(error), + state: await toControlDPublicState(failed), + }; + } + } + + if (command.type === CONTROL_D_COMMANDS.syncNow && !config.lastSyncedHash) { + return { ok: false, error: "Preview and confirm the first synchronization." }; + } + + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + let reviewed: ControlDPreparedSync | undefined; + if ( + command.type === CONTROL_D_COMMANDS.apply || + command.type === CONTROL_D_COMMANDS.repair + ) { + if ( + !preview || + command.previewToken !== preview.token || + preview.inputHash !== (await inputHash(config)) + ) { + preview = null; + return { + ok: false, + error: "Preview changed. Refresh and review before applying.", + }; + } + reviewed = preview.prepared; + preview = null; + } + const result = await runSync({ + confirmApproximate: + command.type === CONTROL_D_COMMANDS.apply || + command.type === CONTROL_D_COMMANDS.repair + ? command.confirmApproximate + : false, + repair: command.type === CONTROL_D_COMMANDS.repair, + automatic: false, + ...(reviewed ? { reviewed } : {}), + }); + if (!result) return { ok: false, error: "Synchronization did not run." }; + if (!result.ok) { + return { + ok: false, + error: errorMessage(result.error), + state: await toControlDPublicState(result.failed), + }; + } + return { + ok: true, + state: await toControlDPublicState(result.next), + snapshot: toPreparedSnapshot(result.prepared, ""), + }; + }; + + return { + respond: (command: ControlDCommand) => + syncQueue.run(async () => { + try { + return await respond(command); + } finally { + if (rerunRequested) { + rerunRequested = false; + scheduleAutomatic(); + } + } + }), + scheduleAutomatic, + }; +}; + +export const registerControlD = (deps: BackgroundEntryDeps): void => { + const controller = createController(deps); + + fireAndForget( + loadControlDConfig().then((config) => { + if ( + config.enabled && + config.connected && + config.autoSyncEnabled && + config.lastSyncedHash + ) { + controller.scheduleAutomatic(); + } + }), + ); + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if ( + !isControlDCommand(message) || + sender.id !== chrome.runtime.id || + !sender.url?.startsWith(chrome.runtime.getURL("/")) + ) + return false; + fireAndForget(controller.respond(message).then(sendResponse), (error) => + sendResponse({ ok: false, error: errorMessage(error) }), + ); + return true; + }); + + chrome.storage.onChanged.addListener((changes, areaName) => { + if ( + areaName === "local" && + (RULES_STORAGE_KEY in changes || LOCATIONS_STORAGE_KEY in changes) + ) { + controller.scheduleAutomatic(); + } + }); +}; diff --git a/src/experimental/control-d/client.test.ts b/src/experimental/control-d/client.test.ts new file mode 100644 index 0000000..8933d6e --- /dev/null +++ b/src/experimental/control-d/client.test.ts @@ -0,0 +1,387 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ControlDClient } from "./client"; + +const jsonResponse = (body: unknown, status = 200, headers?: HeadersInit): Response => + new Response(JSON.stringify(body), { status, ...(headers ? { headers } : {}) }); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ControlDClient", () => { + it("invokes an injected browser transport with the global receiver", async () => { + const browserFetch = vi.fn(function (this: unknown) { + if (this !== globalThis) throw new TypeError("Illegal invocation"); + return Promise.resolve(jsonResponse({ body: { profiles: [] } })); + }); + + await expect( + new ControlDClient( + "token", + browserFetch as unknown as typeof fetch, + ).listProfiles(), + ).resolves.toEqual([]); + expect(browserFetch).toHaveBeenCalledOnce(); + }); + + it("validates proxy fields, ignores unknown fields and excludes hidden exits", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + body: { + proxies: [ + { + PK: "WAW", + city: "Warsaw", + country: "pl", + country_name: "Poland", + gps_lat: "52.2", + gps_long: 21, + future_field: true, + }, + { + PK: "SECRET", + city: "Hidden", + country: "PL", + country_name: "Poland", + gps_lat: 1, + gps_long: 2, + hidden: 1, + }, + ], + }, + }), + ); + + await expect(new ControlDClient("token", fetchImpl).listProxies()).resolves.toEqual( + [ + { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.2, + longitude: 21, + }, + ], + ); + expect(fetchImpl).toHaveBeenCalledWith( + "https://api.controld.com/proxies", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer token" }), + }), + ); + }); + + it("does not retry authentication failures", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({}, 401)); + const request = new ControlDClient("bad", fetchImpl).listProfiles(); + + await expect(request).rejects.toMatchObject({ + status: 401, + }); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it("surfaces a sanitized Control D error code, message and operation", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse( + { + body: [], + success: false, + error: { + message: "Name must be a maximum of 32 characters", + code: 40003, + }, + }, + 400, + ), + ); + + await expect( + new ControlDClient("token", fetchImpl).createProfile("too-long"), + ).rejects.toMatchObject({ + message: + "Control D rejected create profile (HTTP 400, code 40003): Name must be a maximum of 32 characters", + status: 400, + operation: "create profile", + apiCode: 40003, + }); + }); + + it("keeps a transport failure reason for redacted debug logging", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("Illegal invocation"); + }); + + await expect( + new ControlDClient("token", fetchImpl as unknown as typeof fetch).createProfile( + "Privacy Thing", + ), + ).rejects.toMatchObject({ + status: 0, + causeMessage: "TypeError: Illegal invocation", + }); + }); + + it("reads supported endpoint types and sends the selected icon", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + body: { + types: { + os: { + name: "Desktop & Mobile", + icons: { "desktop-linux": "Linux" }, + }, + browser: { + name: "Browser", + icons: { + "browser-chrome": "Google Chrome", + "browser-firefox": "Firefox", + "browser-other": "Other Browser", + }, + }, + router: { + name: "Router", + icons: { router: "Router" }, + }, + }, + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + body: { + device: { + PK: "device-1", + resolvers: { doh: "https://dns.controld.com/secret" }, + }, + }, + }), + ); + const client = new ControlDClient("token", fetchImpl as unknown as typeof fetch); + + await expect(client.listDeviceTypes()).resolves.toEqual([ + "desktop-linux", + "browser-chrome", + "browser-firefox", + "browser-other", + "router", + ]); + await expect( + client.createDevice("Privacy Thing", "profile-1", "browser-chromium"), + ).resolves.toMatchObject({ + id: "device-1", + profileId: "profile-1", + resolverDoh: "https://dns.controld.com/secret", + }); + const [, createInit] = fetchImpl.mock.calls[1] as [string, RequestInit]; + expect(createInit.method).toBe("POST"); + expect(createInit.body?.toString()).toContain("client_count=1"); + expect(createInit.body?.toString()).toContain("profile_id=profile-1"); + expect(createInit.body?.toString()).toContain("icon=browser-chromium"); + }); + + it("accepts a legacy flat endpoint-type map without treating groups as icons", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ body: { types: { "browser-other": "Other Browser" } } }), + ); + + await expect( + new ControlDClient("token", fetchImpl).listDeviceTypes(), + ).resolves.toEqual(["browser-other"]); + }); + + it("configures the managed profile default as enabled Bypass", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({})); + + await new ControlDClient("token", fetchImpl).setDefaultBypass("profile-1"); + + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://api.controld.com/profiles/profile-1/default"); + expect(init.method).toBe("PUT"); + expect(init.body?.toString()).toBe("do=1&status=1"); + }); + + it("parses documented folder and rule list field names", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + body: { + groups: [ + { + PK: 7, + group: "Managed folder", + action: { do: 3, via: "WAW", status: 1 }, + count: 1, + }, + ], + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + body: { + rules: [ + { + PK: "example.com", + group: 7, + action: { do: 3, via: "WAW", status: 1 }, + }, + ], + }, + }), + ); + const client = new ControlDClient("token", fetchImpl as unknown as typeof fetch); + + await expect(client.listGroups("profile-1")).resolves.toEqual([ + { id: 7, name: "Managed folder", action: 3, via: "WAW" }, + ]); + await expect(client.listRules("profile-1", 7)).resolves.toEqual([ + { + hostname: "example.com", + groupId: 7, + action: 3, + via: "WAW", + status: 1, + comment: null, + }, + ]); + }); + + it("reads a second enforced profile when the scalar id is a sentinel", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + body: { + devices: [ + { + PK: "device-1", + name: "PT-Browser", + profile: { PK: "main-profile", name: "Main" }, + profile_id2: "-1", + profile2: { PK: "privacy-profile", name: "Privacy Thing" }, + profile_id3: "0", + resolvers: { doh: "https://dns.controld.com/secret" }, + }, + ], + }, + }), + ); + + await expect(new ControlDClient("token", fetchImpl).listDevices()).resolves.toEqual( + [ + { + id: "device-1", + name: "PT-Browser", + profileId: "main-profile", + enforcedProfileIds: ["main-profile", "privacy-profile"], + resolverDoh: "https://dns.controld.com/secret", + }, + ], + ); + }); + + it("reads a scalar second profile and a lowercase profile key", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + body: { + devices: [ + { + PK: "scalar", + name: "Scalar", + profile: { PK: "main-profile" }, + profile2: "privacy-profile", + resolvers: { doh: "https://dns.controld.com/secret" }, + }, + { + PK: "lowercase", + name: "Lowercase", + profile: { PK: "main-profile" }, + profile2: { pk: "privacy-profile" }, + profiles: [{ PK: "main-profile" }, { id: "listed-profile" }], + resolvers: { doh: "https://dns.controld.com/secret" }, + }, + ], + }, + }), + ); + + await expect(new ControlDClient("token", fetchImpl).listDevices()).resolves.toEqual( + [ + { + id: "scalar", + name: "Scalar", + profileId: "main-profile", + enforcedProfileIds: ["main-profile", "privacy-profile"], + resolverDoh: "https://dns.controld.com/secret", + }, + { + id: "lowercase", + name: "Lowercase", + profileId: "main-profile", + enforcedProfileIds: ["main-profile", "privacy-profile", "listed-profile"], + resolverDoh: "https://dns.controld.com/secret", + }, + ], + ); + }); + + it("reads a second enforced profile from the endpoint", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + body: { + devices: [ + { + PK: "device-1", + name: "PT-Browser", + profile: { PK: "main-profile", name: "Main" }, + profile2: { PK: "privacy-profile", name: "Privacy Thing" }, + resolvers: { doh: "https://dns.controld.com/secret" }, + }, + ], + }, + }), + ); + + await expect(new ControlDClient("token", fetchImpl).listDevices()).resolves.toEqual( + [ + { + id: "device-1", + name: "PT-Browser", + profileId: "main-profile", + enforcedProfileIds: ["main-profile", "privacy-profile"], + resolverDoh: "https://dns.controld.com/secret", + }, + ], + ); + }); + + it("respects Retry-After for a safe request", async () => { + vi.useFakeTimers(); + const onRetry = vi.fn(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({}, 429, { "Retry-After": "2" })) + .mockResolvedValueOnce(jsonResponse({ body: { profiles: [] } })); + + const request = new ControlDClient( + "token", + fetchImpl as unknown as typeof fetch, + 12_000, + onRetry, + ).listProfiles(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(request).resolves.toEqual([]); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledWith({ + attempt: 1, + delayMs: 2_000, + status: 429, + requestId: null, + }); + }); +}); diff --git a/src/experimental/control-d/client.ts b/src/experimental/control-d/client.ts new file mode 100644 index 0000000..ec525c8 --- /dev/null +++ b/src/experimental/control-d/client.ts @@ -0,0 +1,495 @@ +import type { ControlDProxyLocation } from "./contracts"; +import { redactControlDLogValue } from "./redaction"; + +const API_BASE = "https://api.controld.com"; +const MAX_ATTEMPTS = 3; + +type UnknownRecord = Record; + +export class ControlDApiError extends Error { + // eslint-disable-next-line max-params -- Carries the complete sanitized API failure context. + constructor( + message: string, + readonly status: number, + readonly requestId: string | null, + readonly retryAfterSeconds: number | null, + readonly causeMessage: string | null = null, + readonly operation: string | null = null, + readonly apiCode: number | null = null, + ) { + super(message); + this.name = "ControlDApiError"; + } +} + +const isRecord = (value: unknown): value is UnknownRecord => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +const asRecord = (value: unknown): UnknownRecord => (isRecord(value) ? value : {}); +const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); +const asString = (value: unknown): string | null => + typeof value === "string" && value.length > 0 ? value : null; +const profileKey = (value: unknown): string | null => { + if (value === 0 || value === "0" || value === -1) return null; + const id = asString(value); + return id && id !== "-1" ? id : null; +}; +const profileIdFromValue = (value: unknown): string | null => { + if (!isRecord(value)) return profileKey(value); + return profileKey(value.PK) ?? profileKey(value.pk) ?? profileKey(value.id); +}; +const slotProfileId = (profile: unknown, profileId: unknown): string | null => + profileIdFromValue(profile) ?? profileIdFromValue(profileId); +const enforcedDeviceProfiles = (record: UnknownRecord): string[] => [ + ...new Set( + [ + slotProfileId(record.profile, record.profile_id), + slotProfileId(record.profile2, record.profile_id2), + slotProfileId(record.profile3, record.profile_id3), + ...asArray(record.profiles).map(profileIdFromValue), + ].filter((id): id is string => id !== null), + ), +]; +const asNumber = (value: unknown): number | null => { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const bodyRecord = (payload: unknown): UnknownRecord => + asRecord(asRecord(payload).body); +const extractCollection = (payload: unknown, key: string): unknown[] => { + const body = bodyRecord(payload); + return asArray(body[key] ?? asRecord(body.data)[key] ?? asRecord(payload)[key]); +}; + +const formBody = ( + fields: Record, +): URLSearchParams => { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(fields)) { + if (Array.isArray(value)) { + value.forEach((entry) => body.append(key, entry)); + } else { + body.set(key, String(value)); + } + } + return body; +}; + +const delay = async (milliseconds: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +}; + +const parseJson = (text: string): unknown => { + if (!text) return {}; + try { + return JSON.parse(text) as unknown; + } catch { + return {}; + } +}; + +export type ControlDProfile = { id: string; name: string }; +export type ControlDGroup = { + id: number; + name: string; + action: number | null; + via: string | null; +}; +export type ControlDRule = { + hostname: string; + groupId: number | null; + action: number | null; + via: string | null; + status: number | null; + comment: string | null; +}; +export type ControlDDevice = { + id: string; + name: string; + profileId: string | null; + enforcedProfileIds?: readonly string[]; + resolverDoh: string | null; +}; + +export const deviceProfileIds = (device: ControlDDevice): readonly string[] => + device.enforcedProfileIds ?? (device.profileId ? [device.profileId] : []); + +export const deviceUsesAnotherProfile = ( + device: ControlDDevice, + managedProfileId: string | null | undefined, +): boolean => { + if (!managedProfileId) return false; + const ids = deviceProfileIds(device); + return !ids.includes(managedProfileId); +}; +export type ControlDRetryEvent = { + attempt: number; + delayMs: number; + status: number; + requestId: string | null; +}; + +export class ControlDClient { + constructor( + private readonly token: string, + private readonly fetchImpl: typeof fetch = fetch, + private readonly timeoutMs = 12_000, + private readonly onRetry?: (event: ControlDRetryEvent) => void, + ) {} + + // eslint-disable-next-line sonarjs/cognitive-complexity -- Retry, timeout, and HTTP policy stay centralized. + private async request( + path: string, + init: RequestInit = {}, + retryable = false, + operation = "API request", + ): Promise { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetchImpl.call(globalThis, `${API_BASE}${path}`, { + ...init, + headers: { + Accept: "application/json", + Authorization: `Bearer ${this.token}`, + ...(init.body + ? { "Content-Type": "application/x-www-form-urlencoded" } + : {}), + ...init.headers, + }, + signal: controller.signal, + }); + const requestId = + response.headers.get("x-request-id") ?? response.headers.get("cf-ray"); + const retryAfterHeader = response.headers.get("retry-after"); + const retryAfterSeconds = retryAfterHeader + ? Number.parseInt(retryAfterHeader, 10) + : null; + const responseText = response.status === 204 ? "" : await response.text(); + const payload = parseJson(responseText); + + if (!response.ok) { + if ( + retryable && + (response.status === 429 || response.status >= 500) && + attempt < MAX_ATTEMPTS + ) { + const delayMs = + response.status === 429 && Number.isFinite(retryAfterSeconds) + ? Math.max(0, retryAfterSeconds ?? 0) * 1_000 + : 250 * 2 ** (attempt - 1); + this.onRetry?.({ + attempt, + delayMs, + status: response.status, + requestId, + }); + await delay(delayMs); + continue; + } + const apiError = asRecord(asRecord(payload).error); + const apiCode = asNumber(apiError.code); + const rawApiMessage = asString(apiError.message); + const apiMessage = rawApiMessage + ? String(redactControlDLogValue(rawApiMessage, this.token)).slice(0, 240) + : null; + const codeLabel = apiCode === null ? "" : `, code ${apiCode}`; + const detail = apiMessage ? `: ${apiMessage}` : "."; + throw new ControlDApiError( + `Control D rejected ${operation} (HTTP ${response.status}${codeLabel})${detail}`, + response.status, + requestId, + Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : null, + null, + operation, + apiCode, + ); + } + + return payload; + } catch (error) { + if (error instanceof ControlDApiError) throw error; + if (retryable && attempt < MAX_ATTEMPTS) { + const delayMs = 250 * 2 ** (attempt - 1); + this.onRetry?.({ attempt, delayMs, status: 0, requestId: null }); + await delay(delayMs); + continue; + } + throw new ControlDApiError( + error instanceof DOMException && error.name === "AbortError" + ? "Control D API request timed out." + : "Control D API request failed.", + 0, + null, + null, + error instanceof Error ? `${error.name}: ${error.message}` : String(error), + ); + } finally { + clearTimeout(timer); + } + } + throw new ControlDApiError("Control D API request failed.", 0, null, null, null); + } + + async listProfiles(): Promise { + const payload = await this.request("/profiles", {}, true, "list profiles"); + return extractCollection(payload, "profiles").flatMap((entry) => { + const record = asRecord(entry); + const id = asString(record.PK ?? record.pk ?? record.id); + const name = asString(record.name); + return id && name ? [{ id, name }] : []; + }); + } + + async createProfile(name: string): Promise { + await this.request( + "/profiles", + { method: "POST", body: formBody({ name }) }, + false, + "create profile", + ); + } + + async setDefaultBypass(profileId: string): Promise { + await this.request( + `/profiles/${encodeURIComponent(profileId)}/default`, + { method: "PUT", body: formBody({ do: 1, status: 1 }) }, + true, + "set profile default", + ); + } + + async listGroups(profileId: string): Promise { + const payload = await this.request( + `/profiles/${encodeURIComponent(profileId)}/groups`, + {}, + true, + "list rule folders", + ); + return extractCollection(payload, "groups").flatMap((entry) => { + const record = asRecord(entry); + const id = asNumber(record.PK ?? record.pk ?? record.id); + const name = asString(record.name ?? record.group); + return id !== null && name + ? [ + { + id, + name, + action: asNumber(record.do ?? asRecord(record.action).do), + via: asString(record.via ?? asRecord(record.action).via), + }, + ] + : []; + }); + } + + async createGroup(profileId: string, name: string, proxyPk: string): Promise { + await this.request( + `/profiles/${encodeURIComponent(profileId)}/groups`, + { + method: "POST", + body: formBody({ name, do: 3, via: proxyPk, status: 1 }), + }, + false, + "create rule folder", + ); + } + + async listRules(profileId: string, folderId: number): Promise { + const payload = await this.request( + `/profiles/${encodeURIComponent(profileId)}/rules/${folderId}`, + {}, + true, + "list managed rules", + ); + return extractCollection(payload, "rules").flatMap((entry) => { + const record = asRecord(entry); + const action = asRecord(record.action); + const hostname = asString(record.hostname ?? record.host ?? record.PK); + if (!hostname) return []; + return [ + { + hostname, + groupId: asNumber(record.group ?? record.group_id), + action: asNumber(record.do ?? action.do), + via: asString(record.via ?? action.via), + status: asNumber(record.status ?? action.status), + comment: asString(record.comment), + }, + ]; + }); + } + + // eslint-disable-next-line max-params -- Mirrors the public API form contract. + async createRules( + profileId: string, + folderId: number, + proxyPk: string, + hostnames: readonly string[], + comment: string, + ): Promise { + if (hostnames.length === 0) return; + await this.request( + `/profiles/${encodeURIComponent(profileId)}/rules`, + { + method: "POST", + body: formBody({ + do: 3, + status: 1, + via: proxyPk, + group: folderId, + comment, + "hostnames[]": hostnames, + }), + }, + false, + "create managed rules", + ); + } + + // eslint-disable-next-line max-params -- Mirrors the public API form contract. + async updateRules( + profileId: string, + folderId: number, + proxyPk: string, + hostnames: readonly string[], + comment: string, + ): Promise { + if (hostnames.length === 0) return; + await this.request( + `/profiles/${encodeURIComponent(profileId)}/rules`, + { + method: "PUT", + body: formBody({ + do: 3, + status: 1, + via: proxyPk, + group: folderId, + comment, + "hostnames[]": hostnames, + }), + }, + true, + "update managed rules", + ); + } + + async deleteRule(profileId: string, hostname: string): Promise { + await this.request( + `/profiles/${encodeURIComponent(profileId)}/rules/${encodeURIComponent(hostname)}`, + { method: "DELETE" }, + true, + "delete managed rule", + ); + } + + async listProxies(): Promise { + const payload = await this.request("/proxies", {}, true, "list proxy locations"); + return extractCollection(payload, "proxies").flatMap((entry) => { + const record = asRecord(entry); + const pk = asString(record.PK ?? record.pk ?? record.uid); + const city = asString(record.city); + const countryCode = asString(record.country); + const countryName = asString(record.country_name) ?? countryCode; + const latitude = asNumber(record.gps_lat); + const longitude = asNumber(record.gps_long); + const hidden = record.hidden === true || record.hidden === 1; + return pk && + city && + countryCode && + latitude !== null && + longitude !== null && + !hidden + ? [ + { + pk, + city, + countryCode: countryCode.toUpperCase(), + countryName: countryName ?? countryCode, + latitude, + longitude, + }, + ] + : []; + }); + } + + async listDevices(): Promise { + const payload = await this.request("/devices", {}, true, "list endpoints"); + return extractCollection(payload, "devices").flatMap((entry) => { + const record = asRecord(entry); + const resolvers = asRecord(record.resolvers); + const enforcedProfileIds = enforcedDeviceProfiles(record); + const id = asString(record.PK ?? record.pk ?? record.id); + const name = asString(record.name); + return id && name + ? [ + { + id, + name, + profileId: enforcedProfileIds[0] ?? null, + enforcedProfileIds, + resolverDoh: asString(resolvers.doh ?? record.doh), + }, + ] + : []; + }); + } + + async listDeviceTypes(): Promise { + const payload = await this.request( + "/devices/types", + {}, + true, + "list endpoint types", + ); + const types = bodyRecord(payload).types; + const entries = extractCollection(payload, "types"); + const arrayIds = entries.flatMap((entry) => { + if (typeof entry === "string" && entry) return [entry]; + const record = asRecord(entry); + const value = asString(record.id ?? record.PK ?? record.value ?? record.slug); + return value ? [value] : []; + }); + const typeGroups = asRecord(types); + const flatIds = Object.entries(typeGroups).flatMap(([id, value]) => + typeof value === "string" ? [id] : [], + ); + const nestedIds = Object.values(typeGroups).flatMap((group) => + Object.keys(asRecord(asRecord(group).icons)), + ); + return [...new Set([...arrayIds, ...flatIds, ...nestedIds])]; + } + + async createDevice( + name: string, + profileId: string, + icon: string, + ): Promise { + const payload = await this.request( + "/devices", + { + method: "POST", + body: formBody({ name, client_count: 1, profile_id: profileId, icon }), + }, + false, + "create endpoint", + ); + const candidates = [ + ...extractCollection(payload, "devices"), + bodyRecord(payload).device, + bodyRecord(payload), + ]; + for (const entry of candidates) { + const record = asRecord(entry); + const id = asString(record.PK ?? record.pk ?? record.id); + const resolvers = asRecord(record.resolvers); + const resolverDoh = asString(resolvers.doh ?? record.doh); + if (id) + return { id, name, profileId, enforcedProfileIds: [profileId], resolverDoh }; + } + return null; + } +} diff --git a/src/experimental/control-d/compiler.test.ts b/src/experimental/control-d/compiler.test.ts new file mode 100644 index 0000000..f8a8c5f --- /dev/null +++ b/src/experimental/control-d/compiler.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; + +import { compileControlDPattern, compileControlDState } from "./compiler"; +import type { ControlDProxyLocation } from "./contracts"; + +import type { DomainRule, Location } from "@/shared/types"; + +const warsaw: Location = { + id: "warsaw", + label: "Warsaw", + latitude: 52.23, + longitude: 21.01, + countryCode: "PL", + accuracy: 25, + noiseRadius: 50, + language: "pl", + languages: ["pl"], + timeZone: "Europe/Warsaw", +}; + +const paris: Location = { + ...warsaw, + id: "paris", + label: "Paris", + latitude: 48.86, + longitude: 2.35, + countryCode: "FR", + language: "fr", + languages: ["fr"], + timeZone: "Europe/Paris", +}; + +const ottawa: Location = { + ...warsaw, + id: "ottawa", + label: "Ottawa", + latitude: 45.42, + longitude: -75.7, + countryCode: "CA", + language: "en", + languages: ["en"], + timeZone: "America/Toronto", +}; + +const proxies: ControlDProxyLocation[] = [ + { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.2, + longitude: 21, + }, + { + pk: "PAR", + city: "Paris", + countryCode: "FR", + countryName: "France", + latitude: 48.86, + longitude: 2.35, + }, + { + pk: "YOW", + city: "Ottawa", + countryCode: "CA", + countryName: "Canada", + latitude: 45.42, + longitude: -75.7, + }, + { + pk: "BER", + city: "Berlin", + countryCode: "DE", + countryName: "Germany", + latitude: 52.52, + longitude: 13.4, + }, +]; + +const rule = (pattern: string): DomainRule => ({ + pattern, + enabled: true, + locationId: warsaw.id, +}); + +describe("compileControlDPattern", () => { + it("preserves subdomain-only patterns", () => { + expect(compileControlDPattern("*.example.com")).toEqual({ + hostname: "*.example.com", + }); + }); + + it("preserves Privacy Thing suffix patterns", () => { + expect(compileControlDPattern("*example.com")).toEqual({ + hostname: "*example.com", + }); + }); + + it("preserves exact hosts and wildcards supported by Control D", () => { + expect(compileControlDPattern("example.com")).toEqual({ + hostname: "example.com", + }); + expect(compileControlDPattern("server-*.example.com")).toEqual({ + hostname: "server-*.example.com", + }); + }); + + it("rejects values that are not hostname patterns", () => { + expect(compileControlDPattern("https://example.com/path")).toMatchObject({ + warning: { code: "unsupported-pattern" }, + }); + }); + + it("keeps exact hosts in the compiled regional rules", () => { + const result = compileControlDState({ + rules: [rule("www.linkedin.com"), rule("github.com")], + locations: [warsaw], + proxies, + storedMappings: {}, + }); + + expect(result.rules.map((entry) => entry.hostname)).toEqual([ + "github.com", + "www.linkedin.com", + ]); + expect(result.mappings.warsaw).toMatchObject({ + locationLabel: "Warsaw", + ruleCount: 2, + }); + expect(result.warnings).toEqual([]); + }); +}); + +describe("compileControlDState", () => { + it("compiles the exported Warsaw, Paris, and Ottawa rule set", () => { + const result = compileControlDState({ + rules: [ + rule("www.linkedin.com"), + rule("github.com"), + rule("*www.instagram.com"), + rule("*example.com"), + { pattern: "*jakdojade.pl", enabled: true }, + { ...rule("iteracja.elpassion.com"), locationId: ottawa.id }, + { ...rule("test.pl"), locationId: paris.id }, + ], + locations: [warsaw, paris, ottawa], + proxies, + storedMappings: {}, + }); + + expect(result.rules).toHaveLength(6); + expect(result.rules.map((entry) => entry.hostname)).toEqual([ + "*example.com", + "*www.instagram.com", + "github.com", + "iteracja.elpassion.com", + "test.pl", + "www.linkedin.com", + ]); + expect(result.mappings).toMatchObject({ + warsaw: { proxyPk: "WAW", ruleCount: 4 }, + paris: { proxyPk: "PAR", ruleCount: 1 }, + ottawa: { proxyPk: "YOW", ruleCount: 1 }, + }); + }); + + it("selects the nearest exit in the confirmed country", () => { + const result = compileControlDState({ + rules: [rule("*example.com")], + locations: [warsaw], + proxies, + storedMappings: {}, + }); + + expect(result.rules).toEqual([ + { + sourcePattern: "*example.com", + hostname: "*example.com", + locationId: "warsaw", + proxyPk: "WAW", + }, + ]); + expect(result.mappings.warsaw).toMatchObject({ + status: "exact", + confirmed: true, + proxyPk: "WAW", + }); + }); + + it("uses a visible approximate mapping when the country is unavailable", () => { + const result = compileControlDState({ + rules: [rule("*.example.com")], + locations: [{ ...warsaw, countryCode: "CZ" }], + proxies, + storedMappings: {}, + }); + + expect(result.mappings.warsaw).toMatchObject({ + status: "approximate", + confirmed: false, + }); + expect(result.warnings).toContainEqual( + expect.objectContaining({ code: "approximate-location" }), + ); + }); + + it("honors an explicit skip and ignores disabled or location-less rules", () => { + const result = compileControlDState({ + rules: [rule("*example.com"), { ...rule("*off.test"), enabled: false }], + locations: [warsaw], + proxies, + storedMappings: { + warsaw: { + locationId: "warsaw", + proxyPk: null, + status: "skipped", + confirmed: true, + }, + }, + }); + + expect(result.rules).toEqual([]); + expect(result.warnings).toContainEqual( + expect.objectContaining({ code: "skipped-location" }), + ); + }); +}); diff --git a/src/experimental/control-d/compiler.ts b/src/experimental/control-d/compiler.ts new file mode 100644 index 0000000..bb48ffd --- /dev/null +++ b/src/experimental/control-d/compiler.ts @@ -0,0 +1,257 @@ +/* eslint-disable sonarjs/cognitive-complexity -- Fail-closed compilation keeps rule decisions explicit. */ + +import type { + ControlDCompiledRule, + ControlDCompileWarning, + ControlDMapping, + ControlDProxyLocation, +} from "./contracts"; + +import type { DomainRule, Location } from "@/shared/types"; + +export type ControlDCompilation = { + rules: ControlDCompiledRule[]; + warnings: ControlDCompileWarning[]; + mappings: Record; +}; + +const toRadians = (degrees: number): number => (degrees * Math.PI) / 180; + +export const distanceInKilometers = ( + first: Pick, + second: Pick, +): number => { + const earthRadius = 6_371; + const latitudeDelta = toRadians(second.latitude - first.latitude); + const longitudeDelta = toRadians(second.longitude - first.longitude); + const firstLatitude = toRadians(first.latitude); + const secondLatitude = toRadians(second.latitude); + const haversine = + Math.sin(latitudeDelta / 2) ** 2 + + Math.cos(firstLatitude) * + Math.cos(secondLatitude) * + Math.sin(longitudeDelta / 2) ** 2; + + return earthRadius * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); +}; + +const nearestProxy = ( + location: Location, + proxies: readonly ControlDProxyLocation[], +): ControlDProxyLocation | null => + proxies.reduce((nearest, candidate) => { + if (!nearest) return candidate; + return distanceInKilometers(location, candidate) < + distanceInKilometers(location, nearest) + ? candidate + : nearest; + }, null); + +const resolveMapping = ( + location: Location, + proxies: readonly ControlDProxyLocation[], + stored: ControlDMapping | undefined, +): { mapping: ControlDMapping; warning?: ControlDCompileWarning } => { + if (stored?.status === "skipped" || stored?.proxyPk === null) { + return { + mapping: { + locationId: location.id, + locationLabel: location.label, + proxyPk: null, + status: "skipped", + confirmed: stored?.confirmed ?? true, + }, + warning: { + code: "skipped-location", + locationId: location.id, + message: `${location.label} is excluded from Control D synchronization.`, + }, + }; + } + + const storedProxy = stored + ? proxies.find((proxy) => proxy.pk === stored.proxyPk) + : undefined; + if (storedProxy) { + const exact = + Boolean(location.countryCode) && + storedProxy.countryCode === location.countryCode?.toUpperCase(); + const status = exact ? "exact" : "approximate"; + return { + mapping: { + locationId: location.id, + locationLabel: location.label, + proxyPk: storedProxy.pk, + status, + confirmed: status === "exact" || (stored?.confirmed ?? false), + }, + ...(status === "approximate" + ? { + warning: { + code: "approximate-location" as const, + locationId: location.id, + message: `${location.label} uses the approximate exit ${storedProxy.city}, ${storedProxy.countryName}.`, + }, + } + : {}), + }; + } + + const normalizedCountry = location.countryCode?.toUpperCase(); + const sameCountry = normalizedCountry + ? proxies.filter((proxy) => proxy.countryCode === normalizedCountry) + : []; + const selected = nearestProxy( + location, + sameCountry.length > 0 ? sameCountry : proxies, + ); + + if (!selected) { + return { + mapping: { + locationId: location.id, + locationLabel: location.label, + proxyPk: null, + status: "skipped", + confirmed: false, + }, + warning: { + code: "skipped-location", + locationId: location.id, + message: `No usable Control D exit is available for ${location.label}.`, + }, + }; + } + + const exact = sameCountry.length > 0; + const status = exact ? "exact" : "approximate"; + let warning: ControlDCompileWarning | undefined; + if (!normalizedCountry) { + warning = { + code: "missing-country", + locationId: location.id, + message: `${location.label} has no confirmed country and currently maps to ${selected.city}, ${selected.countryName}.`, + }; + } else if (!exact) { + warning = { + code: "approximate-location", + locationId: location.id, + message: `Control D has no exit in ${normalizedCountry}; ${location.label} maps to ${selected.city}, ${selected.countryName}.`, + }; + } + + return { + mapping: { + locationId: location.id, + locationLabel: location.label, + proxyPk: selected.pk, + status, + confirmed: exact, + }, + ...(warning ? { warning } : {}), + }; +}; + +export const compileControlDPattern = ( + pattern: string, +): { hostname: string } | { warning: ControlDCompileWarning } => { + const isHostnamePattern = + pattern.length > 0 && + pattern === pattern.trim() && + pattern !== "*" && + /^[a-z0-9*._-]+$/i.test(pattern); + + if (isHostnamePattern) return { hostname: pattern }; + + return { + warning: { + code: "unsupported-pattern", + pattern, + message: `${pattern} is not a valid Control D hostname pattern.`, + }, + }; +}; + +export const compileControlDState = ({ + rules, + locations, + proxies, + storedMappings, +}: { + rules: readonly DomainRule[]; + locations: readonly Location[]; + proxies: readonly ControlDProxyLocation[]; + storedMappings: Readonly>; +}): ControlDCompilation => { + const warnings: ControlDCompileWarning[] = []; + const mappings: Record = {}; + const compiledRules: ControlDCompiledRule[] = []; + const locationById = new Map(locations.map((location) => [location.id, location])); + const usedHostnames = new Map(); + + for (const rule of rules) { + if (!rule.enabled || !rule.locationId) continue; + const location = locationById.get(rule.locationId); + if (!location) { + warnings.push({ + code: "missing-location", + pattern: rule.pattern, + locationId: rule.locationId, + message: `${rule.pattern} references a missing regional preset.`, + }); + continue; + } + + let mapping = mappings[location.id]; + if (!mapping) { + const resolved = resolveMapping(location, proxies, storedMappings[location.id]); + mapping = resolved.mapping; + mappings[location.id] = mapping; + if (resolved.warning) warnings.push(resolved.warning); + } + if (!mapping.proxyPk || mapping.status === "skipped") continue; + + const patternResult = compileControlDPattern(rule.pattern); + if (!("hostname" in patternResult)) { + warnings.push({ ...patternResult.warning, locationId: location.id }); + continue; + } + const previousLocation = usedHostnames.get(patternResult.hostname); + if (previousLocation && previousLocation !== location.id) { + warnings.push({ + code: "unsupported-pattern", + pattern: rule.pattern, + locationId: location.id, + message: `${patternResult.hostname} resolves to more than one regional preset.`, + }); + continue; + } + + usedHostnames.set(patternResult.hostname, location.id); + compiledRules.push({ + sourcePattern: rule.pattern, + hostname: patternResult.hostname, + locationId: location.id, + proxyPk: mapping.proxyPk, + }); + } + + const ruleCounts = compiledRules.reduce>((counts, rule) => { + counts[rule.locationId] = (counts[rule.locationId] ?? 0) + 1; + return counts; + }, {}); + const summarizedMappings = Object.fromEntries( + Object.entries(mappings).map(([locationId, mapping]) => [ + locationId, + { ...mapping, ruleCount: ruleCounts[locationId] ?? 0 }, + ]), + ); + + return { + rules: compiledRules.sort((left, right) => + left.hostname.localeCompare(right.hostname), + ), + warnings, + mappings: summarizedMappings, + }; +}; diff --git a/src/experimental/control-d/contracts.ts b/src/experimental/control-d/contracts.ts new file mode 100644 index 0000000..1d04c09 --- /dev/null +++ b/src/experimental/control-d/contracts.ts @@ -0,0 +1,236 @@ +import { z } from "zod"; + +export const CONTROL_D_API_ORIGIN = "https://api.controld.com/*"; +export const CONTROL_D_API_GUIDE_URL = + "https://docs.controld.com/reference/get-started"; +export const CONTROL_D_GUIDE_URL = "https://docs.controld.com/docs/browsers-platform"; +export const CONTROL_D_STATUS_URL = "https://controld.com/status"; + +export const CONTROL_D_COMMANDS = { + getState: "pt.control-d.get-state", + setEnabled: "pt.control-d.set-enabled", + connect: "pt.control-d.connect", + discover: "pt.control-d.discover", + selectNew: "pt.control-d.select-new", + adopt: "pt.control-d.adopt", + preview: "pt.control-d.preview", + apply: "pt.control-d.apply", + syncNow: "pt.control-d.sync-now", + repair: "pt.control-d.repair", + updateMapping: "pt.control-d.update-mapping", + confirmDns: "pt.control-d.confirm-dns", + dnsAction: "pt.control-d.dns-action", + disconnect: "pt.control-d.disconnect", +} as const; + +export type ControlDStatus = + "disconnected" | "ready" | "syncing" | "conflict" | "auth-error" | "error"; + +export type ControlDResourceIdentity = { code: string }; +export type ControlDDnsVerification = { endpointId: string; verifiedAt: string }; + +export type ControlDMapping = { + locationId: string; + locationLabel?: string; + ruleCount?: number; + proxyPk: string | null; + status: "exact" | "approximate" | "skipped"; + confirmed: boolean; +}; + +export type ControlDManagedFolder = { + proxyPk: string; + folderId: number; + remoteHash: string; +}; + +export type ControlDConfigV2 = { + version: 2; + enabled: boolean; + connected: boolean; + autoSyncEnabled: boolean; + status: ControlDStatus; + resourceIdentity: ControlDResourceIdentity | null; + profileId: string | null; + endpointId: string | null; + resolverDoh: string | null; + dnsVerification: ControlDDnsVerification | null; + managedFolders: Record; + locationMappings: Record; + lastSyncedHash: string | null; + lastAttemptAt: string | null; + lastSuccessAt: string | null; + lastError: string | null; +}; + +// eslint-disable-next-line sonarjs/redundant-type-aliases +export type ControlDConfig = ControlDConfigV2; + +// eslint-disable-next-line local/max-symbol-name-length +export type ControlDRecoveryCandidate = { + code: string; + profileId: string; + profileName: string; + endpointId: string | null; + endpointName: string | null; + managedFolderCount: number; + compatibility: "ready" | "profile-only" | "ambiguous"; + issue: string | null; +}; + +export type ControlDProxyLocation = { + pk: string; + city: string; + countryCode: string; + countryName: string; + latitude: number; + longitude: number; +}; + +export type ControlDCompiledRule = { + sourcePattern: string; + hostname: string; + locationId: string; + proxyPk: string; +}; + +export type ControlDCompileWarning = { + code: + | "unsupported-pattern" + | "missing-location" + | "missing-country" + | "approximate-location" + | "skipped-location"; + message: string; + pattern?: string; + locationId?: string; +}; + +export type ControlDDiff = { + createProfile: boolean; + createEndpoint: boolean; + createFolders: number; + addRules: number; + updateRules: number; + deleteRules: number; + unchangedRules: number; + warnings: ControlDCompileWarning[]; + mappings: ControlDMapping[]; + requiresApproximationConfirmation: boolean; +}; + +export type ControlDPreparedSnapshot = { + token: string; + diff: ControlDDiff; + proxies: ControlDProxyLocation[]; +}; + +export type ControlDPublicState = { + enabled: boolean; + connected: boolean; + autoSyncEnabled: boolean; + status: ControlDStatus; + hasApiKey: boolean; + setupStatus: "unselected" | "selected"; + resourceCode: string | null; + profileId: string | null; + endpointId: string | null; + hasResolver: boolean; + resolverDoh: string | null; + dnsStatus: "unavailable" | "pending" | "verified"; + dnsVerifiedAt: string | null; + lastAttemptAt: string | null; + lastSuccessAt: string | null; + lastError: string | null; +}; + +export type ControlDResponse = + | ({ ok: true; state: ControlDPublicState } & (T extends undefined ? object : T)) + | { ok: false; error: string; state?: ControlDPublicState }; + +const mappingSchema = z.object({ + locationId: z.string().min(1), + locationLabel: z.string().min(1).optional(), + ruleCount: z.number().int().nonnegative().optional(), + proxyPk: z.string().min(1).nullable(), + status: z.enum(["exact", "approximate", "skipped"]), + confirmed: z.boolean(), +}); + +const managedFolderSchema = z.object({ + proxyPk: z.string().min(1), + folderId: z.number().int().nonnegative(), + remoteHash: z.string(), +}); + +export const controlDConfigSchema = z.object({ + version: z.literal(2), + enabled: z.boolean(), + connected: z.boolean(), + autoSyncEnabled: z.boolean(), + status: z.enum([ + "disconnected", + "ready", + "syncing", + "conflict", + "auth-error", + "error", + ]), + resourceIdentity: z.object({ code: z.string().min(1) }).nullable(), + profileId: z.string().min(1).nullable(), + endpointId: z.string().min(1).nullable(), + resolverDoh: z.string().min(1).nullable(), + dnsVerification: z + .object({ endpointId: z.string().min(1), verifiedAt: z.string().min(1) }) + .nullable(), + managedFolders: z.record(z.string(), managedFolderSchema), + locationMappings: z.record(z.string(), mappingSchema), + lastSyncedHash: z.string().nullable(), + lastAttemptAt: z.string().nullable(), + lastSuccessAt: z.string().nullable(), + lastError: z.string().nullable(), +}); + +export type ControlDCommand = + | { type: typeof CONTROL_D_COMMANDS.getState } + | { type: typeof CONTROL_D_COMMANDS.setEnabled; enabled: boolean } + | { type: typeof CONTROL_D_COMMANDS.connect; apiKey: string } + | { type: typeof CONTROL_D_COMMANDS.discover } + | { type: typeof CONTROL_D_COMMANDS.selectNew } + | { + type: typeof CONTROL_D_COMMANDS.adopt; + profileId: string; + endpointId: string | null; + code: string; + } + | { type: typeof CONTROL_D_COMMANDS.preview } + | { + type: typeof CONTROL_D_COMMANDS.apply; + confirmApproximate: boolean; + previewToken: string; + } + | { type: typeof CONTROL_D_COMMANDS.syncNow } + | { + type: typeof CONTROL_D_COMMANDS.repair; + confirmApproximate: boolean; + previewToken: string; + } + | { type: typeof CONTROL_D_COMMANDS.updateMapping; mapping: ControlDMapping } + | { type: typeof CONTROL_D_COMMANDS.confirmDns; verified: boolean } + | { + type: typeof CONTROL_D_COMMANDS.dnsAction; + action: "copy-resolver" | "open-settings" | "open-status" | "open-guide"; + outcome: "success" | "fallback" | "failure"; + } + | { type: typeof CONTROL_D_COMMANDS.disconnect }; + +export const isControlDCommand = (value: unknown): value is ControlDCommand => { + if (!value || typeof value !== "object") return false; + const type = (value as { type?: unknown }).type; + return ( + typeof type === "string" && + Object.values(CONTROL_D_COMMANDS).includes( + type as (typeof CONTROL_D_COMMANDS)[keyof typeof CONTROL_D_COMMANDS], + ) + ); +}; diff --git a/src/experimental/control-d/reconcile.target.test.ts b/src/experimental/control-d/reconcile.target.test.ts new file mode 100644 index 0000000..9aa4947 --- /dev/null +++ b/src/experimental/control-d/reconcile.target.test.ts @@ -0,0 +1,611 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + ControlDClient, + ControlDDevice, + ControlDGroup, + ControlDRule, +} from "./client"; +import type { ControlDConfig, ControlDProxyLocation } from "./contracts"; +import { + applyControlDSync, + ControlDConflictError, + prepareControlDSync, +} from "./reconcile"; + +import { loadLocations } from "@/background/storage/locations"; +import { loadRules } from "@/background/storage/rules"; + +vi.mock("@/background/storage/locations", () => ({ loadLocations: vi.fn() })); +vi.mock("@/background/storage/rules", () => ({ loadRules: vi.fn() })); + +const proxy: ControlDProxyLocation = { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.2, + longitude: 21, +}; + +const berlinProxy: ControlDProxyLocation = { + pk: "BER", + city: "Berlin", + countryCode: "DE", + countryName: "Germany", + latitude: 52.52, + longitude: 13.4, +}; + +const config = (): ControlDConfig => ({ + version: 2, + enabled: true, + connected: true, + autoSyncEnabled: false, + status: "ready", + resourceIdentity: { code: "ABCDE-FGHJK" }, + profileId: null, + endpointId: null, + resolverDoh: null, + dnsVerification: null, + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: null, + lastAttemptAt: null, + lastSuccessAt: null, + lastError: null, +}); + +class FakeClient { + profiles: Array<{ id: string; name: string }> = []; + groups: ControlDGroup[] = []; + devices: ControlDDevice[] = []; + rules = new Map(); + createRulesCalls = 0; + updateRulesCalls = 0; + deleteRuleCalls = 0; + setDefaultBypassCalls = 0; + createdDeviceIcon: string | null = null; + proxies = [proxy]; + failNextCreateRules = false; + + async listProfiles() { + return this.profiles; + } + + async createProfile(name: string) { + this.profiles.push({ id: "profile-1", name }); + } + + async setDefaultBypass() { + this.setDefaultBypassCalls += 1; + } + + async listGroups() { + return this.groups; + } + + async createGroup(_profileId: string, name: string, proxyPk: string) { + const id = this.groups.length + 7; + this.groups.push({ id, name, action: 3, via: proxyPk }); + this.rules.set(id, []); + } + + async listRules(_profileId: string, folderId: number) { + return this.rules.get(folderId) ?? []; + } + + async createRules( + _profileId: string, + folderId: number, + proxyPk: string, + hostnames: readonly string[], + comment: string, + ) { + if (this.failNextCreateRules) { + this.failNextCreateRules = false; + throw new Error("simulated rule write failure"); + } + this.createRulesCalls += hostnames.length; + const current = this.rules.get(folderId) ?? []; + this.rules.set(folderId, [ + ...current, + ...hostnames.map((hostname) => ({ + hostname, + groupId: folderId, + action: 3, + via: proxyPk, + status: 1, + comment, + })), + ]); + } + + async updateRules( + _profileId: string, + folderId: number, + proxyPk: string, + hostnames: readonly string[], + comment: string, + ) { + this.updateRulesCalls += hostnames.length; + const selected = new Set(hostnames); + const moved: ControlDRule[] = []; + for (const [sourceId, rules] of this.rules) { + moved.push( + ...rules + .filter((rule) => selected.has(rule.hostname)) + .map((rule) => ({ + ...rule, + groupId: folderId, + action: 3, + via: proxyPk, + status: 1, + comment, + })), + ); + this.rules.set( + sourceId, + rules.filter((rule) => !selected.has(rule.hostname)), + ); + } + this.rules.set(folderId, [...(this.rules.get(folderId) ?? []), ...moved]); + } + + async deleteRule(_profileId: string, hostname: string) { + this.deleteRuleCalls += 1; + for (const [folderId, rules] of this.rules) { + this.rules.set( + folderId, + rules.filter((rule) => rule.hostname !== hostname), + ); + } + } + + async listProxies() { + return this.proxies; + } + + async listDevices() { + return this.devices; + } + + async listDeviceTypes() { + return ["browser-chrome", "browser-firefox", "browser-other"]; + } + + async createDevice(name: string, profileId: string, icon: string) { + this.createdDeviceIcon = icon; + const device = { + id: "device-1", + name: name.toLowerCase().replaceAll(" ", "-"), + profileId, + resolverDoh: "https://dns.controld.com/secret", + }; + this.devices.push(device); + return device; + } +} + +const asClient = (client: FakeClient): ControlDClient => + client as unknown as ControlDClient; + +beforeEach(() => { + vi.mocked(loadLocations).mockResolvedValue([ + { + id: "warsaw", + label: "Warsaw", + latitude: 52.23, + longitude: 21.01, + countryCode: "PL", + accuracy: 25, + noiseRadius: 50, + language: "pl", + languages: ["pl"], + timeZone: "Europe/Warsaw", + }, + ]); + vi.mocked(loadRules).mockResolvedValue([ + { pattern: "*example.com", enabled: true, locationId: "warsaw" }, + ]); +}); + +describe("Control D reconcile", () => { + it("creates isolated resources once and is idempotent", async () => { + const fake = new FakeClient(); + const initial = config(); + const firstPrepared = await prepareControlDSync(asClient(fake), initial); + + expect(firstPrepared.diff).toMatchObject({ + createProfile: true, + createEndpoint: true, + createFolders: 1, + addRules: 1, + }); + + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared: firstPrepared, + confirmApproximate: false, + repair: false, + }); + expect(applied).toMatchObject({ + profileId: "profile-1", + endpointId: "device-1", + status: "ready", + autoSyncEnabled: true, + }); + expect(fake.createRulesCalls).toBe(1); + expect(fake.createdDeviceIcon).toBe( + __PT_BROWSER_TARGET__ === "firefox" ? "browser-firefox" : "browser-other", + ); + fake.devices.push({ + id: "device-manual", + name: "Manually attached endpoint", + profileId: "profile-1", + resolverDoh: null, + }); + + const secondPrepared = await prepareControlDSync(asClient(fake), applied); + expect(secondPrepared.diff).toMatchObject({ + createProfile: false, + createEndpoint: false, + createFolders: 0, + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 1, + }); + + await applyControlDSync({ + client: asClient(fake), + config: applied, + prepared: secondPrepared, + confirmApproximate: true, + repair: false, + }); + expect(fake.createRulesCalls).toBe(1); + expect(fake.updateRulesCalls).toBe(0); + expect(fake.deleteRuleCalls).toBe(0); + expect(fake.devices).toHaveLength(2); + expect(fake.devices).toContainEqual( + expect.objectContaining({ + id: "device-manual", + profileId: "profile-1", + }), + ); + }); + + it("uses saved resource IDs when Control D normalizes their names", async () => { + const fake = new FakeClient(); + const initial = config(); + const firstPrepared = await prepareControlDSync(asClient(fake), initial); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared: firstPrepared, + confirmApproximate: false, + repair: false, + }); + + fake.profiles[0]!.name = "privacy-thing-profile"; + fake.groups[0]!.name = "privacy-thing-folder"; + + const secondPrepared = await prepareControlDSync(asClient(fake), applied); + await expect( + applyControlDSync({ + client: asClient(fake), + config: applied, + prepared: secondPrepared, + confirmApproximate: true, + repair: false, + }), + ).resolves.toMatchObject({ + profileId: "profile-1", + endpointId: "device-1", + status: "ready", + }); + }); + + it("accepts a saved endpoint when the managed profile is the second enforced profile", async () => { + const fake = new FakeClient(); + const initial = config(); + const prepared = await prepareControlDSync(asClient(fake), initial); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared, + confirmApproximate: false, + repair: false, + }); + fake.devices[0]!.profileId = "main-profile"; + fake.devices[0]!.enforcedProfileIds = ["main-profile", "profile-1"]; + + await expect(prepareControlDSync(asClient(fake), applied)).resolves.toMatchObject({ + diff: expect.objectContaining({ createEndpoint: false }), + }); + }); + + it("rejects a saved endpoint reassigned to another profile", async () => { + const fake = new FakeClient(); + const initial = config(); + const prepared = await prepareControlDSync(asClient(fake), initial); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared, + confirmApproximate: false, + repair: false, + }); + fake.devices[0]!.profileId = "foreign-profile"; + + await expect(prepareControlDSync(asClient(fake), applied)).rejects.toThrow( + "uses another profile", + ); + }); + + it("stops before writes when a managed rule drifts remotely", async () => { + const fake = new FakeClient(); + const initial = config(); + const prepared = await prepareControlDSync(asClient(fake), initial); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared, + confirmApproximate: false, + repair: false, + }); + fake.rules.set(7, [ + { + ...(fake.rules.get(7)?.[0] as ControlDRule), + comment: "manually changed", + }, + ]); + const drifted = await prepareControlDSync(asClient(fake), applied); + + await expect( + applyControlDSync({ + client: asClient(fake), + config: applied, + prepared: drifted, + confirmApproximate: true, + repair: false, + }), + ).rejects.toBeInstanceOf(ControlDConflictError); + expect(fake.updateRulesCalls).toBe(0); + expect(fake.deleteRuleCalls).toBe(0); + }); + + it("preflights every managed folder before writing any folder", async () => { + vi.mocked(loadLocations).mockResolvedValue([ + ...(await loadLocations()), + { + id: "berlin", + label: "Berlin", + latitude: 52.52, + longitude: 13.4, + countryCode: "DE", + accuracy: 25, + noiseRadius: 50, + language: "de", + languages: ["de"], + timeZone: "Europe/Berlin", + }, + ]); + vi.mocked(loadRules).mockResolvedValue([ + { pattern: "*example.com", enabled: true, locationId: "warsaw" }, + { pattern: "*example.de", enabled: true, locationId: "berlin" }, + ]); + + const fake = new FakeClient(); + fake.proxies = [proxy, berlinProxy]; + const initial = config(); + const prepared = await prepareControlDSync(asClient(fake), initial); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared, + confirmApproximate: false, + repair: false, + }); + const writesBeforeDrift = fake.createRulesCalls; + const defaultsBeforeDrift = fake.setDefaultBypassCalls; + const berlinFolder = applied.managedFolders.BER; + expect(berlinFolder).toBeDefined(); + fake.rules.set(berlinFolder!.folderId, [ + { + ...(fake.rules.get(berlinFolder!.folderId)?.[0] as ControlDRule), + comment: "manually changed", + }, + ]); + vi.mocked(loadRules).mockResolvedValue([ + { pattern: "*example.com", enabled: true, locationId: "warsaw" }, + { pattern: "*new-example.com", enabled: true, locationId: "warsaw" }, + { pattern: "*example.de", enabled: true, locationId: "berlin" }, + ]); + const drifted = await prepareControlDSync(asClient(fake), applied); + + await expect( + applyControlDSync({ + client: asClient(fake), + config: applied, + prepared: drifted, + confirmApproximate: true, + repair: false, + }), + ).rejects.toBeInstanceOf(ControlDConflictError); + expect(fake.createRulesCalls).toBe(writesBeforeDrift); + expect(fake.updateRulesCalls).toBe(0); + expect(fake.deleteRuleCalls).toBe(0); + expect(fake.setDefaultBypassCalls).toBe(defaultsBeforeDrift); + }); + + it("recovers its uniquely named resources after a partial first failure", async () => { + const fake = new FakeClient(); + const initial = config(); + const prepared = await prepareControlDSync(asClient(fake), initial); + fake.failNextCreateRules = true; + + await expect( + applyControlDSync({ + client: asClient(fake), + config: initial, + prepared, + confirmApproximate: false, + repair: false, + }), + ).rejects.toThrow("simulated rule write failure"); + expect(fake.profiles).toHaveLength(1); + expect(fake.devices).toHaveLength(1); + expect(fake.groups).toHaveLength(1); + + const retryPrepared = await prepareControlDSync(asClient(fake), initial); + const recovered = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared: retryPrepared, + confirmApproximate: false, + repair: false, + }); + + expect(recovered).toMatchObject({ + profileId: "profile-1", + endpointId: "device-1", + status: "ready", + }); + expect(fake.profiles).toHaveLength(1); + expect(fake.devices).toHaveLength(1); + expect(fake.groups).toHaveLength(1); + expect(fake.createRulesCalls).toBe(1); + }); +}); + +it("moves hostnames between proxy folders without deleting the destination and remains idempotent", async () => { + const fake = new FakeClient(); + fake.proxies = [proxy, berlinProxy]; + let current = config(); + current = await applyControlDSync({ + client: asClient(fake), + config: current, + prepared: await prepareControlDSync(asClient(fake), current), + confirmApproximate: false, + repair: false, + }); + const locations = await loadLocations(); + vi.mocked(loadLocations).mockResolvedValue( + locations.map((location) => ({ ...location, countryCode: "DE" })), + ); + current = { ...current, locationMappings: {} }; + const preview = await prepareControlDSync(asClient(fake), current); + expect(preview.diff.updateRules).toBe(1); + expect(preview.diff.deleteRules).toBe(0); + current = await applyControlDSync({ + client: asClient(fake), + config: current, + prepared: preview, + confirmApproximate: false, + repair: false, + }); + const destination = current.managedFolders.BER!.folderId; + expect(fake.rules.get(destination)).toMatchObject([ + { hostname: "*example.com", via: "BER" }, + ]); + expect(fake.rules.get(current.managedFolders.WAW!.folderId)).toEqual([]); + const next = await prepareControlDSync(asClient(fake), current); + expect(next.diff).toMatchObject({ + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 1, + }); + await applyControlDSync({ + client: asClient(fake), + config: current, + prepared: next, + confirmApproximate: false, + repair: false, + }); + expect(fake.deleteRuleCalls).toBe(0); +}); +it("rejects remote changes after preview before any write, including repair", async () => { + const fake = new FakeClient(); + const current = config(); + const prepared = await prepareControlDSync(asClient(fake), current); + fake.profiles.push({ id: "new-other", name: "Changed" }); + await expect( + applyControlDSync({ + client: asClient(fake), + config: current, + prepared, + confirmApproximate: true, + repair: true, + }), + ).rejects.toThrow("Remote setup changed"); + expect(fake.setDefaultBypassCalls).toBe(0); + expect(fake.createRulesCalls).toBe(0); +}); +it("does not approve a newly approximate mapping during unattended sync", async () => { + const fake = new FakeClient(); + vi.mocked(loadLocations).mockResolvedValue( + (await loadLocations()).map((location) => ({ ...location, countryCode: "DE" })), + ); + const current = config(); + const prepared = await prepareControlDSync(asClient(fake), current); + expect(prepared.diff.requiresApproximationConfirmation).toBe(true); + await expect( + applyControlDSync({ + client: asClient(fake), + config: current, + prepared, + confirmApproximate: false, + repair: false, + }), + ).rejects.toThrow("Confirm every approximate"); + expect(fake.profiles).toEqual([]); +}); + +it.each(["skipped", "approximate"] as const)( + "retains a saved %s mapping while its rules are disabled and reuses it on re-enable", + async (status) => { + const fake = new FakeClient(); + fake.proxies = [proxy, berlinProxy]; + const stored = { + locationId: "warsaw", + proxyPk: status === "skipped" ? null : "BER", + status, + confirmed: true, + }; + const initial = { ...config(), locationMappings: { warsaw: stored } }; + const rules = await loadRules(); + vi.mocked(loadRules).mockResolvedValue( + rules.map((rule) => ({ ...rule, enabled: false })), + ); + const disabled = await prepareControlDSync(asClient(fake), initial); + expect(disabled.compilation.mappings).toEqual({}); + const applied = await applyControlDSync({ + client: asClient(fake), + config: initial, + prepared: disabled, + confirmApproximate: false, + repair: false, + }); + expect(applied.locationMappings.warsaw).toEqual(stored); + vi.mocked(loadRules).mockResolvedValue(rules); + const reenabled = await prepareControlDSync(asClient(fake), applied); + expect(reenabled.compilation.mappings.warsaw).toMatchObject(stored); + expect(reenabled.compilation.rules).toHaveLength(status === "skipped" ? 0 : 1); + expect(reenabled.diff.requiresApproximationConfirmation).toBe(false); + const resynced = await applyControlDSync({ + client: asClient(fake), + config: applied, + prepared: reenabled, + confirmApproximate: false, + repair: false, + }); + expect(resynced.locationMappings.warsaw).toMatchObject(stored); + expect([...fake.rules.values()].flat()).toMatchObject( + status === "skipped" ? [] : [{ via: "BER", hostname: "*example.com" }], + ); + }, +); diff --git a/src/experimental/control-d/reconcile.ts b/src/experimental/control-d/reconcile.ts new file mode 100644 index 0000000..bfab0df --- /dev/null +++ b/src/experimental/control-d/reconcile.ts @@ -0,0 +1,605 @@ +/* eslint-disable max-lines-per-function, max-params, sonarjs/cognitive-complexity -- Reconcile keeps remote ownership checks in one module. */ +import type { ControlDClient } from "./client"; +import { + ControlDApiError, + deviceUsesAnotherProfile, + type ControlDRule, +} from "./client"; +import { compileControlDState, type ControlDCompilation } from "./compiler"; +import type { + ControlDDiff, + ControlDConfig, + ControlDManagedFolder, + ControlDProxyLocation, +} from "./contracts"; +import { + controlDEndpointName, + controlDFolderName, + controlDProfileName, + controlDRuleComment, +} from "./resource-names"; + +import { loadLocations } from "@/background/storage/locations"; +import { loadRules } from "@/background/storage/rules"; +import type { DomainRule, Location } from "@/shared/types"; + +export class ControlDConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "ControlDConflictError"; + } +} + +export type ControlDPreparedSync = { + inputHash: string; + remoteHash: string; + compilation: ControlDCompilation; + proxies: ControlDProxyLocation[]; + diff: ControlDDiff; +}; + +const resourceCode = (config: ControlDConfig): string => { + if (!config.resourceIdentity) { + throw new Error("Choose a new or existing Control D setup first."); + } + return config.resourceIdentity.code; +}; +const profileName = controlDProfileName; +const endpointName = (code: string): string => + controlDEndpointName(code, __PT_BROWSER_TARGET__); +const folderName = controlDFolderName; +const ruleComment = controlDRuleComment; +const normalizedResourceName = (name: string): string => + name + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/^-|-$/g, ""); +const resourceNameMatches = (actual: string, expected: string): boolean => + actual === expected || + normalizedResourceName(actual) === normalizedResourceName(expected); + +const canonicalRules = (rules: readonly ControlDRule[]): unknown[] => + [...rules] + .sort((left, right) => left.hostname.localeCompare(right.hostname)) + .map((rule) => ({ + hostname: rule.hostname, + groupId: rule.groupId, + action: rule.action, + via: rule.via, + status: rule.status, + comment: rule.comment, + })); + +export const hashControlDValue = async (value: unknown): Promise => { + const encoded = new TextEncoder().encode(JSON.stringify(value)); + const digest = await crypto.subtle.digest("SHA-256", encoded); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +}; + +export const hashControlDInputs = ( + config: ControlDConfig, + rules: readonly DomainRule[], + locations: readonly Location[], +) => + hashControlDValue({ + rules, + locations, + enabled: config.enabled, + connected: config.connected, + resourceIdentity: config.resourceIdentity, + profileId: config.profileId, + endpointId: config.endpointId, + managedFolders: config.managedFolders, + locationMappings: config.locationMappings, + lastSyncedHash: config.lastSyncedHash, + }); +const remoteSnapshot = async (client: ControlDClient, config: ControlDConfig) => { + const profiles = await client.listProfiles(); + const profile = config.profileId + ? profiles.find((profile) => profile.id === config.profileId) + : null; + const groups = profile ? await client.listGroups(profile.id) : []; + const rules = profile + ? await Promise.all( + Object.values(config.managedFolders).map(async (folder) => ({ + id: folder.folderId, + rules: canonicalRules(await client.listRules(profile.id, folder.folderId)), + })), + ) + : []; + const device = config.endpointId + ? (await client.listDevices()).find((device) => device.id === config.endpointId) + : null; + return hashControlDValue({ profiles, groups, rules, device }); +}; +const desiredByProxy = (compilation: ControlDCompilation): Map => { + const result = new Map(); + for (const rule of compilation.rules) { + const current = result.get(rule.proxyPk) ?? []; + current.push(rule.hostname); + result.set(rule.proxyPk, current); + } + for (const hostnames of result.values()) hostnames.sort(); + return result; +}; + +const compareFolderRules = ( + remoteRules: readonly ControlDRule[], + desiredHostnames: readonly string[], + folderId: number, + proxyPk: string, + expectedComment: string, +): Pick< + ControlDDiff, + "addRules" | "updateRules" | "deleteRules" | "unchangedRules" +> => { + const desired = new Set(desiredHostnames); + const remote = new Map(remoteRules.map((rule) => [rule.hostname, rule])); + let addRules = 0; + let updateRules = 0; + let unchangedRules = 0; + + for (const hostname of desired) { + const rule = remote.get(hostname); + if (!rule) { + addRules += 1; + continue; + } + if ( + (rule.groupId === null || rule.groupId === folderId) && + (rule.action === null || rule.action === 3) && + (rule.via === null || rule.via === proxyPk) && + (rule.status === null || rule.status === 1) && + (rule.comment === null || rule.comment === expectedComment) + ) { + unchangedRules += 1; + } else { + updateRules += 1; + } + } + + return { + addRules, + updateRules, + deleteRules: remoteRules.filter((rule) => !desired.has(rule.hostname)).length, + unchangedRules, + }; +}; + +const emptyCounts = () => ({ + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 0, +}); + +export const prepareControlDSync = async ( + client: ControlDClient, + config: ControlDConfig, +): Promise => { + resourceCode(config); + const remoteHash = await remoteSnapshot(client, config); + const [rules, locations, proxies, profiles] = await Promise.all([ + loadRules(), + loadLocations(), + client.listProxies(), + client.listProfiles(), + ]); + if (proxies.length === 0) { + throw new Error("Control D returned no usable proxy locations."); + } + + const compilation = compileControlDState({ + rules, + locations, + proxies, + storedMappings: config.locationMappings, + }); + const desired = desiredByProxy(compilation); + const counts = emptyCounts(); + let createFolders = desired.size; + const knownProfile = config.profileId + ? profiles.find((profile) => profile.id === config.profileId) + : undefined; + + if (config.profileId && !knownProfile) { + throw new ControlDConflictError("The managed Control D profile is missing."); + } + if (knownProfile) { + const groups = await client.listGroups(knownProfile.id); + createFolders = 0; + const managedRules: ControlDRule[] = []; + for (const managed of Object.values(config.managedFolders)) { + const group = groups.find((candidate) => candidate.id === managed.folderId); + if (!group) + throw new ControlDConflictError( + `Managed folder ${managed.folderId} is missing.`, + ); + managedRules.push(...(await client.listRules(knownProfile.id, group.id))); + } + const desiredHosts = new Set(compilation.rules.map((rule) => rule.hostname)); + counts.deleteRules = managedRules.filter( + (rule) => !desiredHosts.has(rule.hostname), + ).length; + for (const [proxyPk, hostnames] of desired) { + const managed = config.managedFolders[proxyPk]; + const group = managed + ? groups.find((candidate) => candidate.id === managed.folderId) + : undefined; + if (!group) { + if (managed) { + throw new ControlDConflictError( + `Managed folder ${managed.folderId} is missing.`, + ); + } + createFolders += 1; + counts.addRules += hostnames.filter( + (host) => !managedRules.some((rule) => rule.hostname === host), + ).length; + counts.updateRules += hostnames.filter((host) => + managedRules.some((rule) => rule.hostname === host), + ).length; + continue; + } + if ( + (group.action !== null && group.action !== 3) || + (group.via !== null && group.via !== proxyPk) + ) { + throw new ControlDConflictError(`Managed folder ${group.id} was changed.`); + } + const remoteRules = managedRules.filter((rule) => + hostnames.includes(rule.hostname), + ); + const folderCounts = compareFolderRules( + remoteRules, + hostnames, + group.id, + proxyPk, + ruleComment(resourceCode(config)), + ); + counts.addRules += folderCounts.addRules; + counts.updateRules += folderCounts.updateRules; + + counts.unchangedRules += folderCounts.unchangedRules; + } + + for (const [proxyPk, managed] of Object.entries(config.managedFolders)) { + if (desired.has(proxyPk)) continue; + const group = groups.find((candidate) => candidate.id === managed.folderId); + if (!group) { + throw new ControlDConflictError( + `Managed folder ${managed.folderId} is missing.`, + ); + } + } + } else { + counts.addRules = compilation.rules.length; + } + + const devices = knownProfile ? await client.listDevices() : []; + const knownEndpoint = config.endpointId + ? devices.find((device) => device.id === config.endpointId) + : undefined; + if (config.endpointId && !knownEndpoint) { + throw new ControlDConflictError("The managed Control D endpoint is missing."); + } + if (knownEndpoint && deviceUsesAnotherProfile(knownEndpoint, knownProfile?.id)) { + throw new ControlDConflictError( + "The managed Control D endpoint uses another profile.", + ); + } + + if (remoteHash !== (await remoteSnapshot(client, config))) + throw new ControlDConflictError( + "Remote setup changed while preparing the preview. Refresh it.", + ); + return { + inputHash: await hashControlDInputs(config, rules, locations), + remoteHash, + compilation, + proxies, + diff: { + createProfile: !knownProfile, + createEndpoint: !knownEndpoint, + createFolders, + ...counts, + warnings: compilation.warnings, + mappings: Object.values(compilation.mappings), + requiresApproximationConfirmation: Object.values(compilation.mappings).some( + (mapping) => mapping.status === "approximate" && !mapping.confirmed, + ), + }, + }; +}; + +const requireUniqueProfile = async ( + client: ControlDClient, + name: string, +): Promise => { + const matches = (await client.listProfiles()).filter((profile) => + resourceNameMatches(profile.name, name), + ); + if (matches.length !== 1 || !matches[0]) { + throw new Error("Could not uniquely identify the managed Control D profile."); + } + return matches[0].id; +}; + +const ensureProfile = async ( + client: ControlDClient, + config: ControlDConfig, +): Promise => { + const name = profileName(resourceCode(config)); + const profiles = await client.listProfiles(); + if (config.profileId) { + const managed = profiles.find((profile) => profile.id === config.profileId); + if (!managed) throw new ControlDConflictError("The managed profile is missing."); + return managed.id; + } + const existing = profiles.filter((profile) => + resourceNameMatches(profile.name, name), + ); + if (existing.length > 1) { + throw new ControlDConflictError("More than one managed profile has the same name."); + } + if (existing[0]) return existing[0].id; + await client.createProfile(name); + return requireUniqueProfile(client, name); +}; + +const ensureEndpoint = async ( + client: ControlDClient, + config: ControlDConfig, + profileId: string, +): Promise<{ id: string; resolverDoh: string | null }> => { + if (config.endpointId) { + const existing = (await client.listDevices()).find( + (device) => device.id === config.endpointId, + ); + if (!existing) throw new ControlDConflictError("The managed endpoint is missing."); + if (deviceUsesAnotherProfile(existing, profileId)) { + throw new ControlDConflictError("The managed endpoint uses another profile."); + } + return { id: existing.id, resolverDoh: existing.resolverDoh }; + } + + const name = endpointName(resourceCode(config)); + const existing = (await client.listDevices()).filter((device) => + resourceNameMatches(device.name, name), + ); + if (existing.length > 1) { + throw new ControlDConflictError( + "More than one managed endpoint has the same name.", + ); + } + if (existing[0]) { + if (deviceUsesAnotherProfile(existing[0], profileId)) { + throw new ControlDConflictError("The recoverable endpoint uses another profile."); + } + return { id: existing[0].id, resolverDoh: existing[0].resolverDoh }; + } + + const types = await client.listDeviceTypes(); + const preferredTypes = + __PT_BROWSER_TARGET__ === "firefox" + ? ["browser-firefox", "browser-other"] + : ["browser-other", "browser-chrome", "browser-edge", "browser-brave"]; + const icon = + preferredTypes.find((candidate) => types.includes(candidate)) ?? + types.find((type) => type.startsWith("browser-")); + if (!icon) throw new Error("Control D returned no supported browser endpoint type."); + const created = await client.createDevice(name, profileId, icon); + if (created) return { id: created.id, resolverDoh: created.resolverDoh }; + const recovered = (await client.listDevices()).filter((device) => + resourceNameMatches(device.name, name), + ); + if (recovered.length !== 1 || !recovered[0]) { + throw new Error("Could not identify the newly created Control D endpoint."); + } + return { id: recovered[0].id, resolverDoh: recovered[0].resolverDoh }; +}; + +const assertNoDrift = async ( + remoteRules: readonly ControlDRule[], + managed: ControlDManagedFolder | undefined, + repair: boolean, +): Promise => { + if (!managed?.remoteHash || repair) return; + const remoteHash = await hashControlDValue(canonicalRules(remoteRules)); + if (remoteHash !== managed.remoteHash) { + throw new ControlDConflictError( + "Managed Control D rules changed remotely. Review the diff and use repair explicitly.", + ); + } +}; + +export const applyControlDSync = async ({ + client, + config, + prepared, + confirmApproximate, + repair, +}: { + client: ControlDClient; + config: ControlDConfig; + prepared: ControlDPreparedSync; + confirmApproximate: boolean; + repair: boolean; +}): Promise => { + if (prepared.diff.requiresApproximationConfirmation && !confirmApproximate) { + throw new Error("Confirm every approximate location mapping before applying."); + } + + if (prepared.remoteHash !== (await remoteSnapshot(client, config))) + throw new ControlDConflictError( + "Remote setup changed. Refresh and review the preview.", + ); + const confirmedMappings = Object.fromEntries( + Object.values(prepared.compilation.mappings).map((mapping) => [ + mapping.locationId, + mapping.status === "approximate" && confirmApproximate + ? { ...mapping, confirmed: true } + : mapping, + ]), + ); + const nextConfig: ControlDConfig = { + ...config, + locationMappings: { ...config.locationMappings, ...confirmedMappings }, + }; + const profileId = await ensureProfile(client, nextConfig); + const desired = desiredByProxy(prepared.compilation); + const groups = await client.listGroups(profileId); + const preflightRules = new Map(); + for (const [proxyPk, managed] of Object.entries(nextConfig.managedFolders)) { + const group = groups.find((candidate) => candidate.id === managed.folderId); + if (!group) { + throw new ControlDConflictError(`Managed folder ${managed.folderId} is missing.`); + } + if ( + (group.action !== null && group.action !== 3) || + (group.via !== null && group.via !== proxyPk) + ) { + throw new ControlDConflictError(`Managed folder ${group.id} was changed.`); + } + const remoteRules = await client.listRules(profileId, group.id); + await assertNoDrift(remoteRules, managed, repair); + if ( + remoteRules.some((rule) => rule.groupId !== null && rule.groupId !== group.id) + ) { + throw new ControlDConflictError( + `Folder ${group.id} returned rules owned elsewhere.`, + ); + } + preflightRules.set(group.id, remoteRules); + } + + const endpoint = await ensureEndpoint(client, nextConfig, profileId); + await client.setDefaultBypass(profileId); + const managedFolders: Record = { + ...nextConfig.managedFolders, + }; + + const desiredHosts = new Set(prepared.compilation.rules.map((rule) => rule.hostname)); + const allManagedRules = [...preflightRules.values()].flat(); + const existingHosts = new Set(allManagedRules.map((rule) => rule.hostname)); + for (const rule of allManagedRules) { + if (!desiredHosts.has(rule.hostname)) + await client.deleteRule(profileId, rule.hostname); + } + for (const [proxyPk, hostnames] of desired) { + const known = managedFolders[proxyPk]; + let group = known + ? groups.find((candidate) => candidate.id === known.folderId) + : undefined; + if (!group) { + if (known) { + throw new ControlDConflictError(`Managed folder ${known.folderId} is missing.`); + } + const name = folderName(resourceCode(nextConfig), proxyPk); + const matches = groups.filter((candidate) => + resourceNameMatches(candidate.name, name), + ); + if (matches.length > 1) { + throw new ControlDConflictError( + `More than one managed folder exists for ${proxyPk}.`, + ); + } + group = matches[0]; + if (!group) { + await client.createGroup(profileId, name, proxyPk); + const refreshed = (await client.listGroups(profileId)).filter((candidate) => + resourceNameMatches(candidate.name, name), + ); + if (refreshed.length !== 1 || !refreshed[0]) { + throw new Error(`Could not identify the managed folder for ${proxyPk}.`); + } + group = refreshed[0]; + } + } + if ( + (group.action !== null && group.action !== 3) || + (group.via !== null && group.via !== proxyPk) + ) { + throw new ControlDConflictError(`Managed folder ${group.id} was changed.`); + } + + const remoteRules = + preflightRules.get(group.id) ?? (await client.listRules(profileId, group.id)); + if ( + remoteRules.some((rule) => rule.groupId !== null && rule.groupId !== group.id) + ) { + throw new ControlDConflictError( + `Folder ${group.id} returned rules owned elsewhere.`, + ); + } + + const destinationHosts = new Set(remoteRules.map((rule) => rule.hostname)); + const toCreate = hostnames.filter( + (hostname) => !existingHosts.has(hostname) && !destinationHosts.has(hostname), + ); + const toUpdate = hostnames.filter((hostname) => { + const rule = + allManagedRules.find((rule) => rule.hostname === hostname) ?? + remoteRules.find((rule) => rule.hostname === hostname); + return ( + rule && + (rule.groupId !== group.id || + rule.action !== 3 || + rule.via !== proxyPk || + rule.status !== 1 || + rule.comment !== ruleComment(resourceCode(nextConfig))) + ); + }); + + await client.createRules( + profileId, + group.id, + proxyPk, + toCreate, + ruleComment(resourceCode(nextConfig)), + ); + await client.updateRules( + profileId, + group.id, + proxyPk, + toUpdate, + ruleComment(resourceCode(nextConfig)), + ); + + managedFolders[proxyPk] = { proxyPk, folderId: group.id, remoteHash: "" }; + } + // Hash final state after moves; no later source-folder cleanup can delete a destination. + for (const [proxyPk, managed] of Object.entries(managedFolders)) { + managedFolders[proxyPk] = { + ...managed, + remoteHash: await hashControlDValue( + canonicalRules(await client.listRules(profileId, managed.folderId)), + ), + }; + } + + return { + ...nextConfig, + connected: true, + autoSyncEnabled: true, + status: "ready", + profileId, + endpointId: endpoint.id, + resolverDoh: endpoint.resolverDoh ?? nextConfig.resolverDoh, + dnsVerification: + nextConfig.dnsVerification?.endpointId === endpoint.id + ? nextConfig.dnsVerification + : null, + managedFolders, + lastSyncedHash: await hashControlDValue(prepared.compilation.rules), + lastAttemptAt: new Date().toISOString(), + lastSuccessAt: new Date().toISOString(), + lastError: null, + }; +}; + +export const isControlDAuthError = (error: unknown): boolean => + error instanceof ControlDApiError && (error.status === 401 || error.status === 403); diff --git a/src/experimental/control-d/recovery.test.ts b/src/experimental/control-d/recovery.test.ts new file mode 100644 index 0000000..d3b3500 --- /dev/null +++ b/src/experimental/control-d/recovery.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import type { + ControlDClient, + ControlDDevice, + ControlDGroup, + ControlDRule, +} from "./client"; +import type { ControlDConfig } from "./contracts"; +import { adoptRecoverySet, discoverRecoverySets } from "./recovery"; + +const config = (): ControlDConfig => ({ + version: 2, + enabled: true, + connected: true, + autoSyncEnabled: false, + status: "ready", + resourceIdentity: null, + profileId: null, + endpointId: null, + resolverDoh: null, + dnsVerification: null, + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: null, + lastAttemptAt: null, + lastSuccessAt: null, + lastError: null, +}); + +class FakeClient { + profiles = [ + { id: "managed", name: "Privacy Thing ABCDE-FGHJK" }, + { id: "legacy", name: "Privacy Thing 123e4567e89b12d3" }, + { id: "foreign", name: "Personal profile" }, + ]; + devices: ControlDDevice[] = [ + { + id: "endpoint", + name: "PT Browser ABCDE-FGHJK", + profileId: "managed", + resolverDoh: "https://dns.controld.com/secret", + }, + ]; + groups: ControlDGroup[] = [ + { + id: 7, + name: "PT ABCDE-FGHJK WAW", + action: 3, + via: "WAW", + }, + ]; + rules: ControlDRule[] = [ + { + hostname: "example.com", + groupId: 7, + action: 3, + via: "WAW", + status: 1, + comment: "PT ABCDE-FGHJK", + }, + ]; + writes = 0; + + async listProfiles() { + return this.profiles; + } + async listDevices() { + return this.devices; + } + async listGroups(profileId: string) { + return profileId === "managed" ? this.groups : []; + } + async listRules() { + return this.rules; + } + async createProfile() { + this.writes += 1; + } + async createDevice() { + this.writes += 1; + return null; + } + async createGroup() { + this.writes += 1; + } + async createRules() { + this.writes += 1; + } + async updateRules() { + this.writes += 1; + } + async deleteRule() { + this.writes += 1; + } +} + +const asClient = (client: FakeClient): ControlDClient => + client as unknown as ControlDClient; + +describe("Control D recovery", () => { + it("discovers only exact v2 Privacy Thing resources without writes", async () => { + const client = new FakeClient(); + await expect(discoverRecoverySets(asClient(client))).resolves.toEqual([ + expect.objectContaining({ + code: "ABCDE-FGHJK", + profileId: "managed", + endpointId: "endpoint", + compatibility: "ready", + }), + ]); + expect(client.writes).toBe(0); + }); + + it("recovers a profile without an endpoint", async () => { + const client = new FakeClient(); + client.devices = []; + await expect(discoverRecoverySets(asClient(client))).resolves.toEqual([ + expect.objectContaining({ compatibility: "profile-only", endpointId: null }), + ]); + }); + + it("marks duplicate managed endpoints as ambiguous", async () => { + const client = new FakeClient(); + client.devices.push({ ...client.devices[0]!, id: "endpoint-2" }); + await expect(discoverRecoverySets(asClient(client))).resolves.toEqual([ + expect.objectContaining({ compatibility: "ambiguous", endpointId: null }), + ]); + }); + + it("adopts explicitly and rebuilds ownership hashes without remote writes", async () => { + const client = new FakeClient(); + const adopted = await adoptRecoverySet({ + client: asClient(client), + config: config(), + profileId: "managed", + endpointId: "endpoint", + code: "ABCDE-FGHJK", + }); + expect(adopted).toMatchObject({ + resourceIdentity: { code: "ABCDE-FGHJK" }, + profileId: "managed", + endpointId: "endpoint", + resolverDoh: "https://dns.controld.com/secret", + autoSyncEnabled: false, + lastSyncedHash: null, + managedFolders: { WAW: { proxyPk: "WAW", folderId: 7 } }, + }); + expect(client.writes).toBe(0); + }); +}); diff --git a/src/experimental/control-d/recovery.ts b/src/experimental/control-d/recovery.ts new file mode 100644 index 0000000..354e5e2 --- /dev/null +++ b/src/experimental/control-d/recovery.ts @@ -0,0 +1,161 @@ +import { deviceProfileIds, type ControlDClient, type ControlDRule } from "./client"; +import type { + ControlDConfig, + ControlDManagedFolder, + ControlDRecoveryCandidate, +} from "./contracts"; +import { hashControlDValue } from "./reconcile"; +import { + isControlDEndpointName, + isControlDFolderName, + parseControlDProfileCode, +} from "./resource-names"; + +const canonicalRules = (rules: readonly ControlDRule[]): unknown[] => + [...rules] + .sort((left, right) => left.hostname.localeCompare(right.hostname)) + .map((rule) => ({ + hostname: rule.hostname, + groupId: rule.groupId, + action: rule.action, + via: rule.via, + status: rule.status, + comment: rule.comment, + })); + +export const discoverRecoverySets = async ( + client: ControlDClient, +): Promise => { + const [profiles, devices] = await Promise.all([ + client.listProfiles(), + client.listDevices(), + ]); + const recognizedProfiles = profiles.flatMap((profile) => { + const code = parseControlDProfileCode(profile.name); + return code ? [{ profile, code }] : []; + }); + const candidates = await Promise.all( + recognizedProfiles.map(async ({ profile, code }) => { + const groups = await client.listGroups(profile.id); + const namedGroups = groups.filter((group) => + group.name.startsWith(`PT ${code} `), + ); + const managedGroups = groups.filter( + (group) => + group.via !== null && isControlDFolderName(group.name, code, group.via), + ); + const namedEndpoints = devices.filter((device) => + isControlDEndpointName(device.name, code), + ); + const endpoints = namedEndpoints.filter((device) => + deviceProfileIds(device).includes(profile.id), + ); + const routes = new Set(); + let duplicateRoute = false; + for (const group of managedGroups) { + const route = group.via ?? ""; + if (routes.has(route)) duplicateRoute = true; + routes.add(route); + } + const invalidGroup = namedGroups.some( + (group) => + !group.via || + !isControlDFolderName(group.name, code, group.via) || + (group.action !== null && group.action !== 3), + ); + const ambiguous = + endpoints.length > 1 || + duplicateRoute || + invalidGroup || + namedEndpoints.length !== endpoints.length || + recognizedProfiles.filter((item) => item.code === code).length > 1; + let compatibility: ControlDRecoveryCandidate["compatibility"] = "ready"; + if (ambiguous) compatibility = "ambiguous"; + else if (endpoints.length === 0) compatibility = "profile-only"; + return { + code, + profileId: profile.id, + profileName: profile.name, + endpointId: endpoints.length === 1 ? (endpoints[0]?.id ?? null) : null, + endpointName: endpoints.length === 1 ? (endpoints[0]?.name ?? null) : null, + managedFolderCount: managedGroups.length, + compatibility, + issue: ambiguous + ? "This setup has duplicate managed endpoints or route folders." + : null, + }; + }), + ); + return candidates.sort((left, right) => + left.profileName.localeCompare(right.profileName), + ); +}; + +export const adoptRecoverySet = async ({ + client, + config, + profileId, + endpointId, + code, +}: { + client: ControlDClient; + config: ControlDConfig; + profileId: string; + endpointId: string | null; + code: string; +}): Promise => { + const candidates = await discoverRecoverySets(client); + const candidate = candidates.find( + (item) => + item.profileId === profileId && + item.endpointId === endpointId && + item.code === code, + ); + if (!candidate || candidate.compatibility === "ambiguous") { + throw new Error("The selected Privacy Thing setup is no longer recoverable."); + } + const [groups, devices] = await Promise.all([ + client.listGroups(profileId), + client.listDevices(), + ]); + const endpoint = endpointId + ? devices.find( + (device) => + device.id === endpointId && + deviceProfileIds(device).includes(profileId) && + isControlDEndpointName(device.name, code), + ) + : undefined; + if (endpointId && !endpoint) { + throw new Error("The selected Privacy Thing endpoint is no longer available."); + } + const managedFolders: Record = {}; + for (const group of groups) { + if (!group.via || !isControlDFolderName(group.name, code, group.via)) continue; + if (managedFolders[group.via]) { + throw new Error("The selected setup has duplicate managed route folders."); + } + const rules = await client.listRules(profileId, group.id); + managedFolders[group.via] = { + proxyPk: group.via, + folderId: group.id, + remoteHash: await hashControlDValue(canonicalRules(rules)), + }; + } + return { + ...config, + resourceIdentity: { code }, + profileId, + endpointId, + resolverDoh: endpoint?.resolverDoh ?? null, + dnsVerification: null, + managedFolders, + locationMappings: {}, + autoSyncEnabled: false, + status: "ready", + lastSyncedHash: null, + lastAttemptAt: new Date().toISOString(), + lastSuccessAt: null, + lastError: null, + }; +}; diff --git a/src/experimental/control-d/redaction.test.ts b/src/experimental/control-d/redaction.test.ts new file mode 100644 index 0000000..b42060d --- /dev/null +++ b/src/experimental/control-d/redaction.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { redactControlDLogValue } from "./redaction"; + +describe("redactControlDLogValue", () => { + it("redacts secrets, authorization and resolver URLs recursively", () => { + const secret = "write-token-123"; + const redacted = redactControlDLogValue( + { + apiKey: secret, + header: `Bearer ${secret}`, + resolver: "failed for https://dns.controld.com/secret-resolver-id", + nested: [`failed for ${secret}`], + status: 429, + }, + secret, + ); + + expect(redacted).toEqual({ + apiKey: "[REDACTED]", + header: "Bearer [REDACTED]", + resolver: "failed for [CONTROL_D_URL_REDACTED]", + nested: ["failed for [REDACTED]"], + status: 429, + }); + }); +}); diff --git a/src/experimental/control-d/redaction.ts b/src/experimental/control-d/redaction.ts new file mode 100644 index 0000000..feaec50 --- /dev/null +++ b/src/experimental/control-d/redaction.ts @@ -0,0 +1,25 @@ +const SECRET_FIELD = /authorization|api[-_ ]?key|token|secret/i; +const BEARER = /Bearer\s+\S+/gi; +const CONTROL_D_URL = /https:\/\/[^\s"'<>]*controld\.com\/[^\s"'<>]*/gi; + +const redactString = (value: string, apiKey?: string): string => { + let redacted = value.replace(CONTROL_D_URL, "[CONTROL_D_URL_REDACTED]"); + redacted = redacted.replace(BEARER, "Bearer [REDACTED]"); + if (apiKey) redacted = redacted.split(apiKey).join("[REDACTED]"); + return redacted; +}; + +export const redactControlDLogValue = (value: unknown, apiKey?: string): unknown => { + if (typeof value === "string") return redactString(value, apiKey); + if (Array.isArray(value)) { + return value.map((entry) => redactControlDLogValue(entry, apiKey)); + } + if (!value || typeof value !== "object") return value; + + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + SECRET_FIELD.test(key) ? "[REDACTED]" : redactControlDLogValue(entry, apiKey), + ]), + ); +}; diff --git a/src/experimental/control-d/resource-names.test.ts b/src/experimental/control-d/resource-names.test.ts new file mode 100644 index 0000000..a036c3e --- /dev/null +++ b/src/experimental/control-d/resource-names.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + controlDEndpointName, + controlDFolderName, + controlDProfileName, + controlDRuleComment, + generateResourceCode, + parseControlDProfileCode, +} from "./resource-names"; + +const CODE = "ABCDE-FGHJK"; + +describe("Control D resource names", () => { + it("uses readable names within the live API limit", () => { + expect(controlDProfileName(CODE)).toBe("Privacy Thing ABCDE-FGHJK"); + expect(controlDEndpointName(CODE, "chromium")).toBe("PT Browser ABCDE-FGHJK"); + expect(controlDEndpointName(CODE, "firefox")).toBe("PT Firefox ABCDE-FGHJK"); + expect(controlDFolderName(CODE, "WAW")).toBe("PT ABCDE-FGHJK WAW"); + expect(controlDRuleComment(CODE)).toBe("PT ABCDE-FGHJK"); + expect( + controlDFolderName(CODE, "an-arbitrarily-long-proxy-primary-key").length, + ).toBeLessThanOrEqual(32); + }); + + it("generates a hardware-independent Crockford Base32 code", () => { + const code = generateResourceCode(); + expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/); + }); + + it("recovers only the new exact profile format", () => { + expect(parseControlDProfileCode("Privacy Thing ABCDE-FGHJK")).toBe(CODE); + expect(parseControlDProfileCode("Privacy Thing 123e4567e89b12d3")).toBeNull(); + expect(parseControlDProfileCode("Renamed Privacy Thing ABCDE-FGHJK")).toBeNull(); + }); +}); diff --git a/src/experimental/control-d/resource-names.ts b/src/experimental/control-d/resource-names.ts new file mode 100644 index 0000000..ce5ad89 --- /dev/null +++ b/src/experimental/control-d/resource-names.ts @@ -0,0 +1,91 @@ +const MAX_RESOURCE_NAME_LENGTH = 32; +const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +const CODE_PATTERN = /^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/; + +const fnv1a = (value: string): number => { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(value)) { + hash ^= byte; + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +}; + +const encodeBase32 = (value: number, length: number): string => { + let remaining = value >>> 0; + let result = ""; + for (let index = 0; index < length; index += 1) { + result = CODE_ALPHABET[remaining & 31] + result; + remaining >>>= 5; + } + return result; +}; + +export const isControlDResourceCode = (value: string): boolean => + CODE_PATTERN.test(value); + +export const generateResourceCode = (): string => { + const bytes = crypto.getRandomValues(new Uint8Array(7)); + let bits = 0n; + for (const byte of bytes) bits = (bits << 8n) | BigInt(byte); + bits &= (1n << 50n) - 1n; + let compact = ""; + for (let index = 0; index < 10; index += 1) { + compact = CODE_ALPHABET[Number(bits & 31n)] + compact; + bits >>= 5n; + } + return `${compact.slice(0, 5)}-${compact.slice(5)}`; +}; + +const assertCode = (code: string): string => { + if (!isControlDResourceCode(code)) + throw new Error("Invalid Control D resource code."); + return code; +}; + +const assertValidName = (name: string): string => { + if (name.length > MAX_RESOURCE_NAME_LENGTH) { + throw new Error("Generated Control D resource name exceeds 32 characters."); + } + return name; +}; + +const routeToken = (proxyPk: string): string => { + const normalized = proxyPk + .trim() + .toUpperCase() + .replaceAll(/[^0-9A-Z]+/g, "-") + .replaceAll(/^-|-$/g, ""); + if (normalized && normalized.length <= 17) return normalized; + const prefix = (normalized || "ROUTE").slice(0, 12); + return `${prefix}-${encodeBase32(fnv1a(proxyPk), 4)}`; +}; + +export const controlDProfileName = (code: string): string => + assertValidName(`Privacy Thing ${assertCode(code)}`); + +export const controlDEndpointName = (code: string, browserTarget: string): string => + assertValidName( + `PT ${browserTarget === "firefox" ? "Firefox" : "Browser"} ${assertCode(code)}`, + ); + +export const controlDFolderName = (code: string, proxyPk: string): string => + assertValidName(`PT ${assertCode(code)} ${routeToken(proxyPk)}`); + +export const controlDRuleComment = (code: string): string => `PT ${assertCode(code)}`; + +export const parseControlDProfileCode = (name: string): string | null => { + const match = /^Privacy Thing ([0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5})$/.exec( + name, + ); + return match?.[1] ?? null; +}; + +export const isControlDEndpointName = (name: string, code: string): boolean => + name === `PT Browser ${code}` || name === `PT Firefox ${code}`; + +export const isControlDFolderName = ( + name: string, + code: string, + proxyPk: string, +): boolean => name === controlDFolderName(code, proxyPk); diff --git a/src/experimental/control-d/storage.test.ts b/src/experimental/control-d/storage.test.ts new file mode 100644 index 0000000..0307d61 --- /dev/null +++ b/src/experimental/control-d/storage.test.ts @@ -0,0 +1,91 @@ +import { IDBFactory } from "fake-indexeddb"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + CONTROL_D_STORE_KEYS, + forgetControlDApiKey, + loadControlDApiKey, + loadControlDConfig, + saveControlDApiKey, +} from "./storage"; + +import { EXTENSION_STORAGE_KEYS } from "@/shared/extension-contract"; + +const state: Record = {}; + +beforeEach(() => { + vi.stubGlobal("indexedDB", new IDBFactory()); + for (const key of Object.keys(state)) Reflect.deleteProperty(state, key); + vi.stubGlobal("chrome", { + storage: { + local: { + get: vi.fn(async (key: string) => (key in state ? { [key]: state[key] } : {})), + set: vi.fn(async (value: Record) => + Object.assign(state, value), + ), + remove: vi.fn(async (key: string) => Reflect.deleteProperty(state, key)), + }, + }, + }); +}); + +describe("Control D storage", () => { + it("uses a private namespace disjoint from production settings", () => { + expect(CONTROL_D_STORE_KEYS).toEqual([ + "pt.experimental.control-d.v2.config", + "pt.experimental.control-d.v2.api-key", + ]); + expect( + CONTROL_D_STORE_KEYS.some((key) => + Object.values(EXTENSION_STORAGE_KEYS).includes(key as never), + ), + ).toBe(false); + }); + + it("creates v2 without reading or changing old experimental keys", async () => { + const oldValue = { version: 1, instanceId: "old-instance" }; + state["pt.experimental.control-d.config.v1"] = oldValue; + const config = await loadControlDConfig(); + expect(config).toMatchObject({ + version: 2, + enabled: false, + connected: false, + resourceIdentity: null, + profileId: null, + endpointId: null, + }); + expect(state["pt.experimental.control-d.config.v1"]).toBe(oldValue); + }); + + it("replaces only malformed v2 config", async () => { + state[CONTROL_D_STORE_KEYS[0]] = { version: 2, profileId: "foreign" }; + state["unrelated.production.key"] = { keep: true }; + expect((await loadControlDConfig()).resourceIdentity).toBeNull(); + expect(state["unrelated.production.key"]).toEqual({ keep: true }); + }); + + it("stores and forgets only the private API key", async () => { + await saveControlDApiKey(" secret "); + expect(await loadControlDApiKey()).toBe("secret"); + expect(state[CONTROL_D_STORE_KEYS[1]]).toBeUndefined(); + await forgetControlDApiKey(); + expect(await loadControlDApiKey()).toBeNull(); + }); + + it("migrates the legacy key and removes the content-script-readable copy", async () => { + state[CONTROL_D_STORE_KEYS[1]] = " legacy-secret "; + const restrict = vi.fn(async () => undefined); + Object.assign(chrome.storage.local, { setAccessLevel: restrict }); + expect(await loadControlDApiKey()).toBe("legacy-secret"); + expect(restrict).toHaveBeenCalledWith({ accessLevel: "TRUSTED_CONTEXTS" }); + expect(state[CONTROL_D_STORE_KEYS[1]]).toBeUndefined(); + expect(await loadControlDApiKey()).toBe("legacy-secret"); + }); + + it("does not replace the current private key with a stale legacy copy", async () => { + await saveControlDApiKey("current-secret"); + state[CONTROL_D_STORE_KEYS[1]] = "stale-secret"; + expect(await loadControlDApiKey()).toBe("current-secret"); + expect(state[CONTROL_D_STORE_KEYS[1]]).toBeUndefined(); + }); +}); diff --git a/src/experimental/control-d/storage.ts b/src/experimental/control-d/storage.ts new file mode 100644 index 0000000..8673cde --- /dev/null +++ b/src/experimental/control-d/storage.ts @@ -0,0 +1,124 @@ +import { + deletePrivateApiKey, + readPrivateApiKey, + writePrivateApiKey, +} from "./api-key-store"; + +import { + controlDConfigSchema, + type ControlDConfig, + type ControlDPublicState, +} from "@/experimental/control-d/contracts"; + +const CONFIG_KEY = "pt.experimental.control-d.v2.config"; +const API_KEY = "pt.experimental.control-d.v2.api-key"; + +const createDefaultConfig = (): ControlDConfig => ({ + version: 2, + enabled: false, + connected: false, + autoSyncEnabled: false, + status: "disconnected", + resourceIdentity: null, + profileId: null, + endpointId: null, + resolverDoh: null, + dnsVerification: null, + managedFolders: {}, + locationMappings: {}, + lastSyncedHash: null, + lastAttemptAt: null, + lastSuccessAt: null, + lastError: null, +}); + +export const loadControlDConfig = async (): Promise => { + const stored = await chrome.storage.local.get(CONFIG_KEY); + const parsed = controlDConfigSchema.safeParse(stored[CONFIG_KEY]); + if (parsed.success) { + const locationMappings = Object.fromEntries( + Object.entries(parsed.data.locationMappings).map(([locationId, mapping]) => [ + locationId, + { + locationId: mapping.locationId, + ...(mapping.locationLabel === undefined + ? {} + : { locationLabel: mapping.locationLabel }), + ...(mapping.ruleCount === undefined ? {} : { ruleCount: mapping.ruleCount }), + proxyPk: mapping.proxyPk, + status: mapping.status, + confirmed: mapping.confirmed, + }, + ]), + ); + return { ...parsed.data, locationMappings }; + } + const config = createDefaultConfig(); + await chrome.storage.local.set({ [CONFIG_KEY]: config }); + return config; +}; + +export const saveControlDConfig = async (config: ControlDConfig): Promise => { + await chrome.storage.local.set({ [CONFIG_KEY]: controlDConfigSchema.parse(config) }); +}; + +const protectLegacyApiKey = async (): Promise => { + // Firefox versions without setAccessLevel still use the private database. + if (chrome.storage.local.setAccessLevel) { + await chrome.storage.local.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" }); + } +}; + +export const loadControlDApiKey = async (): Promise => { + await protectLegacyApiKey(); + const privateKey = await readPrivateApiKey(); + const stored = await chrome.storage.local.get(API_KEY); + const legacy = stored[API_KEY]; + const migratedKey = + typeof legacy === "string" && legacy.trim() ? legacy.trim() : null; + if (!privateKey && migratedKey) await writePrivateApiKey(migratedKey); + if (legacy !== undefined) await chrome.storage.local.remove(API_KEY); + return privateKey ?? migratedKey; +}; + +export const saveControlDApiKey = async (apiKey: string): Promise => { + await protectLegacyApiKey(); + await writePrivateApiKey(apiKey.trim()); + await chrome.storage.local.remove(API_KEY); +}; + +export const forgetControlDApiKey = async (): Promise => { + await protectLegacyApiKey(); + await deletePrivateApiKey(); + await chrome.storage.local.remove(API_KEY); +}; + +export const toControlDPublicState = async ( + config: ControlDConfig, +): Promise => { + const dnsVerified = + config.endpointId !== null && + config.dnsVerification?.endpointId === config.endpointId; + let dnsStatus: ControlDPublicState["dnsStatus"] = "unavailable"; + if (config.resolverDoh) dnsStatus = dnsVerified ? "verified" : "pending"; + return { + enabled: config.enabled, + connected: config.connected, + autoSyncEnabled: config.autoSyncEnabled, + status: config.status, + hasApiKey: (await loadControlDApiKey()) !== null, + setupStatus: config.resourceIdentity ? "selected" : "unselected", + resourceCode: config.resourceIdentity?.code ?? null, + profileId: config.profileId, + endpointId: config.endpointId, + hasResolver: config.resolverDoh !== null, + resolverDoh: config.resolverDoh, + dnsStatus, + dnsVerifiedAt: dnsVerified ? (config.dnsVerification?.verifiedAt ?? null) : null, + lastAttemptAt: config.lastAttemptAt, + lastSuccessAt: config.lastSuccessAt, + lastError: config.lastError, + }; +}; + +export const CONTROL_D_STORE_KEYS = [CONFIG_KEY, API_KEY] as const; diff --git a/src/experimental/control-d/sync-queue.test.ts b/src/experimental/control-d/sync-queue.test.ts new file mode 100644 index 0000000..854c272 --- /dev/null +++ b/src/experimental/control-d/sync-queue.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createControlDSyncQueue } from "./sync-queue"; + +describe("createControlDSyncQueue", () => { + it("serializes operations and starts the queued operation with its latest input", async () => { + const queue = createControlDSyncQueue(); + let releaseFirst: (() => void) | undefined; + let latestSnapshot = "old"; + const first = queue.run( + () => + new Promise((resolve) => { + releaseFirst = () => resolve("first"); + }), + ); + const secondOperation = vi.fn(async () => latestSnapshot); + const second = queue.run(secondOperation); + + expect(queue.isBusy()).toBe(true); + expect(secondOperation).not.toHaveBeenCalled(); + latestSnapshot = "newest"; + releaseFirst?.(); + + await expect(first).resolves.toBe("first"); + await expect(second).resolves.toBe("newest"); + expect(secondOperation).toHaveBeenCalledOnce(); + expect(queue.isBusy()).toBe(false); + }); + + it("continues after a failed operation", async () => { + const queue = createControlDSyncQueue(); + + await expect( + queue.run(async () => { + throw new Error("failed"); + }), + ).rejects.toThrow("failed"); + await expect(queue.run(async () => "recovered")).resolves.toBe("recovered"); + }); +}); diff --git a/src/experimental/control-d/sync-queue.ts b/src/experimental/control-d/sync-queue.ts new file mode 100644 index 0000000..aa99f26 --- /dev/null +++ b/src/experimental/control-d/sync-queue.ts @@ -0,0 +1,26 @@ +export const createControlDSyncQueue = () => { + let active: Promise | null = null; + + const run = async (operation: () => Promise): Promise => { + while (active) { + try { + await active; + } catch { + // A failed operation still releases the queue for the newest snapshot. + } + } + + const current = operation(); + active = current; + try { + return await current; + } finally { + if (active === current) active = null; + } + }; + + return { + run, + isBusy: () => active !== null, + }; +}; diff --git a/src/experimental/control-d/ui-copy-en.ts b/src/experimental/control-d/ui-copy-en.ts new file mode 100644 index 0000000..9ac87be --- /dev/null +++ b/src/experimental/control-d/ui-copy-en.ts @@ -0,0 +1,161 @@ +import { BRAND_DISPLAY_NAME } from "@/shared/brand"; +export const controlDEn = { + title: "Control D", + featureTitle: "Control D integration", + featureDescription: + "Sync regional rules to a separate Control D profile. Beta and local builds only.", + open: "Open Control D", + back: "Back to Advanced", + lead: "Send regional rules to Control D. Changes go one way, from this extension.", + progressLabel: "Setup progress", + enableLabel: "Enable integration", + status: { + loading: "Loading", + disconnected: "Not connected", + authError: "Authorization failed", + conflict: "Review required", + syncError: "Sync error", + syncing: "Synchronizing", + chooseSetup: "Choose setup", + review: "Ready to review", + dnsPending: "Rules synced · Check DNS", + active: "Active", + }, + account: { + title: "Connect account", + description: + "Use an API key with write access. It stays in this installation and is excluded from exports and browser sync.", + placeholder: "Control D API key", + connect: "Connect", + connectedTitle: "API access verified", + connectedDescription: "Account access works. No remote changes have been made.", + docs: "API instructions", + permissionDenied: "Control D API access was not granted.", + }, + setup: { + title: "Choose setup", + description: `Create a separate setup or reconnect one previously created by ${BRAND_DISPLAY_NAME}.`, + none: "No compatible setups found.", + existing: "Existing setups", + missingEndpoint: "An endpoint will be created when you apply.", + blocked: "Resolve this setup's conflicts in Control D first.", + useExisting: "Use this setup", + createNew: "Create new setup", + confirmTitle: "Use this setup?", + confirmDescription: + "Link this installation to the selected profile. Remote changes start only after reviewing and applying the plan.", + folders: (count: number) => `Folders: ${count}`, + }, + rules: { + title: "Review changes", + description: "Review this plan before changing managed resources.", + preview: "Refresh preview", + apply: "Apply changes", + repair: "Repair rules", + sync: "Sync now", + routes: "Route overrides", + showRoutes: "Review routes", + hideRoutes: "Hide routes", + acceptApproximate: "I accept the approximate routes in this preview.", + upToDate: "Rules are up to date.", + routeDescription: "Change an automatic choice only when you need another exit.", + }, + dns: { + title: "Configure browser DNS", + description: `Set Secure DNS in your browser. ${BRAND_DISPLAY_NAME} cannot change or verify it automatically.`, + copy: "Copy resolver", + copied: "Copied", + settings: "Open DNS settings", + verify: "Check on Control D", + guide: "Setup instructions", + confirm: "I verified this resolver", + verified: "DNS verified", + verifiedDescription: "You confirmed this endpoint on the Control D status page.", + verifiedState: "Verified", + unverifiedState: "Not verified", + }, + overview: { + title: "Integration overview", + account: "API access", + rules: "Managed rules", + dns: "Browser DNS", + review: "Review routes", + verify: "Check DNS again", + setupLabel: (code: string) => `Setup ${code}`, + syncedAt: (time: string) => `Synced ${time}`, + }, + summary: { + profiles: "Profiles", + endpoints: "Endpoints", + folders: "Folders", + add: "Add", + update: "Update", + remove: "Remove", + }, + disconnect: { + action: "Disconnect", + title: "Disconnect Control D?", + description: + "Forget the API key and stop sync. Remote resources and browser DNS settings remain.", + confirm: "Disconnect", + }, + common: { + cancel: "Cancel", + continue: "Continue", + notYet: "Not yet", + working: "Working…", + selected: "Selected", + requestFailed: "Control D request failed.", + copyFailed: "Could not copy the resolver.", + applyFirst: "Apply changes to create an endpoint and resolver.", + confirmedAt: (time: string) => `Confirmed ${time}`, + }, + help: { + account: { + title: "Your key stays here", + body: "Connect grants this extension access to Control D. The key stays in this browser.", + note: "Write access is needed to create and update regional rules.", + }, + setup: { + title: "A separate setup", + body: "Use a new setup or reconnect an existing one. Other setups keep their settings.", + note: "Remote changes start only after you apply the reviewed plan.", + }, + rules: { + title: "Review, then apply", + body: "The preview shows planned additions, updates and removals.", + note: "Changes affect this setup's managed resources.", + }, + dns: { + title: "Set DNS in your browser", + body: "Copy the resolver and use it as Secure DNS in browser settings.", + note: "Check the Control D status page, then confirm this resolver here.", + }, + overview: { + title: "Three separate checks", + body: "Account access, rule sync and browser DNS are checked separately.", + note: "Disconnect removes the key and stops sync. DNS and remote resources remain.", + }, + }, + route: { + notSynchronized: "Not synchronized", + unavailable: "Exit unavailable", + exact: "Nearest exit in the same country.", + approximate: "Approximate exit. Choose another if needed.", + skipped: "Excluded from sync.", + change: "Change", + choose: "Choose exit", + skip: "Skip sync", + ruleCount: (count: number) => `Rules: ${count}`, + selectLabel: (location: string) => `Exit for ${location}`, + }, + steps: ["Account", "Setup", "Rules", "DNS"], +} as const; +type MessageShape = T extends string + ? string + : T extends (...args: never[]) => unknown + ? T + : T extends readonly string[] + ? readonly string[] + : { [K in keyof T]: MessageShape }; +export type ControlDMessages = MessageShape; diff --git a/src/experimental/control-d/ui-copy-es.ts b/src/experimental/control-d/ui-copy-es.ts new file mode 100644 index 0000000..4f896f7 --- /dev/null +++ b/src/experimental/control-d/ui-copy-es.ts @@ -0,0 +1,157 @@ +import type { ControlDMessages } from "./ui-copy-en"; + +import { BRAND_DISPLAY_NAME } from "@/shared/brand"; +export const controlDEs = { + title: "Control D", + featureTitle: "Integración con Control D", + featureDescription: + "Sincroniza reglas regionales con un perfil de Control D independiente. Solo en versiones beta y locales.", + open: "Abrir Control D", + back: "Volver a Avanzado", + lead: "Envía reglas regionales a Control D. Los cambios parten de esta extensión.", + progressLabel: "Progreso de configuración", + enableLabel: "Activar integración", + status: { + loading: "Cargando", + disconnected: "Sin conectar", + authError: "Error de autorización", + conflict: "Requiere revisión", + syncError: "Error de sincronización", + syncing: "Sincronizando", + chooseSetup: "Elige una configuración", + review: "Listo para revisar", + dnsPending: "Reglas sincronizadas · Verifica DNS", + active: "Activo", + }, + account: { + title: "Conectar cuenta", + description: + "Usa una clave API con permiso de escritura. Se guarda en esta instalación y queda fuera de las exportaciones y la sincronización del navegador.", + placeholder: "Clave API de Control D", + connect: "Conectar", + connectedTitle: "Acceso API verificado", + connectedDescription: + "El acceso a la cuenta funciona. No se han realizado cambios remotos.", + docs: "Instrucciones de la API", + permissionDenied: "No se concedió acceso a la API de Control D.", + }, + setup: { + title: "Elegir configuración", + description: `Crea una configuración independiente o vuelve a conectar una creada por ${BRAND_DISPLAY_NAME}.`, + none: "No se encontraron configuraciones compatibles.", + existing: "Configuraciones existentes", + missingEndpoint: "Se creará un endpoint al aplicar los cambios.", + blocked: "Resuelve primero los conflictos de esta configuración en Control D.", + useExisting: "Usar esta configuración", + createNew: "Crear configuración", + confirmTitle: "¿Usar esta configuración?", + confirmDescription: + "Vincula esta instalación con el perfil seleccionado. Los cambios remotos requieren revisar y aplicar el plan.", + folders: (count: number) => `Carpetas: ${count}`, + }, + rules: { + title: "Revisar cambios", + description: "Revisa este plan antes de cambiar los recursos administrados.", + preview: "Actualizar vista previa", + apply: "Aplicar cambios", + repair: "Reparar reglas", + sync: "Sincronizar ahora", + routes: "Rutas personalizadas", + showRoutes: "Revisar rutas", + hideRoutes: "Ocultar rutas", + acceptApproximate: "Acepto las rutas aproximadas de esta vista previa.", + upToDate: "Las reglas están actualizadas.", + routeDescription: "Cambia una selección automática solo si necesitas otra salida.", + }, + dns: { + title: "Configurar DNS del navegador", + description: `Configura DNS seguro en el navegador. ${BRAND_DISPLAY_NAME} no puede cambiarlo ni verificarlo automáticamente.`, + copy: "Copiar resolvedor", + copied: "Copiado", + settings: "Abrir ajustes de DNS", + verify: "Verificar en Control D", + guide: "Instrucciones", + confirm: "He verificado este resolvedor", + verified: "DNS verificado", + verifiedDescription: + "Confirmaste este endpoint en la página de estado de Control D.", + verifiedState: "Verificado", + unverifiedState: "Sin verificar", + }, + overview: { + title: "Resumen de integración", + account: "Acceso API", + rules: "Reglas administradas", + dns: "DNS del navegador", + review: "Revisar rutas", + verify: "Verificar DNS de nuevo", + setupLabel: (code: string) => `Configuración ${code}`, + syncedAt: (time: string) => `Sincronizado ${time}`, + }, + summary: { + profiles: "Perfiles", + endpoints: "Endpoints", + folders: "Carpetas", + add: "Añadir", + update: "Actualizar", + remove: "Eliminar", + }, + disconnect: { + action: "Desconectar", + title: "¿Desconectar Control D?", + description: + "Olvida la clave API y detén la sincronización. Los recursos remotos y los ajustes de DNS permanecen.", + confirm: "Desconectar", + }, + common: { + cancel: "Cancelar", + continue: "Continuar", + notYet: "Todavía no", + working: "Procesando…", + selected: "Seleccionado", + requestFailed: "La solicitud a Control D falló.", + copyFailed: "No se pudo copiar el resolvedor.", + applyFirst: "Aplica los cambios para crear un endpoint y un resolvedor.", + confirmedAt: (time: string) => `Confirmado ${time}`, + }, + help: { + account: { + title: "Tu clave se queda aquí", + body: "Conectar permite a la extensión acceder a Control D. La clave permanece en este navegador.", + note: "Se necesita acceso de escritura para crear y actualizar reglas regionales.", + }, + setup: { + title: "Una configuración independiente", + body: "Usa una configuración nueva o vuelve a conectar una existente. Las demás conservan sus ajustes.", + note: "Los cambios remotos comienzan al aplicar el plan revisado.", + }, + rules: { + title: "Revisar y aplicar", + body: "La vista previa muestra las adiciones, cambios y eliminaciones previstas.", + note: "Los cambios afectan a los recursos administrados de esta configuración.", + }, + dns: { + title: "Configura DNS en el navegador", + body: "Copia el resolvedor y úsalo como DNS seguro en los ajustes del navegador.", + note: "Revisa la página de estado de Control D y confirma aquí este resolvedor.", + }, + overview: { + title: "Tres comprobaciones", + body: "El acceso a la cuenta, la sincronización y el DNS se verifican por separado.", + note: "Desconectar elimina la clave y detiene la sincronización. El DNS y los recursos remotos permanecen.", + }, + }, + route: { + notSynchronized: "Sin sincronizar", + unavailable: "Salida no disponible", + exact: "Salida más cercana en el mismo país.", + approximate: "Salida aproximada. Elige otra si es necesario.", + skipped: "Excluido de la sincronización.", + change: "Cambiar", + choose: "Elegir salida", + skip: "Omitir sincronización", + ruleCount: (count: number) => `Reglas: ${count}`, + selectLabel: (location: string) => `Salida para ${location}`, + }, + steps: ["Cuenta", "Configuración", "Reglas", "DNS"], +} satisfies ControlDMessages; diff --git a/src/experimental/control-d/ui-copy-pt.ts b/src/experimental/control-d/ui-copy-pt.ts new file mode 100644 index 0000000..c870503 --- /dev/null +++ b/src/experimental/control-d/ui-copy-pt.ts @@ -0,0 +1,158 @@ +import type { ControlDMessages } from "./ui-copy-en"; + +import { BRAND_DISPLAY_NAME } from "@/shared/brand"; +export const controlDPt = { + title: "Control D", + featureTitle: "Integração com Control D", + featureDescription: + "Sincronize regras regionais com um perfil separado do Control D. Apenas versões beta e locais.", + open: "Abrir Control D", + back: "Voltar a Avançado", + lead: "Envie regras regionais para o Control D. As alterações partem desta extensão.", + progressLabel: "Progresso da configuração", + enableLabel: "Ativar integração", + status: { + loading: "Carregando", + disconnected: "Não conectado", + authError: "Falha de autorização", + conflict: "Revisão necessária", + syncError: "Erro de sincronização", + syncing: "Sincronizando", + chooseSetup: "Escolha uma configuração", + review: "Pronto para revisar", + dnsPending: "Regras sincronizadas · Verifique o DNS", + active: "Ativo", + }, + account: { + title: "Conectar conta", + description: + "Use uma chave de API com acesso de escrita. Ela fica nesta instalação, fora de exportações e da sincronização do navegador.", + placeholder: "Chave de API do Control D", + connect: "Conectar", + connectedTitle: "Acesso à API verificado", + connectedDescription: + "O acesso à conta funciona. Nenhuma alteração remota foi feita.", + docs: "Instruções da API", + permissionDenied: "O acesso à API do Control D não foi concedido.", + }, + setup: { + title: "Escolher configuração", + description: `Crie uma configuração separada ou reconecte uma criada pelo ${BRAND_DISPLAY_NAME}.`, + none: "Nenhuma configuração compatível encontrada.", + existing: "Configurações existentes", + missingEndpoint: "Um endpoint será criado ao aplicar.", + blocked: "Resolva primeiro os conflitos desta configuração no Control D.", + useExisting: "Usar esta configuração", + createNew: "Criar configuração", + confirmTitle: "Usar esta configuração?", + confirmDescription: + "Vincule esta instalação ao perfil selecionado. As alterações remotas exigem revisar e aplicar o plano.", + folders: (count: number) => `Pastas: ${count}`, + }, + rules: { + title: "Revisar alterações", + description: "Revise este plano antes de alterar recursos gerenciados.", + preview: "Atualizar prévia", + apply: "Aplicar alterações", + repair: "Reparar regras", + sync: "Sincronizar agora", + routes: "Rotas personalizadas", + showRoutes: "Revisar rotas", + hideRoutes: "Ocultar rotas", + acceptApproximate: "Aceito as rotas aproximadas desta prévia.", + upToDate: "As regras estão atualizadas.", + routeDescription: + "Altere uma escolha automática apenas se precisar de outra saída.", + }, + dns: { + title: "Configurar DNS do navegador", + description: `Configure o DNS seguro no navegador. O ${BRAND_DISPLAY_NAME} não pode alterá-lo nem verificá-lo automaticamente.`, + copy: "Copiar resolvedor", + copied: "Copiado", + settings: "Abrir configurações de DNS", + verify: "Verificar no Control D", + guide: "Instruções", + confirm: "Verifiquei este resolvedor", + verified: "DNS verificado", + verifiedDescription: + "Você confirmou este endpoint na página de status do Control D.", + verifiedState: "Verificado", + unverifiedState: "Não verificado", + }, + overview: { + title: "Visão geral da integração", + account: "Acesso à API", + rules: "Regras gerenciadas", + dns: "DNS do navegador", + review: "Revisar rotas", + verify: "Verificar DNS novamente", + setupLabel: (code: string) => `Configuração ${code}`, + syncedAt: (time: string) => `Sincronizado ${time}`, + }, + summary: { + profiles: "Perfis", + endpoints: "Endpoints", + folders: "Pastas", + add: "Adicionar", + update: "Atualizar", + remove: "Remover", + }, + disconnect: { + action: "Desconectar", + title: "Desconectar o Control D?", + description: + "Esqueça a chave de API e pare a sincronização. Recursos remotos e configurações de DNS permanecem.", + confirm: "Desconectar", + }, + common: { + cancel: "Cancelar", + continue: "Continuar", + notYet: "Ainda não", + working: "Processando…", + selected: "Selecionado", + requestFailed: "A solicitação ao Control D falhou.", + copyFailed: "Não foi possível copiar o resolvedor.", + applyFirst: "Aplique as alterações para criar um endpoint e um resolvedor.", + confirmedAt: (time: string) => `Confirmado ${time}`, + }, + help: { + account: { + title: "Sua chave fica aqui", + body: "Conectar permite à extensão acessar o Control D. A chave fica neste navegador.", + note: "O acesso de escrita é necessário para criar e atualizar regras regionais.", + }, + setup: { + title: "Uma configuração separada", + body: "Use uma configuração nova ou reconecte uma existente. As demais mantêm suas configurações.", + note: "As alterações remotas começam ao aplicar o plano revisado.", + }, + rules: { + title: "Revisar e aplicar", + body: "A prévia mostra adições, alterações e remoções planejadas.", + note: "As alterações afetam os recursos gerenciados desta configuração.", + }, + dns: { + title: "Configure o DNS no navegador", + body: "Copie o resolvedor e use-o como DNS seguro nas configurações do navegador.", + note: "Verifique a página de status do Control D e confirme este resolvedor aqui.", + }, + overview: { + title: "Três verificações", + body: "O acesso à conta, a sincronização e o DNS são verificados separadamente.", + note: "Desconectar remove a chave e para a sincronização. O DNS e os recursos remotos permanecem.", + }, + }, + route: { + notSynchronized: "Não sincronizado", + unavailable: "Saída indisponível", + exact: "Saída mais próxima no mesmo país.", + approximate: "Saída aproximada. Escolha outra se necessário.", + skipped: "Excluído da sincronização.", + change: "Alterar", + choose: "Escolher saída", + skip: "Ignorar sincronização", + ruleCount: (count: number) => `Regras: ${count}`, + selectLabel: (location: string) => `Saída para ${location}`, + }, + steps: ["Conta", "Configuração", "Regras", "DNS"], +} satisfies ControlDMessages; diff --git a/src/experimental/control-d/ui-copy-ru.ts b/src/experimental/control-d/ui-copy-ru.ts new file mode 100644 index 0000000..e11d093 --- /dev/null +++ b/src/experimental/control-d/ui-copy-ru.ts @@ -0,0 +1,156 @@ +import type { ControlDMessages } from "./ui-copy-en"; + +import { BRAND_DISPLAY_NAME } from "@/shared/brand"; +export const controlDRu = { + title: "Control D", + featureTitle: "Интеграция с Control D", + featureDescription: + "Синхронизация региональных правил с отдельным профилем Control D. Только бета и локальные сборки.", + open: "Открыть Control D", + back: "Назад к дополнительным настройкам", + lead: "Отправляйте региональные правила в Control D. Изменения передаются из расширения.", + progressLabel: "Ход настройки", + enableLabel: "Включить интеграцию", + status: { + loading: "Загрузка", + disconnected: "Не подключено", + authError: "Ошибка авторизации", + conflict: "Требуется проверка", + syncError: "Ошибка синхронизации", + syncing: "Синхронизация", + chooseSetup: "Выберите конфигурацию", + review: "Готово к проверке", + dnsPending: "Правила синхронизированы · Проверьте DNS", + active: "Активно", + }, + account: { + title: "Подключить аккаунт", + description: + "Используйте API-ключ с правом записи. Он хранится в этой установке и не попадает в экспорт или синхронизацию браузера.", + placeholder: "API-ключ Control D", + connect: "Подключить", + connectedTitle: "Доступ к API проверен", + connectedDescription: "Доступ к аккаунту работает. Удалённых изменений ещё нет.", + docs: "Инструкция по API", + permissionDenied: "Доступ к API Control D не предоставлен.", + }, + setup: { + title: "Выберите конфигурацию", + description: `Создайте отдельную конфигурацию или подключите ранее созданную ${BRAND_DISPLAY_NAME}.`, + none: "Совместимых конфигураций нет.", + existing: "Существующие конфигурации", + missingEndpoint: "Точка подключения будет создана при применении.", + blocked: "Сначала устраните конфликты этой конфигурации в Control D.", + useExisting: "Использовать конфигурацию", + createNew: "Создать конфигурацию", + confirmTitle: "Использовать эту конфигурацию?", + confirmDescription: + "Свяжите установку с выбранным профилем. Удалённые изменения начнутся после проверки и применения плана.", + folders: (count: number) => `Папки: ${count}`, + }, + rules: { + title: "Проверьте изменения", + description: "Проверьте план перед изменением управляемых ресурсов.", + preview: "Обновить просмотр", + apply: "Применить изменения", + repair: "Восстановить правила", + sync: "Синхронизировать", + routes: "Настройки маршрутов", + showRoutes: "Проверить маршруты", + hideRoutes: "Скрыть маршруты", + acceptApproximate: "Я принимаю приближённые маршруты в этом плане.", + upToDate: "Правила актуальны.", + routeDescription: "Измените автоматический выбор, если нужна другая точка выхода.", + }, + dns: { + title: "Настройте DNS браузера", + description: `Настройте безопасный DNS в браузере. ${BRAND_DISPLAY_NAME} не может изменить или проверить его автоматически.`, + copy: "Копировать адрес DNS", + copied: "Скопировано", + settings: "Открыть настройки DNS", + verify: "Проверить в Control D", + guide: "Инструкция", + confirm: "Я проверил этот адрес DNS", + verified: "DNS проверен", + verifiedDescription: + "Вы подтвердили эту точку подключения на странице статуса Control D.", + verifiedState: "Проверено", + unverifiedState: "Не проверено", + }, + overview: { + title: "Обзор интеграции", + account: "Доступ к API", + rules: "Управляемые правила", + dns: "DNS браузера", + review: "Проверить маршруты", + verify: "Проверить DNS снова", + setupLabel: (code: string) => `Конфигурация ${code}`, + syncedAt: (time: string) => `Синхронизировано ${time}`, + }, + summary: { + profiles: "Профили", + endpoints: "Точки подключения", + folders: "Папки", + add: "Добавить", + update: "Обновить", + remove: "Удалить", + }, + disconnect: { + action: "Отключить", + title: "Отключить Control D?", + description: + "Забыть API-ключ и остановить синхронизацию. Удалённые ресурсы и настройки DNS сохранятся.", + confirm: "Отключить", + }, + common: { + cancel: "Отмена", + continue: "Продолжить", + notYet: "Пока нет", + working: "Выполняется…", + selected: "Выбрано", + requestFailed: "Запрос Control D завершился ошибкой.", + copyFailed: "Не удалось скопировать адрес DNS.", + applyFirst: "Примените изменения для создания точки подключения и адреса DNS.", + confirmedAt: (time: string) => `Подтверждено ${time}`, + }, + help: { + account: { + title: "Ключ хранится здесь", + body: "Подключение даёт расширению доступ к Control D. Ключ остаётся в этом браузере.", + note: "Право записи нужно для создания и обновления региональных правил.", + }, + setup: { + title: "Отдельная конфигурация", + body: "Создайте конфигурацию или подключите существующую. Настройки остальных сохраняются.", + note: "Удалённые изменения начнутся после применения проверенного плана.", + }, + rules: { + title: "Проверьте и примените", + body: "План показывает добавление, обновление и удаление правил.", + note: "Изменения затронут управляемые ресурсы этой конфигурации.", + }, + dns: { + title: "Настройте DNS в браузере", + body: "Скопируйте адрес и задайте его как безопасный DNS в браузере.", + note: "Проверьте страницу статуса Control D и подтвердите адрес здесь.", + }, + overview: { + title: "Три отдельные проверки", + body: "Доступ к аккаунту, синхронизация и DNS проверяются отдельно.", + note: "Отключение удалит ключ и остановит синхронизацию. DNS и удалённые ресурсы сохранятся.", + }, + }, + route: { + notSynchronized: "Не синхронизировано", + unavailable: "Точка выхода недоступна", + exact: "Ближайшая точка выхода в той же стране.", + approximate: "Приближённая точка выхода. При необходимости выберите другую.", + skipped: "Исключено из синхронизации.", + change: "Изменить", + choose: "Выбрать точку выхода", + skip: "Не синхронизировать", + ruleCount: (count: number) => `Правила: ${count}`, + selectLabel: (location: string) => `Точка выхода для ${location}`, + }, + steps: ["Аккаунт", "Конфигурация", "Правила", "DNS"], +} satisfies ControlDMessages; diff --git a/src/experimental/control-d/ui-copy-uk.ts b/src/experimental/control-d/ui-copy-uk.ts new file mode 100644 index 0000000..64f6d6a --- /dev/null +++ b/src/experimental/control-d/ui-copy-uk.ts @@ -0,0 +1,157 @@ +import type { ControlDMessages } from "./ui-copy-en"; + +import { BRAND_DISPLAY_NAME } from "@/shared/brand"; +export const controlDUk = { + title: "Control D", + featureTitle: "Інтеграція з Control D", + featureDescription: + "Синхронізація регіональних правил з окремим профілем Control D. Лише бета й локальні збірки.", + open: "Відкрити Control D", + back: "Назад до додаткових налаштувань", + lead: "Надсилайте регіональні правила до Control D. Зміни передаються з розширення.", + progressLabel: "Перебіг налаштування", + enableLabel: "Увімкнути інтеграцію", + status: { + loading: "Завантаження", + disconnected: "Не підключено", + authError: "Помилка авторизації", + conflict: "Потрібна перевірка", + syncError: "Помилка синхронізації", + syncing: "Синхронізація", + chooseSetup: "Оберіть конфігурацію", + review: "Готово до перевірки", + dnsPending: "Правила синхронізовано · Перевірте DNS", + active: "Активно", + }, + account: { + title: "Підключити обліковий запис", + description: + "Використовуйте API-ключ з правом запису. Він зберігається в цій установці й не потрапляє до експорту чи синхронізації браузера.", + placeholder: "API-ключ Control D", + connect: "Підключити", + connectedTitle: "Доступ до API перевірено", + connectedDescription: + "Доступ до облікового запису працює. Віддалених змін ще немає.", + docs: "Інструкція з API", + permissionDenied: "Доступ до API Control D не надано.", + }, + setup: { + title: "Оберіть конфігурацію", + description: `Створіть окрему конфігурацію або підключіть раніше створену ${BRAND_DISPLAY_NAME}.`, + none: "Сумісних конфігурацій немає.", + existing: "Наявні конфігурації", + missingEndpoint: "Точку підключення буде створено під час застосування.", + blocked: "Спочатку усуньте конфлікти цієї конфігурації в Control D.", + useExisting: "Використати конфігурацію", + createNew: "Створити конфігурацію", + confirmTitle: "Використати цю конфігурацію?", + confirmDescription: + "Пов’яжіть установку з обраним профілем. Віддалені зміни почнуться після перевірки й застосування плану.", + folders: (count: number) => `Папки: ${count}`, + }, + rules: { + title: "Перевірте зміни", + description: "Перевірте план перед зміною керованих ресурсів.", + preview: "Оновити перегляд", + apply: "Застосувати зміни", + repair: "Відновити правила", + sync: "Синхронізувати", + routes: "Налаштування маршрутів", + showRoutes: "Перевірити маршрути", + hideRoutes: "Приховати маршрути", + acceptApproximate: "Я погоджуюсь на приблизні маршрути в цьому плані.", + upToDate: "Правила актуальні.", + routeDescription: "Змініть автоматичний вибір, якщо потрібна інша точка виходу.", + }, + dns: { + title: "Налаштуйте DNS браузера", + description: `Налаштуйте безпечний DNS у браузері. ${BRAND_DISPLAY_NAME} не може змінити чи перевірити його автоматично.`, + copy: "Копіювати адресу DNS", + copied: "Скопійовано", + settings: "Відкрити налаштування DNS", + verify: "Перевірити в Control D", + guide: "Інструкція", + confirm: "Я перевірив цю адресу DNS", + verified: "DNS перевірено", + verifiedDescription: + "Ви підтвердили цю точку підключення на сторінці стану Control D.", + verifiedState: "Перевірено", + unverifiedState: "Не перевірено", + }, + overview: { + title: "Огляд інтеграції", + account: "Доступ до API", + rules: "Керовані правила", + dns: "DNS браузера", + review: "Перевірити маршрути", + verify: "Перевірити DNS знову", + setupLabel: (code: string) => `Конфігурація ${code}`, + syncedAt: (time: string) => `Синхронізовано ${time}`, + }, + summary: { + profiles: "Профілі", + endpoints: "Точки підключення", + folders: "Папки", + add: "Додати", + update: "Оновити", + remove: "Видалити", + }, + disconnect: { + action: "Відключити", + title: "Відключити Control D?", + description: + "Забути API-ключ і зупинити синхронізацію. Віддалені ресурси й налаштування DNS збережуться.", + confirm: "Відключити", + }, + common: { + cancel: "Скасувати", + continue: "Продовжити", + notYet: "Ще ні", + working: "Виконується…", + selected: "Обрано", + requestFailed: "Запит Control D завершився помилкою.", + copyFailed: "Не вдалося скопіювати адресу DNS.", + applyFirst: "Застосуйте зміни для створення точки підключення й адреси DNS.", + confirmedAt: (time: string) => `Підтверджено ${time}`, + }, + help: { + account: { + title: "Ключ зберігається тут", + body: "Підключення дає розширенню доступ до Control D. Ключ залишається в цьому браузері.", + note: "Право запису потрібне для створення й оновлення регіональних правил.", + }, + setup: { + title: "Окрема конфігурація", + body: "Створіть конфігурацію або підключіть наявну. Налаштування інших зберігаються.", + note: "Віддалені зміни почнуться після застосування перевіреного плану.", + }, + rules: { + title: "Перевірте й застосуйте", + body: "План показує додавання, оновлення й видалення правил.", + note: "Зміни торкнуться керованих ресурсів цієї конфігурації.", + }, + dns: { + title: "Налаштуйте DNS у браузері", + body: "Скопіюйте адресу й задайте її як безпечний DNS у браузері.", + note: "Перевірте сторінку стану Control D і підтвердьте адресу тут.", + }, + overview: { + title: "Три окремі перевірки", + body: "Доступ до облікового запису, синхронізація й DNS перевіряються окремо.", + note: "Відключення видалить ключ і зупинить синхронізацію. DNS і віддалені ресурси збережуться.", + }, + }, + route: { + notSynchronized: "Не синхронізовано", + unavailable: "Точка виходу недоступна", + exact: "Найближча точка виходу в тій самій країні.", + approximate: "Приблизна точка виходу. За потреби оберіть іншу.", + skipped: "Виключено із синхронізації.", + change: "Змінити", + choose: "Обрати точку виходу", + skip: "Не синхронізувати", + ruleCount: (count: number) => `Правила: ${count}`, + selectLabel: (location: string) => `Точка виходу для ${location}`, + }, + steps: ["Обліковий запис", "Конфігурація", "Правила", "DNS"], +} satisfies ControlDMessages; diff --git a/src/experimental/control-d/ui-copy.ts b/src/experimental/control-d/ui-copy.ts new file mode 100644 index 0000000..e03cb34 --- /dev/null +++ b/src/experimental/control-d/ui-copy.ts @@ -0,0 +1,18 @@ +import { controlDEn, type ControlDMessages } from "./ui-copy-en"; +import { controlDEs } from "./ui-copy-es"; +import { controlDPt } from "./ui-copy-pt"; +import { controlDRu } from "./ui-copy-ru"; +import { controlDUk } from "./ui-copy-uk"; + +import { createMessagesProxy, getActiveUiLocale } from "@/ui/i18n"; +const catalogs = { + en: controlDEn, + es: controlDEs, + pt: controlDPt, + ru: controlDRu, + uk: controlDUk, +}; +// Keep the experiment's catalogs inside its lazy module so release builds omit them. +export const controlDText = createMessagesProxy( + () => catalogs[getActiveUiLocale()], +) as ControlDMessages; diff --git a/src/experimental/control-d/ui-entry.tsx b/src/experimental/control-d/ui-entry.tsx new file mode 100644 index 0000000..111b4d6 --- /dev/null +++ b/src/experimental/control-d/ui-entry.tsx @@ -0,0 +1,953 @@ +/* eslint-disable max-lines */ +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import { + CONTROL_D_API_GUIDE_URL, + CONTROL_D_API_ORIGIN, + CONTROL_D_COMMANDS, + CONTROL_D_GUIDE_URL, + CONTROL_D_STATUS_URL, + type ControlDDiff, + type ControlDMapping, + type ControlDPreparedSnapshot, + type ControlDPublicState, + type ControlDRecoveryCandidate, +} from "./contracts"; +import { controlDText as t } from "./ui-copy"; +import { ControlDRegionalRoute } from "./ui-regional-route"; + +import { cn } from "@/ui/components/lib/utils"; +import { SettingsControlCard } from "@/ui/components/SettingsControlCard"; +import { SettingsHelpCard } from "@/ui/components/SettingsHelpCard"; +import { Button } from "@/ui/components/ui/button"; +import { Card } from "@/ui/components/ui/card"; +import { Checkbox } from "@/ui/components/ui/checkbox"; +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/ui/components/ui/dialog"; +import { Input } from "@/ui/components/ui/input"; +import { Separator } from "@/ui/components/ui/separator"; +import { Switch } from "@/ui/components/ui/switch"; +import { SetupProgress } from "@/ui/options/components/onboarding/setup-progress"; +import { SETTINGS_SUBPAGE_ANCHORS, PAGE_ANCHORS } from "@/ui/options/navigation"; +import { AppSubpageHeader } from "@/ui/shared/AppSubpageHeader"; + +type UiResponse = + | { + ok: true; + state: ControlDPublicState; + snapshot?: ControlDPreparedSnapshot; + candidates?: ControlDRecoveryCandidate[]; + } + | { ok: false; error: string; state?: ControlDPublicState }; + +type FlowStep = 0 | 1 | 2 | 3; +type ConfirmState = + | { kind: "adopt"; candidate: ControlDRecoveryCandidate } + | { kind: "disconnect" } + | null; + +const EMPTY_PROXIES: ControlDPreparedSnapshot["proxies"] = []; +const send = async (message: unknown): Promise => + (await chrome.runtime.sendMessage(message)) as UiResponse; + +const requestApiAccess = async (): Promise => { + const permission = { origins: [CONTROL_D_API_ORIGIN] }; + if (await chrome.permissions.contains(permission)) return true; + return chrome.permissions.request(permission); +}; + +export const isIntegrationAvailable = (): boolean => + (chrome.runtime.getManifest().optional_host_permissions ?? []).includes( + CONTROL_D_API_ORIGIN, + ); + +const settingTitle = (text: string) => ( +

{text}

+); + +const StepSection = ({ + alert, + children, + ...props +}: React.ComponentProps & { + alert?: React.ReactNode; +}) => ( + + {alert ?
{alert}
: null} + {children} +
+); +const formatTime = (value: string | null): string => + value ? new Date(value).toLocaleString() : t.common.notYet; + +const inferStep = (state: ControlDPublicState | null): FlowStep => { + if (!state?.connected) return 0; + if (state.setupStatus === "unselected") return 1; + return state.lastSuccessAt ? 3 : 2; +}; + +const connectedStep = (state: ControlDPublicState): FlowStep => { + if (state.setupStatus === "unselected") return 1; + return state.lastSuccessAt ? 3 : 2; +}; + +const stateLabel = (state: ControlDPublicState | null): string => { + if (!state) return t.status.loading; + if (!state.connected) return t.status.disconnected; + if (state.status === "auth-error") return t.status.authError; + if (state.status === "conflict") return t.status.conflict; + if (state.status === "error") return t.status.syncError; + if (state.status === "syncing") return t.status.syncing; + if (state.setupStatus === "unselected") return t.status.chooseSetup; + if (!state.lastSuccessAt) return t.status.review; + if (state.dnsStatus !== "verified") return t.status.dnsPending; + return t.status.active; +}; + +type StatusTone = "neutral" | "success" | "error" | "warning"; + +const stateTone = (state: ControlDPublicState | null): StatusTone => { + if (!state?.connected || state.status === "syncing") return "neutral"; + if (state.status === "auth-error" || state.status === "error") return "error"; + if (state.status === "conflict") return "warning"; + if (state.setupStatus === "unselected" || !state.lastSuccessAt) return "neutral"; + return state.dnsStatus === "verified" ? "success" : "warning"; +}; + +const toneClasses: Record = { + success: "border-tone-success-border bg-tone-success-bg text-tone-success-text", + error: "border-tone-error-border bg-tone-error-bg text-tone-error-text", + warning: "border-tone-warning-border bg-tone-warning-bg text-tone-warning-text", + neutral: "border-border bg-muted/60 text-foreground", +}; + +const StatusBadge = ({ label, tone }: { label: string; tone: StatusTone }) => ( + + {label} + +); + +export const ControlDFeatureToggle = ({ + onEnabledChange, +}: { + onEnabledChange: (enabled: boolean) => void; +}) => { + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void send({ type: CONTROL_D_COMMANDS.getState }).then((response) => { + if (!response.state) return; + setState(response.state); + onEnabledChange(response.state.enabled); + }); + }, [onEnabledChange]); + + const toggle = async (enabled: boolean) => { + setBusy(true); + try { + const response = await send({ type: CONTROL_D_COMMANDS.setEnabled, enabled }); + if (response.state) setState(response.state); + if (response.ok) onEnabledChange(enabled); + } finally { + setBusy(false); + } + }; + + const openIntegration = () => { + window.location.hash = SETTINGS_SUBPAGE_ANCHORS.experimentalIntegration; + }; + + return ( + void toggle(enabled)} + /> + } + > + {state?.enabled ? ( +
+ + +
+ ) : null} +
+ ); +}; + +const StepRail = ({ + current, + furthest, + onSelect, +}: { + current: FlowStep; + furthest: FlowStep; + onSelect: (step: FlowStep) => void; +}) => ( +
+ onSelect(step as FlowStep)} + /> +
+); + +const ChangeSummary = ({ diff }: { diff: ControlDDiff }) => { + const values = [ + [t.summary.profiles, diff.createProfile ? 1 : 0], + [t.summary.endpoints, diff.createEndpoint ? 1 : 0], + [t.summary.folders, diff.createFolders], + [t.summary.add, diff.addRules], + [t.summary.update, diff.updateRules], + [t.summary.remove, diff.deleteRules], + ] as const; + return ( +
+ {values.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +}; + +// eslint-disable-next-line max-lines-per-function -- Coordinates the complete experimental setup flow. +export const ControlDSubpage = () => { + const [state, setState] = useState(null); + const [apiKey, setApiKey] = useState(""); + const [candidates, setCandidates] = useState([]); + const [selectedProfileId, setSelectedProfileId] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [confirmApproximate, setConfirmApproximate] = useState(false); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + const [stepOverride, setStepOverride] = useState(null); + const [showRoutes, setShowRoutes] = useState(false); + const [editingLocationId, setEditingLocationId] = useState(null); + const [copied, setCopied] = useState(false); + const [confirmState, setConfirmState] = useState(null); + + const run = useCallback(async (message: unknown) => { + setBusy(true); + setNotice(null); + try { + const response = await send(message); + if (response.state) setState(response.state); + if (!response.ok) { + setSnapshot(null); + setConfirmApproximate(false); + setNotice(response.error); + return response; + } + if (response.snapshot) { + setSnapshot(response.snapshot); + setConfirmApproximate(false); + } else if (!response.ok) { + setSnapshot(null); + setConfirmApproximate(false); + } + if (response.candidates) { + setCandidates(response.candidates); + setSelectedProfileId(null); + } + return response; + } catch (error) { + setNotice(error instanceof Error ? error.message : t.common.requestFailed); + return null; + } finally { + setBusy(false); + } + }, []); + + useEffect(() => { + void (async () => { + const response = await run({ type: CONTROL_D_COMMANDS.getState }); + if ( + response?.ok && + response.state.connected && + response.state.setupStatus === "unselected" + ) { + await run({ type: CONTROL_D_COMMANDS.discover }); + } else if ( + response?.ok && + response.state.connected && + response.state.setupStatus === "selected" && + (!response.state.lastSuccessAt || response.state.status === "conflict") + ) { + await run({ type: CONTROL_D_COMMANDS.preview }); + } + })(); + }, [run]); + + const inferredStep = inferStep(state); + const currentStep = stepOverride ?? inferredStep; + const syncing = busy || state?.status === "syncing"; + const active = Boolean(state?.lastSuccessAt && state.dnsStatus === "verified"); + const selectedCandidate = candidates.find( + (candidate) => candidate.profileId === selectedProfileId, + ); + const proxies = snapshot?.proxies ?? EMPTY_PROXIES; + const proxyByPk = useMemo( + () => new Map(proxies.map((proxy) => [proxy.pk, proxy])), + [proxies], + ); + + const connect = async () => { + if (!(await requestApiAccess())) { + setNotice(t.account.permissionDenied); + return; + } + const response = await run({ type: CONTROL_D_COMMANDS.connect, apiKey }); + if (response?.ok) { + setApiKey(""); + setStepOverride(connectedStep(response.state)); + } + }; + + const chooseNew = async () => { + const response = await run({ type: CONTROL_D_COMMANDS.selectNew }); + if (!response?.ok) return; + setSnapshot(null); + setConfirmApproximate(false); + const prepared = await run({ type: CONTROL_D_COMMANDS.preview }); + if (prepared?.ok) setStepOverride(2); + }; + + const adopt = async (candidate: ControlDRecoveryCandidate) => { + const response = await run({ + type: CONTROL_D_COMMANDS.adopt, + profileId: candidate.profileId, + endpointId: candidate.endpointId, + code: candidate.code, + }); + if (!response?.ok) return; + setSnapshot(null); + setConfirmApproximate(false); + const prepared = await run({ type: CONTROL_D_COMMANDS.preview }); + if (prepared?.ok) setStepOverride(2); + }; + + const updateMapping = async (mapping: ControlDMapping, proxyPk: string) => { + setSnapshot(null); + setConfirmApproximate(false); + const proxy = proxyByPk.get(proxyPk); + const shared = { + locationId: mapping.locationId, + ...(mapping.locationLabel ? { locationLabel: mapping.locationLabel } : {}), + ...(mapping.ruleCount === undefined ? {} : { ruleCount: mapping.ruleCount }), + }; + const next: ControlDMapping = proxy + ? { ...shared, proxyPk, status: "approximate", confirmed: false } + : { ...shared, proxyPk: null, status: "skipped", confirmed: true }; + const response = await run({ + type: CONTROL_D_COMMANDS.updateMapping, + mapping: next, + }); + if (response?.ok) { + setEditingLocationId(null); + await run({ type: CONTROL_D_COMMANDS.preview }); + } + }; + + const apply = async () => { + const type = + state?.status === "conflict" + ? CONTROL_D_COMMANDS.repair + : CONTROL_D_COMMANDS.apply; + const response = await run({ + type, + confirmApproximate, + previewToken: snapshot?.token ?? "", + }); + if (response?.ok) setStepOverride(3); + }; + + const recordDnsAction = ( + action: "copy-resolver" | "open-settings" | "open-status" | "open-guide", + outcome: "success" | "fallback" | "failure", + ) => void send({ type: CONTROL_D_COMMANDS.dnsAction, action, outcome }); + + const copyResolver = async () => { + if (!state?.resolverDoh) return; + try { + await navigator.clipboard.writeText(state.resolverDoh); + setCopied(true); + recordDnsAction("copy-resolver", "success"); + } catch { + recordDnsAction("copy-resolver", "failure"); + setNotice(t.common.copyFailed); + } + }; + + const openBrowserDns = async () => { + const settingsUrl = + __PT_BROWSER_TARGET__ === "firefox" + ? "about:preferences#privacy" + : "chrome://settings/security"; + try { + await chrome.tabs.create({ url: settingsUrl }); + recordDnsAction("open-settings", "success"); + } catch { + await chrome.tabs.create({ url: CONTROL_D_GUIDE_URL }); + recordDnsAction("open-settings", "fallback"); + } + }; + + const openExternal = async (action: "open-status" | "open-guide", url: string) => { + try { + await chrome.tabs.create({ url }); + recordDnsAction(action, "success"); + } catch { + recordDnsAction(action, "failure"); + } + }; + + const finishConfirmation = async () => { + const current = confirmState; + setConfirmState(null); + if (current?.kind === "adopt") await adopt(current.candidate); + if (current?.kind === "disconnect") { + const response = await run({ type: CONTROL_D_COMMANDS.disconnect }); + if (response?.ok) { + setSnapshot(null); + setCandidates([]); + setStepOverride(0); + } + } + }; + + const stepAlert = + (notice ?? state?.lastError) ? ( +
+ {notice ?? state?.lastError} +
+ ) : null; + + const renderAccount = () => ( + + {state?.connected ? ( +
+ + +
+ ) : ( +
+
+ setApiKey(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && apiKey.trim() && !syncing) void connect(); + }} + /> + +
+ +
+ )} +
+ ); + + const renderSetup = () => ( + +
+ {candidates.length === 0 ? ( +

+ {t.setup.none} +

+ ) : ( +
+ {candidates.map((candidate) => { + const blocked = candidate.compatibility === "ambiguous"; + const selected = selectedProfileId === candidate.profileId; + return ( + + ); + })} +
+ )} +
+ + {selectedCandidate ? ( + + ) : null} +
+
+
+ ); + + // eslint-disable-next-line sonarjs/cognitive-complexity + const renderRules = () => { + const diff = snapshot?.diff; + const blockingWarnings = + diff?.warnings.filter( + (warning) => + warning.code === "unsupported-pattern" || warning.code === "missing-location", + ) ?? []; + const mappings = showRoutes + ? (diff?.mappings ?? []) + : (diff?.mappings.filter((mapping) => mapping.status === "approximate") ?? []); + let applyLabel: string = t.rules.apply; + if (syncing) applyLabel = t.common.working; + else if (state?.status === "conflict") applyLabel = t.rules.repair; + return ( +
+ + {diff ? ( +
+ + {diff.addRules + diff.updateRules + diff.deleteRules === 0 && + !diff.createProfile && + !diff.createEndpoint ? ( +

{t.rules.upToDate}

+ ) : null} + {diff.warnings.length > 0 ? ( +
    + {diff.warnings.map((warning, index) => ( +
  • + {warning.message} +
  • + ))} +
+ ) : null} + {diff.requiresApproximationConfirmation ? ( + + ) : null} +
+ + +
+
+ ) : ( + + )} +
+ {mappings.length > 0 ? ( + +
+ {mappings.map((mapping) => ( + + setEditingLocationId(editing ? mapping.locationId : null) + } + onMappingChange={(next, proxyPk) => void updateMapping(next, proxyPk)} + /> + ))} +
+
+ ) : null} +
+ ); + }; + + const renderDns = () => ( + + {state?.resolverDoh ? ( +
+ + {state.resolverDoh.replace(/^(https:\/\/[^/]+\/).+$/, "$1••••••••")} + +
+ + + + +
+ {state.dnsStatus === "verified" ? ( +

+ {t.common.confirmedAt(formatTime(state.dnsVerifiedAt))} +

+ ) : ( +
+ +
+ )} +
+ ) : ( +

{t.common.applyFirst}

+ )} +
+ ); + + const renderOverview = () => ( +
+ +
+
+

{t.overview.account}

+

{t.account.connectedTitle}

+
+
+

{t.overview.rules}

+

+ {t.overview.syncedAt(formatTime(state?.lastSuccessAt ?? null))} +

+
+
+

{t.overview.dns}

+

+ {state?.dnsStatus === "verified" + ? t.dns.verifiedState + : t.dns.unverifiedState} +

+
+
+
+ + + +
+
+ +
+ +
+
+ ); + + let content = renderDns(); + if (active && stepOverride === null) content = renderOverview(); + else if (currentStep === 0) content = renderAccount(); + else if (currentStep === 1) content = renderSetup(); + else if (currentStep === 2) content = renderRules(); + + const help = + active && stepOverride === null + ? t.help.overview + : ([t.help.account, t.help.setup, t.help.rules, t.help.dns] as const)[ + currentStep + ]; + + return ( +
+ +
+
+ + +
+ {syncing ? t.common.working : stateLabel(state)} +
+ {content} +
+
+
+ +

{help.body}

+

{help.note}

+
+
+
+ { + if (!open) setConfirmState(null); + }} + > + + + + + {confirmState?.kind === "adopt" + ? t.setup.confirmTitle + : t.disconnect.title} + + + {confirmState?.kind === "adopt" + ? t.setup.confirmDescription + : t.disconnect.description} + + + + + + + + +
+ ); +}; + +export const ControlDPanel = ControlDSubpage; diff --git a/src/experimental/control-d/ui-regional-route.tsx b/src/experimental/control-d/ui-regional-route.tsx new file mode 100644 index 0000000..4cc93b1 --- /dev/null +++ b/src/experimental/control-d/ui-regional-route.tsx @@ -0,0 +1,111 @@ +import type { ControlDMapping, ControlDProxyLocation } from "./contracts"; +import { controlDText as t } from "./ui-copy"; + +import { Button } from "@/ui/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/ui/components/ui/select"; + +const ruleCountLabel = (count: number): string => t.route.ruleCount(count); + +const proxyLabel = ( + mapping: ControlDMapping, + proxy: ControlDProxyLocation | undefined, +): string => { + if (proxy) return `${proxy.city}, ${proxy.countryName}`; + if (mapping.status === "skipped") return t.route.notSynchronized; + return t.route.unavailable; +}; + +const mappingDescription = (status: ControlDMapping["status"]): string => { + if (status === "exact") { + return t.route.exact; + } + if (status === "approximate") { + return t.route.approximate; + } + return t.route.skipped; +}; + +type RouteProps = { + busy: boolean; + editing: boolean; + mapping: ControlDMapping; + proxies: readonly ControlDProxyLocation[]; + proxy: ControlDProxyLocation | undefined; + onEditingChange: (editing: boolean) => void; + onMappingChange: (mapping: ControlDMapping, proxyPk: string) => void; +}; + +export const ControlDRegionalRoute = ({ + busy, + editing, + mapping, + proxies, + proxy, + onEditingChange, + onMappingChange, +}: RouteProps) => { + const label = mapping.locationLabel ?? mapping.locationId; + const selectedProxyLabel = proxyLabel(mapping, proxy); + let actionLabel: string = t.route.change; + if (editing) actionLabel = t.common.cancel; + else if (mapping.status === "skipped" || !proxy) actionLabel = t.route.choose; + + return ( +
+
+
{label}
+
+ {ruleCountLabel(mapping.ruleCount ?? 0)} +
+
+
+ {editing ? ( + + ) : ( +
+
{selectedProxyLabel}
+
+ {mappingDescription(mapping.status)} +
+
+ )} +
+ +
+ ); +}; diff --git a/src/scripts/manifest-config.target.test.ts b/src/scripts/manifest-config.target.test.ts index 5bdffe0..9b234d1 100644 --- a/src/scripts/manifest-config.target.test.ts +++ b/src/scripts/manifest-config.target.test.ts @@ -24,6 +24,21 @@ describe("extension manifest", () => { expect(resolveBrandDisplayName("stable")).toBe("Privacy Thing (Preview)"); }); + it("declares Control D API access only for beta and local builds", () => { + const releaseManifest = createManifest({ buildChannel: "release" }); + const betaManifest = createManifest({ buildChannel: "beta" }); + const localManifest = createManifest({ buildChannel: "local" }); + + expect(releaseManifest).not.toHaveProperty("optional_host_permissions"); + expect(betaManifest.optional_host_permissions).toEqual([ + "https://api.controld.com/*", + ]); + expect(localManifest.optional_host_permissions).toEqual([ + "https://api.controld.com/*", + ]); + expect(releaseManifest.permissions).not.toContain("proxy"); + }); + it("declares Firefox toolbar theme icons without adding them to Chromium", () => { const firefoxManifest = createManifest({ browserTarget: "firefox" }); const chromiumManifest = createManifest({ browserTarget: "chromium" }); diff --git a/src/shared/profile-schema.ts b/src/shared/profile-schema.ts index 6b4a176..b1a8510 100644 --- a/src/shared/profile-schema.ts +++ b/src/shared/profile-schema.ts @@ -88,6 +88,11 @@ const normalizeLegacyWorker = ( */ export const locationProfileSchema = z .object({ + countryCode: z + .string() + .regex(/^[a-zA-Z]{2}$/) + .transform((value) => value.toUpperCase()) + .optional(), id: z.string().min(1), label: z.string().min(1), latitude: z.number().min(-90).max(90), diff --git a/src/shared/shared-model-types.ts b/src/shared/shared-model-types.ts index 83493f2..168ec30 100644 --- a/src/shared/shared-model-types.ts +++ b/src/shared/shared-model-types.ts @@ -227,6 +227,8 @@ export type Location = { label: string; latitude: number; longitude: number; + /** ISO 3166-1 alpha-2 country code used by optional regional integrations. */ + countryCode?: string; accuracy: number; noiseRadius: number; language: string; @@ -240,6 +242,7 @@ export type ProfileDraft = { label: string; latitude: number; longitude: number; + countryCode?: string; accuracy: number; noiseRadius: number; language: string; diff --git a/src/stubs/experimental-control-d-background.ts b/src/stubs/experimental-control-d-background.ts new file mode 100644 index 0000000..56bb2db --- /dev/null +++ b/src/stubs/experimental-control-d-background.ts @@ -0,0 +1,2 @@ +export const registerControlD = (_deps: { getDebugMode: () => boolean }): void => + undefined; diff --git a/src/stubs/experimental-control-d-ui.tsx b/src/stubs/experimental-control-d-ui.tsx new file mode 100644 index 0000000..33329ba --- /dev/null +++ b/src/stubs/experimental-control-d-ui.tsx @@ -0,0 +1,9 @@ +export const ControlDFeatureToggle = (_props: { + onEnabledChange: (enabled: boolean) => void; +}) => null; + +export const ControlDPanel = () => null; + +export const ControlDSubpage = () => null; + +export const isIntegrationAvailable = (): boolean => false; diff --git a/src/ui/i18n/en-sections/common.ts b/src/ui/i18n/en-sections/common.ts index 9291247..021f113 100644 --- a/src/ui/i18n/en-sections/common.ts +++ b/src/ui/i18n/en-sections/common.ts @@ -29,6 +29,8 @@ export const common = { name: "Name", latitude: "Latitude", longitude: "Longitude", + countryCodeHint: "Two-letter country code for regional services.", + countryCode: "Country code", accuracy: "Accuracy", noiseRadius: "Max radius (m)", timeZone: "Time zone", diff --git a/src/ui/i18n/es-sections/common.ts b/src/ui/i18n/es-sections/common.ts index afd1044..643f443 100644 --- a/src/ui/i18n/es-sections/common.ts +++ b/src/ui/i18n/es-sections/common.ts @@ -27,6 +27,8 @@ export const common = { `${count} ${count === 1 ? "seleccionado" : "seleccionados"}`, fields: { + countryCodeHint: "Código de país de dos letras para servicios regionales.", + countryCode: "Código de país", name: "Nombre", latitude: "Latitud", longitude: "Longitud", diff --git a/src/ui/i18n/index.ts b/src/ui/i18n/index.ts index 9b7b2eb..57fee83 100644 --- a/src/ui/i18n/index.ts +++ b/src/ui/i18n/index.ts @@ -43,7 +43,7 @@ export const applyUiLocalePreference = (preference: UiLocalePreference): void => const isMessageRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const createMessagesProxy = (read: () => object): object => +export const createMessagesProxy = (read: () => object): object => new Proxy( {}, { diff --git a/src/ui/i18n/pt-sections/common.ts b/src/ui/i18n/pt-sections/common.ts index c16ee83..a01d961 100644 --- a/src/ui/i18n/pt-sections/common.ts +++ b/src/ui/i18n/pt-sections/common.ts @@ -27,6 +27,8 @@ export const common = { `${count} ${count === 1 ? "selecionado" : "selecionados"}`, fields: { + countryCodeHint: "Código do país com duas letras para serviços regionais.", + countryCode: "Código do país", name: "Nome", latitude: "Latitude", longitude: "Longitude", diff --git a/src/ui/i18n/ru-sections/common.ts b/src/ui/i18n/ru-sections/common.ts index 841c31d..852d615 100644 --- a/src/ui/i18n/ru-sections/common.ts +++ b/src/ui/i18n/ru-sections/common.ts @@ -24,6 +24,8 @@ export const common = { }, selectionCount: (count: number) => `Выбрано: ${count}`, fields: { + countryCodeHint: "Двухбуквенный код страны для региональных сервисов.", + countryCode: "Код страны", name: "Название", latitude: "Широта", longitude: "Долгота", diff --git a/src/ui/i18n/uk-sections/common.ts b/src/ui/i18n/uk-sections/common.ts index e9d5bbd..760817e 100644 --- a/src/ui/i18n/uk-sections/common.ts +++ b/src/ui/i18n/uk-sections/common.ts @@ -24,6 +24,8 @@ export const common = { }, selectionCount: (count: number) => `Вибрано: ${count}`, fields: { + countryCodeHint: "Дволітерний код країни для регіональних сервісів.", + countryCode: "Код країни", name: "Назва", latitude: "Широта", longitude: "Довгота", diff --git a/src/ui/options/components/modals/LocationDetailsFields.tsx b/src/ui/options/components/modals/LocationDetailsFields.tsx index 4a52e05..08921ba 100644 --- a/src/ui/options/components/modals/LocationDetailsFields.tsx +++ b/src/ui/options/components/modals/LocationDetailsFields.tsx @@ -31,6 +31,7 @@ type LocationFieldsDraft = { label: string; latitude: number; longitude: number; + countryCode?: string; accuracy: number; noiseRadius: number; language: string; @@ -78,6 +79,34 @@ const NameField = ({ ); +const CountryCodeField = ({ + draft, + onDraftChange, + disabled, +}: LocationFieldsProps) => ( +
+ + {t.common.fields.countryCode} + + { + const value = event.currentTarget.value.replace(/[^a-z]/gi, "").toUpperCase(); + onDraftChange((current) => { + const next = { ...current }; + if (value) next.countryCode = value; + else delete next.countryCode; + return next; + }); + }} + /> +
+); + const GeolocationFields = ({ draft, onDraftChange, @@ -139,6 +168,11 @@ const GeolocationFields = ({ /> +
void; + selectableUntil?: number; +}) => { + const fillScale = (active - 1) / (STEP_COUNT - 1); + return ( +
+
+
+
+ {Array.from({ length: STEP_COUNT }, (_, index) => { + const item = index + 1; + const reached = item <= active; + const className = cn( + "relative z-[1] grid h-[22px] w-[22px] place-items-center rounded-full border text-xs font-semibold leading-none transition-[background-color,border-color,color,transform] duration-300", + reached + ? "scale-100 border-primary bg-primary text-primary-foreground" + : "scale-95 border-border bg-background text-muted-foreground", + ); + if (!onSelect) { + return ( +
+ {item} +
+ ); + } + const stepLabel = stepLabels?.[index]; + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/ui/options/components/onboarding/welcome-wizard-visuals.tsx b/src/ui/options/components/onboarding/welcome-wizard-visuals.tsx index deae91e..0c99c64 100644 --- a/src/ui/options/components/onboarding/welcome-wizard-visuals.tsx +++ b/src/ui/options/components/onboarding/welcome-wizard-visuals.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import privacyPolicyMarkdown from "../../../../../PRIVACY.md?raw"; -import { cn } from "@/ui/components/lib/utils"; import { Dialog, DialogCloseButton, @@ -12,6 +11,7 @@ import { DialogTitle, } from "@/ui/components/ui/dialog"; import { t } from "@/ui/i18n"; +import { SetupProgress } from "@/ui/options/components/onboarding/setup-progress"; import type { WizardStep } from "@/ui/options/components/onboarding/WelcomeWizard"; import { NUMERIC_ROLLING_ALPHABET, @@ -37,34 +37,7 @@ const PARENT_PROGRESS: Record = { export const WizardProgress = ({ step }: { step: WizardStep }) => { const active = PARENT_PROGRESS[step]; if (!active) return null; - const fillScale = (active - 1) / 3; - return ( -
-
-
-
- {[1, 2, 3, 4].map((item) => ( -
- {item} -
- ))} -
-
- ); + return ; }; const OdometerDigit = ({ diff --git a/src/ui/options/components/tabs/AboutTab.tsx b/src/ui/options/components/tabs/AboutTab.tsx index 73c586d..dcaa303 100644 --- a/src/ui/options/components/tabs/AboutTab.tsx +++ b/src/ui/options/components/tabs/AboutTab.tsx @@ -37,7 +37,14 @@ const getReleaseChannelLabel = (channel: "local" | "beta" | "stable"): string => }; const renderAboutSubpage = ( - view: "privacyPolicy" | "thirdPartyNotices" | "license" | "none" | "logs" | null, + view: + | "privacyPolicy" + | "thirdPartyNotices" + | "license" + | "none" + | "logs" + | "experimentalIntegration" + | null, ) => { if (view === "privacyPolicy") return ( diff --git a/src/ui/options/components/tabs/AdvancedTab.tsx b/src/ui/options/components/tabs/AdvancedTab.tsx index 57eb75c..6d2467b 100644 --- a/src/ui/options/components/tabs/AdvancedTab.tsx +++ b/src/ui/options/components/tabs/AdvancedTab.tsx @@ -1,5 +1,10 @@ import React from "react"; +import { + ControlDFeatureToggle, + ControlDSubpage, + isIntegrationAvailable as isExperimentalIntegrationAvailable, +} from "@/experimental/control-d/ui-entry"; import { cn } from "@/ui/components/lib/utils"; import { getSettingDescriptionId, @@ -87,7 +92,11 @@ const RuntimeCard = () => { ); }; -const ExperimentalCard = () => { +const ExperimentalCard = ({ + onIntegrationToggle, +}: { + onIntegrationToggle: (enabled: boolean) => void; +}) => { const { featureFlags, highlightedAnchorId, @@ -169,6 +178,9 @@ const ExperimentalCard = () => { /> } /> + {isExperimentalIntegrationAvailable() ? ( + + ) : null} ); @@ -371,7 +383,7 @@ const AdvancedOverview = () => {
- + undefined} />
@@ -391,15 +403,22 @@ const AdvancedOverview = () => { export const AdvancedTab = () => { const { logsHostFilter, settingsSubpageView } = useSettings(); + const showExperiment = + settingsSubpageView === "experimentalIntegration" && + isExperimentalIntegrationAvailable(); + let content = ; + if (settingsSubpageView === "logs") { + content = ( + + + + ); + } else if (showExperiment) { + content = ; + } return ( - {settingsSubpageView === "logs" ? ( - - - - ) : ( - - )} + {content} ); }; diff --git a/src/ui/options/navigation.ts b/src/ui/options/navigation.ts index 6a6c210..915d411 100644 --- a/src/ui/options/navigation.ts +++ b/src/ui/options/navigation.ts @@ -36,6 +36,7 @@ export const PAGE_ANCHORS: Record = { export const SETTINGS_SUBPAGE_ANCHORS = { logs: "page-logs", + experimentalIntegration: "page-experimental-integration", privacyPolicy: "page-privacy-policy", thirdPartyNotices: "page-third-party-notices", license: "page-license", @@ -146,6 +147,7 @@ const STATIC_ANCHOR_TO_TAB: Record = { [PAGE_ANCHORS.advanced]: "advanced", [PAGE_ANCHORS.about]: "about", [SETTINGS_SUBPAGE_ANCHORS.logs]: "advanced", + [SETTINGS_SUBPAGE_ANCHORS.experimentalIntegration]: "advanced", [SETTINGS_SUBPAGE_ANCHORS.privacyPolicy]: "about", [SETTINGS_SUBPAGE_ANCHORS.thirdPartyNotices]: "about", [SETTINGS_SUBPAGE_ANCHORS.license]: "about", @@ -224,6 +226,7 @@ const ANCHOR_ALIASES: Record = { const SUBPAGE_VIEW_BY_ANCHOR: Partial> = { [SETTINGS_SUBPAGE_ANCHORS.logs]: "logs", + [SETTINGS_SUBPAGE_ANCHORS.experimentalIntegration]: "experimentalIntegration", [SETTINGS_SUBPAGE_ANCHORS.privacyPolicy]: "privacyPolicy", [SETTINGS_SUBPAGE_ANCHORS.thirdPartyNotices]: "thirdPartyNotices", [SETTINGS_SUBPAGE_ANCHORS.license]: "license", diff --git a/src/ui/options/state/use-settings-locations.ts b/src/ui/options/state/use-settings-locations.ts index 2fec272..85cb32e 100644 --- a/src/ui/options/state/use-settings-locations.ts +++ b/src/ui/options/state/use-settings-locations.ts @@ -234,6 +234,7 @@ const commitGeneratedLocation = async ( label: draft.label, latitude: draft.latitude, longitude: draft.longitude, + ...(draft.countryCode ? { countryCode: draft.countryCode } : {}), accuracy: draft.accuracy, noiseRadius: draft.noiseRadius, language: draft.language, diff --git a/src/ui/options/stories/ControlD.stories.tsx b/src/ui/options/stories/ControlD.stories.tsx new file mode 100644 index 0000000..6b18c54 --- /dev/null +++ b/src/ui/options/stories/ControlD.stories.tsx @@ -0,0 +1,410 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, within } from "storybook/test"; + +import { + CONTROL_D_COMMANDS, + type ControlDDiff, + type ControlDPreparedSnapshot, + type ControlDPublicState, + type ControlDRecoveryCandidate, +} from "../../../experimental/control-d/contracts"; +import { + ControlDFeatureToggle, + ControlDSubpage, +} from "../../../experimental/control-d/ui-entry"; + +import { EXTENSION_STORAGE_KEYS } from "@/shared/extension-contract"; +import { DEFAULT_PREFERENCES } from "@/shared/settings-defaults"; +import type { ThemeMode } from "@/shared/types"; +import { applyUiLocalePreference, type UiLocale } from "@/ui/i18n"; +import { AppPageFrame } from "@/ui/shared/AppPageFrame"; +import { ThemeProvider } from "@/ui/shared/ThemeProvider"; + +const baseState: ControlDPublicState = { + enabled: true, + connected: true, + autoSyncEnabled: true, + status: "ready", + hasApiKey: true, + setupStatus: "selected", + resourceCode: "ABCDE-FGHJK", + profileId: "profile-1", + endpointId: "device-1", + hasResolver: true, + resolverDoh: "https://dns.controld.com/private-resolver-token", + dnsStatus: "verified", + dnsVerifiedAt: "2026-09-10T14:30:00.000Z", + lastAttemptAt: "2026-09-10T14:28:00.000Z", + lastSuccessAt: "2026-09-10T14:28:00.000Z", + lastError: null, +}; + +const diff: ControlDDiff = { + createProfile: false, + createEndpoint: false, + createFolders: 0, + addRules: 0, + updateRules: 0, + deleteRules: 0, + unchangedRules: 6, + warnings: [], + mappings: [ + { + locationId: "warsaw", + locationLabel: "Warsaw", + ruleCount: 4, + proxyPk: "WAW", + status: "exact", + confirmed: true, + }, + { + locationId: "ottawa", + locationLabel: "Ottawa", + ruleCount: 1, + proxyPk: "YOW", + status: "exact", + confirmed: true, + }, + { + locationId: "paris", + locationLabel: "Paris", + ruleCount: 1, + proxyPk: "PAR", + status: "exact", + confirmed: true, + }, + ], + requiresApproximationConfirmation: false, +}; + +const proxies = [ + { + pk: "WAW", + city: "Warsaw", + countryCode: "PL", + countryName: "Poland", + latitude: 52.23, + longitude: 21.01, + }, + { + pk: "PAR", + city: "Paris", + countryCode: "FR", + countryName: "France", + latitude: 48.86, + longitude: 2.35, + }, + { + pk: "YOW", + city: "Ottawa", + countryCode: "CA", + countryName: "Canada", + latitude: 45.42, + longitude: -75.7, + }, +]; +const snapshot: ControlDPreparedSnapshot = { token: "story-preview", diff, proxies }; +const firstSnapshot: ControlDPreparedSnapshot = { + token: "first-preview", + proxies, + diff: { + ...diff, + createProfile: true, + createEndpoint: true, + createFolders: 2, + addRules: 6, + unchangedRules: 0, + }, +}; +const approximateSnapshot: ControlDPreparedSnapshot = { + token: "approximate-preview", + proxies: [ + ...proxies, + { + pk: "GRU", + city: "Sao Paulo", + countryCode: "BR", + countryName: "Brazil", + latitude: -23.55, + longitude: -46.63, + }, + ], + diff: { + ...firstSnapshot.diff, + warnings: [ + { + code: "approximate-location", + locationId: "rio", + message: "Control D has no exit in Brazil; Rio maps to Sao Paulo.", + }, + ], + mappings: [ + { + locationId: "rio", + locationLabel: "Rio de Janeiro", + ruleCount: 1, + proxyPk: "GRU", + status: "approximate", + confirmed: false, + }, + ], + requiresApproximationConfirmation: true, + }, +}; +const candidates: ControlDRecoveryCandidate[] = [ + { + code: "ABCDE-FGHJK", + profileId: "profile-1", + profileName: "Privacy Thing ABCDE-FGHJK", + endpointId: "device-1", + endpointName: "PT Browser ABCDE-FGHJK", + managedFolderCount: 3, + compatibility: "ready", + issue: null, + }, + { + code: "MNPQR-STVWX", + profileId: "profile-2", + profileName: "Privacy Thing MNPQR-STVWX", + endpointId: null, + endpointName: null, + managedFolderCount: 2, + compatibility: "profile-only", + issue: null, + }, +]; + +const installBoundary = ( + state: ControlDPublicState, + preparedSnapshot: ControlDPreparedSnapshot, + recoveryCandidates: ControlDRecoveryCandidate[], + themeMode: ThemeMode, +): void => { + Reflect.set(globalThis, "chrome", { + runtime: { + id: "storybook-control-d", + sendMessage: async (message: { type?: string }) => { + if ( + message.type === CONTROL_D_COMMANDS.discover || + message.type === CONTROL_D_COMMANDS.connect + ) { + return { ok: true, state, candidates: recoveryCandidates }; + } + if ( + [ + CONTROL_D_COMMANDS.preview, + CONTROL_D_COMMANDS.syncNow, + CONTROL_D_COMMANDS.apply, + CONTROL_D_COMMANDS.repair, + ].includes(message.type as never) + ) { + return { ok: true, state, snapshot: preparedSnapshot }; + } + return { ok: true, state }; + }, + getManifest: () => ({ + optional_host_permissions: ["https://api.controld.com/*"], + }), + getURL: (path: string) => path, + }, + permissions: { contains: async () => true, request: async () => true }, + storage: { + local: { + get: async () => ({ + [EXTENSION_STORAGE_KEYS.preferences]: { ...DEFAULT_PREFERENCES, themeMode }, + }), + set: async () => undefined, + remove: async () => undefined, + }, + onChanged: { addListener: () => undefined, removeListener: () => undefined }, + }, + tabs: { create: async () => undefined }, + }); +}; + +const Surface = ({ + state, + preparedSnapshot = snapshot, + recoveryCandidates = [], + themeMode = "light", +}: { + state: ControlDPublicState; + preparedSnapshot?: ControlDPreparedSnapshot; + recoveryCandidates?: ControlDRecoveryCandidate[]; + themeMode?: ThemeMode; +}) => { + installBoundary(state, preparedSnapshot, recoveryCandidates, themeMode); + return ( + + + + + + ); +}; + +const meta = { + title: "Options/Control D", + component: ControlDSubpage, + parameters: { layout: "fullscreen", privacyThing: { surface: "options" } }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const disconnected: ControlDPublicState = { + ...baseState, + connected: false, + autoSyncEnabled: false, + status: "disconnected", + hasApiKey: false, + setupStatus: "unselected", + resourceCode: null, + profileId: null, + endpointId: null, + hasResolver: false, + resolverDoh: null, + dnsStatus: "unavailable", + dnsVerifiedAt: null, + lastAttemptAt: null, + lastSuccessAt: null, +}; + +const choosing: ControlDPublicState = { + ...disconnected, + connected: true, + status: "ready", + hasApiKey: true, +}; +const firstSync: ControlDPublicState = { + ...baseState, + autoSyncEnabled: false, + profileId: null, + endpointId: null, + hasResolver: false, + resolverDoh: null, + dnsStatus: "unavailable", + dnsVerifiedAt: null, + lastAttemptAt: null, + lastSuccessAt: null, +}; +const dnsPending: ControlDPublicState = { + ...baseState, + dnsStatus: "pending", + dnsVerifiedAt: null, +}; + +export const Account: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const progress = canvas.getByLabelText("Setup progress"); + await expect(progress.querySelectorAll("button")).toHaveLength(4); + await expect(canvas.getByRole("button", { name: "Account" })).toBeEnabled(); + await expect(canvas.getByRole("button", { name: "Setup" })).toBeDisabled(); + await expect( + canvas.getByRole("button", { name: "API instructions" }), + ).toBeVisible(); + }, +}; +export const NoExistingSetup: Story = { render: () => }; +export const ExistingSetups: Story = { + render: () => , +}; +export const FirstSynchronization: Story = { + render: () => , +}; +export const ApproximateRoute: Story = { + render: () => , +}; +export const BrowserDns: Story = { render: () => }; +export const Active: Story = { render: () => }; +export const Conflict: Story = { + render: () => ( + + ), +}; +export const Syncing: Story = { + render: () => , +}; +export const DarkActive: Story = { + render: () => , +}; + +export const SelectInteraction: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Sao Paulo, Brazil")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Change" })); + const select = canvas.getByRole("combobox", { + name: "Exit for Rio de Janeiro", + }); + await userEvent.click(select); + await expect( + await within(document.body).findByRole("option", { name: "Warsaw, Poland" }), + ).toBeVisible(); + await userEvent.keyboard("{Escape}"); + }, +}; + +const StatusSurface = ({ + state, + locale, +}: { + state: ControlDPublicState; + locale: UiLocale; +}) => { + applyUiLocalePreference(locale); + installBoundary(state, snapshot, [], "light"); + return undefined} />; +}; + +const checkStatusTone = + (tone: string, locale: "es" | "pt" | "ru" | "uk") => + async ({ canvasElement }: { canvasElement: HTMLElement }) => { + try { + await expect(document.documentElement.lang).toBe(locale); + const canvas = within(canvasElement); + const badge = await canvas.findByText( + (_, element) => element?.getAttribute("data-control-d-tone") === tone, + ); + await expect(badge).toHaveClass(`text-tone-${tone}-text`); + } finally { + applyUiLocalePreference("en"); + } + }; + +export const AuthErrorStatus: Story = { + render: () => ( + + ), + play: checkStatusTone("error", "ru"), +}; +export const SyncErrorStatus: Story = { + render: () => , + play: checkStatusTone("error", "uk"), +}; +export const ConflictStatus: Story = { + render: () => ( + + ), + play: checkStatusTone("warning", "pt"), +}; +export const DnsPendingStatus: Story = { + render: () => , + play: checkStatusTone("warning", "es"), +}; diff --git a/tests/build-contracts/chromium-build.test.ts b/tests/build-contracts/chromium-build.test.ts index d3c6108..83caa01 100644 --- a/tests/build-contracts/chromium-build.test.ts +++ b/tests/build-contracts/chromium-build.test.ts @@ -13,11 +13,13 @@ import { } from "../../config/build-budgets"; import { BRAND_DISPLAY_NAME } from "../../scripts/brand-config.mjs"; +import { findControlDReleaseLeaks } from "./experimental-integrations"; import { findRetiredBuildLeaks } from "./retired-name"; type ChromiumManifest = { version?: string; version_name?: string; + optional_host_permissions?: string[]; content_scripts?: Array<{ all_frames?: boolean; js?: string[]; @@ -81,6 +83,18 @@ test("contains no retired namespace outside approved notification copy", async ( expect(await findRetiredBuildLeaks("chrome")).toEqual([]); }); +test("keeps the Control D experiment out of release artifacts", async () => { + const manifest = await readChromiumManifest(); + if ( + manifest.version_name?.endsWith("-local") || + manifest.version_name?.endsWith("-beta") + ) + return; + + expect(manifest.optional_host_permissions).toBeUndefined(); + expect(await findControlDReleaseLeaks("chrome")).toEqual([]); +}); + test("exposes only the runtime-applied marker to downstream CI jobs", async () => { const markerPath = path.resolve( process.cwd(), @@ -112,13 +126,15 @@ test("does not expose worker bootstrap resources in the chromium manifest", asyn test("stamps chromium manifests for release, local, and beta builds", async () => { const manifest = await readChromiumManifest(); - if (manifest.version_name) { + if (/-(local|beta)$/.test(manifest.version_name ?? "")) { expect(manifest.version_name).toMatch(/^0\.\d{4}\.\d{3,4}\.\d{1,4}-(local|beta)$/); expect(manifest.version).toMatch(/^0\.\d{4}\.\d{3,4}\.\d{1,4}$/); return; } expect(manifest.version).toMatch(/^\d+\.\d+\.\d+(?:\.\d+)?$/); + if (manifest.version_name) + expect(manifest.version_name).toMatch(/^\d+\.\d+\.\d+(?:\.\d+)?$/); }); test("uses static content script bundles instead of async loader stubs", async () => { diff --git a/tests/build-contracts/experimental-integrations.ts b/tests/build-contracts/experimental-integrations.ts new file mode 100644 index 0000000..bdb5a73 --- /dev/null +++ b/tests/build-contracts/experimental-integrations.ts @@ -0,0 +1,41 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +const CONTROL_D_MARKERS = [ + "Control D", + "ControlD", + "controld.com", + "control-d", + "pt.control-d", + "experimental/control-d", +] as const; + +const listFiles = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(directory, entry.name); + return entry.isDirectory() ? listFiles(entryPath) : [entryPath]; + }), + ); + return nested.flat(); +}; + +export const findControlDReleaseLeaks = async ( + target: "chrome" | "firefox", +): Promise> => { + const root = path.resolve(process.cwd(), "build", target); + const files = await listFiles(root); + const leaks: Array<{ file: string; marker: string }> = []; + + for (const file of files) { + const content = await readFile(file); + const text = content.toString("utf8"); + for (const marker of CONTROL_D_MARKERS) { + if (text.includes(marker)) { + leaks.push({ file: path.relative(root, file), marker }); + } + } + } + return leaks; +}; diff --git a/tests/build-contracts/firefox-build.test.ts b/tests/build-contracts/firefox-build.test.ts index 8f6197d..4ad192e 100644 --- a/tests/build-contracts/firefox-build.test.ts +++ b/tests/build-contracts/firefox-build.test.ts @@ -14,11 +14,13 @@ import { STABLE_FX_EXT_ID, } from "../../scripts/brand-config.mjs"; +import { findControlDReleaseLeaks } from "./experimental-integrations"; import { findRetiredBuildLeaks } from "./retired-name"; type FirefoxManifest = { version?: string; version_name?: string; + optional_host_permissions?: string[]; background?: { scripts?: string[]; service_worker?: string; @@ -60,9 +62,21 @@ test("contains no retired namespace outside approved Firefox IDs and notificatio expect(await findRetiredBuildLeaks("firefox")).toEqual([]); }); +test("keeps the Control D experiment out of release artifacts", async () => { + const manifest = await readFirefoxManifest(); + if ( + manifest.version_name?.endsWith("-local") || + manifest.version_name?.endsWith("-beta") + ) + return; + + expect(manifest.optional_host_permissions).toBeUndefined(); + expect(await findControlDReleaseLeaks("firefox")).toEqual([]); +}); + test("builds a firefox artifact with gecko settings and script-injection fallback", async () => { const manifest = await readFirefoxManifest(); - const isNonReleaseBuild = Boolean(manifest.version_name); + const isNonReleaseBuild = /-(local|beta)$/.test(manifest.version_name ?? ""); expect(manifest.minimum_chrome_version).toBeUndefined(); expect(manifest.browser_specific_settings?.gecko?.id).toBe( @@ -146,13 +160,15 @@ test("does not emit or bundle Chromium Battery support", async () => { test("stamps firefox manifests for release, local, and beta builds", async () => { const manifest = await readFirefoxManifest(); - if (manifest.version_name) { + if (/-(local|beta)$/.test(manifest.version_name ?? "")) { expect(manifest.version_name).toMatch(/^0\.\d{4}\.\d{3,4}\.\d{1,4}-(local|beta)$/); expect(manifest.version).toMatch(/^0\.\d{4}\.\d{3,4}\.\d{1,4}$/); return; } expect(manifest.version).toMatch(/^\d+\.\d+\.\d+(?:\.\d+)?$/); + if (manifest.version_name) + expect(manifest.version_name).toMatch(/^\d+\.\d+\.\d+(?:\.\d+)?$/); }); test("compiled firefox page-world scripts do not contain product-identifying channel strings", async () => { diff --git a/tests/e2e/extension-options-navigation.spec.ts b/tests/e2e/extension-options-navigation.spec.ts index 6401cc5..26a89ac 100644 --- a/tests/e2e/extension-options-navigation.spec.ts +++ b/tests/e2e/extension-options-navigation.spec.ts @@ -14,6 +14,7 @@ import { expectAnchorInViewport, importSettings, openSettingsTab, + saveSimpleSettings, } from "./extension-test.helpers"; import { expect, test } from "./fixtures"; @@ -32,6 +33,27 @@ test("loads the options page from the extension", async ({ context, extensionId await expect(page.locator("#rules-preview-hostname-preview")).toHaveCount(0); await openSettingsTab(page, "advanced"); await expect(page.locator("#export-settings")).toBeVisible(); + const hasControlDPermission = await page.evaluate(() => + (chrome.runtime.getManifest().optional_host_permissions ?? []).includes( + "https://api.controld.com/*", + ), + ); + const controlDToggle = page.locator("[data-control-d-toggle]"); + await expect(controlDToggle).toHaveCount(hasControlDPermission ? 1 : 0); + if (hasControlDPermission) { + await expect(page.locator("[data-control-d-state]")).toHaveCount(0); + await controlDToggle.click(); + await expect(controlDToggle).toHaveAttribute("data-state", "checked"); + await page.locator("[data-control-d-open]").click(); + await expect(page.locator("[data-control-d-state]")).toHaveAttribute( + "data-control-d-state", + "disconnected", + ); + await expect(page.locator("[data-control-d-api-key]")).toHaveAttribute( + "type", + "password", + ); + } await openSettingsTab(page, "about"); await expect(page.locator("#about-version")).toHaveText(/^\d+\.\d+/); await expect(page.getByRole("link", { name: "Tomasz Janusz" })).toHaveAttribute( @@ -51,6 +73,106 @@ test("loads the options page from the extension", async ({ context, extensionId ).toHaveCount(0); }); +test("keeps Control D credentials in the extension origin and rejects page commands", async ({ + context, + extensionId, + serverUrl, +}) => { + const options = await context.newPage(); + await options.goto(`chrome-extension://${extensionId}/src/ui/options/index.html`); + const supported = await options.evaluate(() => + (chrome.runtime.getManifest().optional_host_permissions ?? []).includes( + "https://api.controld.com/*", + ), + ); + if (!supported) return; + const migrated = await options.evaluate(async () => { + const legacyKey = "pt.experimental.control-d.v2.api-key"; + await chrome.storage.local.set({ [legacyKey]: "test-only-credential" }); + const response = await chrome.runtime.sendMessage({ + type: "pt.control-d.get-state", + }); + return { response, legacy: (await chrome.storage.local.get(legacyKey))[legacyKey] }; + }); + expect(migrated.response).toMatchObject({ ok: true, state: { hasApiKey: true } }); + expect(migrated.legacy).toBeUndefined(); + const site = await context.newPage(); + await site.goto(serverUrl); + const tabId = await options.evaluate( + async (url) => (await chrome.tabs.query({})).find((tab) => tab.url === url)?.id, + site.url(), + ); + expect(tabId).toBeDefined(); + const [result] = await options.evaluate( + async (id) => + chrome.scripting.executeScript({ + target: { tabId: id! }, + world: "ISOLATED", + func: async () => { + let localDenied = false; + try { + await chrome.storage.local.get("pt.experimental.control-d.v2.api-key"); + } catch { + localDenied = true; + } + let commandRejected: boolean; + try { + const response = await chrome.runtime.sendMessage({ + type: "pt.control-d.disconnect", + }); + commandRejected = response?.ok !== true; + } catch { + commandRejected = true; + } + const hasPrivateStore = await new Promise((resolve, reject) => { + const request = indexedDB.open("pt.experimental.control-d.credentials", 1); + request.onsuccess = () => { + const hasStore = request.result.objectStoreNames.contains("keys"); + request.result.close(); + resolve(hasStore); + }; + request.onerror = () => reject(request.error); + }); + return { localDenied, commandRejected, hasPrivateStore }; + }, + }), + tabId, + ); + expect(result?.result).toEqual({ + localDenied: true, + commandRejected: true, + hasPrivateStore: false, + }); + const disconnected = await options.evaluate(() => + chrome.runtime.sendMessage({ type: "pt.control-d.disconnect" }), + ); + expect(disconnected).toMatchObject({ ok: true, state: { hasApiKey: false } }); +}); + +test("shows Control D actions in View Logs when debug mode is enabled", async ({ + context, + extensionId, +}) => { + const page = await context.newPage(); + const optionsUrl = `chrome-extension://${extensionId}/src/ui/options/index.html`; + await page.goto(optionsUrl); + await saveSimpleSettings(page, { debugMode: true }); + await openSettingsTab(page, "advanced"); + + const controlDToggle = page.locator("[data-control-d-toggle]"); + if ((await controlDToggle.count()) === 0) return; + + await controlDToggle.click(); + await expect( + page.getByRole("heading", { name: "Control D", exact: true }), + ).toBeVisible(); + await page.goto(`${optionsUrl}#page-logs`); + + await expect( + page.getByText("control-d.integration.toggled", { exact: true }), + ).toBeVisible(); +}); + test("keeps the selected settings tab in the URL across reloads", async ({ context, extensionId, diff --git a/tests/e2e/firefox-runtime-control-d.spec.ts b/tests/e2e/firefox-runtime-control-d.spec.ts new file mode 100644 index 0000000..d7de180 --- /dev/null +++ b/tests/e2e/firefox-runtime-control-d.spec.ts @@ -0,0 +1,43 @@ +import { expect } from "@playwright/test"; + +import { openFxOptionsProbe, test } from "./firefox-runtime.shared"; + +test("Firefox Control D migrates and forgets credentials in private extension storage", async ({ + context, + extensionOrigin, + debuggerPort, +}) => { + const options = await openFxOptionsProbe({ context, extensionOrigin, debuggerPort }); + const migrated = await options.evaluate<{ + response: { ok: boolean; state: { hasApiKey: boolean } }; + legacyPresent: boolean; + privateKeyPresent: boolean; + }>(`(async () => { + const key = "pt.experimental.control-d.v2.api-key"; + await chrome.storage.local.set({ [key]: "test-only-credential" }); + const response = await chrome.runtime.sendMessage({ type: "pt.control-d.get-state" }); + const legacyPresent = (await chrome.storage.local.get(key))[key] !== undefined; + const privateKeyPresent = await new Promise((resolve, reject) => { + const request = indexedDB.open("pt.experimental.control-d.credentials", 1); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction("keys", "readonly"); + const entry = transaction.objectStore("keys").get("api-key"); + transaction.oncomplete = () => { + database.close(); + resolve(entry.result === "test-only-credential"); + }; + transaction.onabort = () => reject(transaction.error); + }; + request.onerror = () => reject(request.error); + }); + return { response, legacyPresent, privateKeyPresent }; + })()`); + expect(migrated.response).toMatchObject({ ok: true, state: { hasApiKey: true } }); + expect(migrated.legacyPresent).toBe(false); + expect(migrated.privateKeyPresent).toBe(true); + const disconnected = await options.evaluate( + `chrome.runtime.sendMessage({ type: "pt.control-d.disconnect" })`, + ); + expect(disconnected).toMatchObject({ ok: true, state: { hasApiKey: false } }); +});