@fairfox/polly 0.20.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -3
- package/dist/cli/polly.js +21 -1
- package/dist/cli/polly.js.map +3 -3
- package/dist/src/background/index.js.map +7 -7
- package/dist/src/background/message-router.js.map +7 -7
- package/dist/src/elysia/index.d.ts +2 -0
- package/dist/src/elysia/index.js +177 -17
- package/dist/src/elysia/index.js.map +8 -5
- package/dist/src/elysia/peer-repo-plugin.d.ts +79 -0
- package/dist/src/elysia/signaling-server-plugin.d.ts +121 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +90 -1
- package/dist/src/index.js.map +15 -13
- package/dist/src/mesh.d.ts +29 -0
- package/dist/src/mesh.js +1502 -0
- package/dist/src/mesh.js.map +22 -0
- package/dist/src/peer.d.ts +29 -0
- package/dist/src/peer.js +928 -0
- package/dist/src/peer.js.map +20 -0
- package/dist/src/shared/adapters/index.js.map +6 -6
- package/dist/src/shared/lib/_client-only.d.ts +38 -0
- package/dist/src/shared/lib/access.d.ts +124 -0
- package/dist/src/shared/lib/blob-ref.d.ts +72 -0
- package/dist/src/shared/lib/context-helpers.js.map +7 -7
- package/dist/src/shared/lib/crdt-specialised.d.ts +129 -0
- package/dist/src/shared/lib/crdt-state.d.ts +86 -0
- package/dist/src/shared/lib/encryption.d.ts +117 -0
- package/dist/src/shared/lib/mesh-network-adapter.d.ts +130 -0
- package/dist/src/shared/lib/mesh-signaling-client.d.ts +85 -0
- package/dist/src/shared/lib/mesh-state.d.ts +102 -0
- package/dist/src/shared/lib/mesh-webrtc-adapter.d.ts +132 -0
- package/dist/src/shared/lib/message-bus.js.map +7 -7
- package/dist/src/shared/lib/migrate-primitive.d.ts +100 -0
- package/dist/src/shared/lib/pairing.d.ts +170 -0
- package/dist/src/shared/lib/peer-relay-adapter.d.ts +80 -0
- package/dist/src/shared/lib/peer-repo-server.d.ts +83 -0
- package/dist/src/shared/lib/peer-state.d.ts +117 -0
- package/dist/src/shared/lib/primitive-registry.d.ts +88 -0
- package/dist/src/shared/lib/resource.js.map +4 -4
- package/dist/src/shared/lib/revocation.d.ts +126 -0
- package/dist/src/shared/lib/schema-version.d.ts +129 -0
- package/dist/src/shared/lib/signing.d.ts +118 -0
- package/dist/src/shared/lib/state.js.map +4 -4
- package/dist/src/shared/state/app-state.js.map +5 -5
- package/dist/tools/init/src/cli.js.map +1 -1
- package/dist/tools/quality/src/cli.js +162 -0
- package/dist/tools/quality/src/cli.js.map +11 -0
- package/dist/tools/test/src/adapters/index.js.map +2 -2
- package/dist/tools/test/src/browser/harness.d.ts +80 -0
- package/dist/tools/test/src/browser/index.d.ts +32 -0
- package/dist/tools/test/src/browser/index.js +243 -0
- package/dist/tools/test/src/browser/index.js.map +10 -0
- package/dist/tools/test/src/browser/run.d.ts +26 -0
- package/dist/tools/test/src/index.js.map +2 -2
- package/dist/tools/verify/specs/tla/MeshState.cfg +21 -0
- package/dist/tools/verify/specs/tla/MeshState.tla +247 -0
- package/dist/tools/verify/specs/tla/PeerState.cfg +27 -0
- package/dist/tools/verify/specs/tla/PeerState.tla +238 -0
- package/dist/tools/verify/specs/tla/README.md +27 -3
- package/dist/tools/verify/src/cli.js.map +8 -8
- package/dist/tools/visualize/src/cli.js.map +7 -7
- package/package.json +51 -5
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Browser test runner for Polly applications.
|
|
4
|
+
*
|
|
5
|
+
* Finds all *.browser.ts files in a given directory, bundles each with
|
|
6
|
+
* Bun.build for the browser target (with an internal Automerge WASM fix),
|
|
7
|
+
* serves the bundle on an ephemeral port, opens a Puppeteer page, and
|
|
8
|
+
* polls window.__done for results. Prints pass/fail per test and exits
|
|
9
|
+
* non-zero if any test failed.
|
|
10
|
+
*
|
|
11
|
+
* A signalling server for WebRTC tests starts automatically on a random
|
|
12
|
+
* port. The URL is injected into the bundle via process.env.SIGNALING_URL.
|
|
13
|
+
*
|
|
14
|
+
* Usage (from project root):
|
|
15
|
+
*
|
|
16
|
+
* bun tools/test/src/browser/run.ts [testDir] [filter]
|
|
17
|
+
*
|
|
18
|
+
* Examples:
|
|
19
|
+
*
|
|
20
|
+
* bun tools/test/src/browser/run.ts tests/browser
|
|
21
|
+
* bun tools/test/src/browser/run.ts tests/browser mesh-webrtc
|
|
22
|
+
* HEADLESS=false bun tools/test/src/browser/run.ts tests/browser
|
|
23
|
+
*
|
|
24
|
+
* When invoked without a testDir, defaults to tests/browser relative to cwd.
|
|
25
|
+
*/
|
|
26
|
+
export {};
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
"sources": ["../tools/test/src/adapters/context-menus.mock.ts", "../tools/test/src/adapters/fetch.mock.ts", "../tools/test/src/adapters/logger.mock.ts", "../tools/test/src/adapters/offscreen.mock.ts", "../tools/test/src/adapters/runtime.mock.ts", "../tools/test/src/adapters/storage.mock.ts", "../tools/test/src/adapters/tabs.mock.ts", "../tools/test/src/adapters/window.mock.ts", "../tools/test/src/adapters/index.ts", "../tools/test/src/test-utils.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
5
|
"import type { ContextMenusAdapter } from \"@/shared/adapters/context-menus.adapter\";\n\nexport interface MockContextMenus extends ContextMenusAdapter {\n _menus: Map<string, chrome.contextMenus.CreateProperties>;\n}\n\nexport function createMockContextMenus(): MockContextMenus {\n const menus = new Map<string, chrome.contextMenus.CreateProperties>();\n\n return {\n create: async (createProperties: chrome.contextMenus.CreateProperties): Promise<void> => {\n if (createProperties.id) {\n menus.set(createProperties.id, createProperties);\n }\n },\n update: async (\n _id: string,\n _updateProperties: Omit<chrome.contextMenus.CreateProperties, \"id\">\n ): Promise<void> => {\n // Mock implementation\n },\n remove: async (_id: string): Promise<void> => {\n // Mock implementation\n },\n removeAll: async (): Promise<void> => {\n // Mock implementation\n },\n onClicked: (\n _callback: (info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) => void\n ): void => {\n // Mock implementation\n },\n _menus: menus,\n };\n}\n",
|
|
6
|
-
"import type { FetchAdapter } from \"@/shared/adapters/fetch.adapter\";\n\nexport interface MockFetch extends FetchAdapter {\n _responses: Array<Partial<Response>>;\n _calls: Array<{ input: string | URL; init?: RequestInit }>;\n}\n\nexport function createMockFetch(): MockFetch {\n const responses: Array<Partial<Response>> = [];\n const calls: Array<{ input: string | URL; init?: RequestInit }> = [];\n\n return {\n fetch: async (input: string | URL, init?: RequestInit): Promise<Response> => {\n calls.push({ input, ...(init && { init }) });\n\n const mockResponse = responses.shift() || {\n ok: true,\n status: 200,\n headers: new Headers(),\n statusText: \"OK\",\n json: async () => ({}),\n text: async () => \"\",\n blob: async () => new Blob(),\n arrayBuffer: async () => new ArrayBuffer(0),\n formData: async () => new FormData(),\n };\n\n return mockResponse as Response;\n },\n _responses: responses,\n _calls: calls,\n };\n}\n",
|
|
6
|
+
"import type { FetchAdapter } from \"@/shared/adapters/fetch.adapter\";\n\nexport interface MockFetch extends FetchAdapter {\n _responses: Array<Partial<Response>>;\n _calls: Array<{ input: string | URL; init?: RequestInit }>;\n}\n\nexport function createMockFetch(): MockFetch {\n const responses: Array<Partial<Response>> = [];\n const calls: Array<{ input: string | URL; init?: RequestInit }> = [];\n\n return {\n fetch: async (input: string | URL, init?: RequestInit): Promise<Response> => {\n calls.push({ input, ...(init && { init }) });\n\n const mockResponse = responses.shift() || {\n ok: true,\n status: 200,\n headers: new Headers(),\n statusText: \"OK\",\n json: async () => ({}),\n text: async () => \"\",\n blob: async () => new Blob(),\n arrayBuffer: async () => new ArrayBuffer(0),\n formData: async () => new FormData(),\n };\n\n return mockResponse as unknown as Response;\n },\n _responses: responses,\n _calls: calls,\n };\n}\n",
|
|
7
7
|
"// Mock logger adapter for testing\nimport type { LoggerAdapter } from \"@/shared/adapters/logger.adapter\";\nimport type { LogLevel } from \"@/shared/types/messages\";\n\nexport interface LogCall {\n level: LogLevel;\n message: string;\n context?: Record<string, unknown>;\n error?: Error;\n timestamp: number;\n}\n\nexport interface MockLogger extends LoggerAdapter {\n _calls: LogCall[];\n _clear(): void;\n}\n\nexport function createMockLogger(options?: { silent?: boolean }): MockLogger {\n const calls: LogCall[] = [];\n const silent = options?.silent ?? true;\n\n const logToConsole = (level: LogLevel, message: string, context?: Record<string, unknown>) => {\n if (!silent) {\n // biome-ignore lint/suspicious/noConsole: Mock logger intentionally uses console for testing\n const consoleMethod = level === \"debug\" ? console.log : console[level];\n consoleMethod(message, context);\n }\n };\n\n return {\n debug(message: string, context?: Record<string, unknown>): void {\n calls.push({\n level: \"debug\",\n message,\n ...(context && { context }),\n timestamp: Date.now(),\n });\n logToConsole(\"debug\", message, context);\n },\n\n info(message: string, context?: Record<string, unknown>): void {\n calls.push({\n level: \"info\",\n message,\n ...(context && { context }),\n timestamp: Date.now(),\n });\n logToConsole(\"info\", message, context);\n },\n\n warn(message: string, context?: Record<string, unknown>): void {\n calls.push({\n level: \"warn\",\n message,\n ...(context && { context }),\n timestamp: Date.now(),\n });\n logToConsole(\"warn\", message, context);\n },\n\n error(message: string, error?: Error, context?: Record<string, unknown>): void {\n calls.push({\n level: \"error\",\n message,\n ...(error && { error }),\n ...(context && { context }),\n timestamp: Date.now(),\n });\n logToConsole(\"error\", message, { ...context, error });\n },\n\n log(level: LogLevel, message: string, context?: Record<string, unknown>): void {\n calls.push({\n level,\n message,\n ...(context && { context }),\n timestamp: Date.now(),\n });\n logToConsole(level, message, context);\n },\n\n // Test-only internals\n _calls: calls,\n _clear() {\n calls.length = 0;\n },\n };\n}\n",
|
|
8
8
|
"import type {\n CreateOffscreenDocumentParameters,\n OffscreenAdapter,\n} from \"@/shared/adapters/offscreen.adapter\";\n\nexport interface MockOffscreen extends OffscreenAdapter {\n _hasDocument: boolean;\n}\n\nexport function createMockOffscreen(): MockOffscreen {\n let hasDocument = false;\n\n return {\n createDocument: async (_parameters: CreateOffscreenDocumentParameters): Promise<void> => {\n hasDocument = true;\n },\n closeDocument: async (): Promise<void> => {\n hasDocument = false;\n },\n hasDocument: async (): Promise<boolean> => {\n return hasDocument;\n },\n _hasDocument: hasDocument,\n };\n}\n",
|
|
9
9
|
"import type { MessageSender, PortAdapter, RuntimeAdapter } from \"@/shared/adapters/runtime.adapter\";\n\nexport interface MockPort extends PortAdapter {\n _listeners: Set<(message: unknown) => void>;\n _disconnectListeners: Set<() => void>;\n}\n\nexport function createMockPort(name: string): MockPort {\n const listeners = new Set<(message: unknown) => void>();\n const disconnectListeners = new Set<() => void>();\n\n return {\n name,\n onMessage: (callback) => listeners.add(callback),\n onDisconnect: (callback) => disconnectListeners.add(callback),\n postMessage: (message) => {\n for (const listener of listeners) {\n listener(message);\n }\n },\n disconnect: () => {\n for (const listener of disconnectListeners) {\n listener();\n }\n },\n _listeners: listeners,\n _disconnectListeners: disconnectListeners,\n };\n}\n\nexport interface MockRuntime extends RuntimeAdapter {\n id: string;\n _messageListeners: Set<\n (message: unknown, sender: MessageSender, sendResponse: (response: unknown) => void) => void\n >;\n _connectListeners: Set<(port: PortAdapter) => void>;\n}\n\nexport function createMockRuntime(id = \"test-extension-id\"): MockRuntime {\n const messageListeners = new Set<\n (message: unknown, sender: MessageSender, sendResponse: (response: unknown) => void) => void\n >();\n const connectListeners = new Set<(port: PortAdapter) => void>();\n\n return {\n id,\n sendMessage: async <T>(message: T): Promise<unknown> => {\n // Check if this is a response message\n if (typeof message === \"object\" && message !== null && \"success\" in message) {\n // This is a response, route it back to all listeners\n for (const listener of messageListeners) {\n listener(message, { url: \"\" }, () => {\n // Empty response handler for mock\n });\n }\n return undefined;\n }\n\n // This is a request, call ALL listeners (Chrome calls all, but only first response is used)\n if (messageListeners.size === 0) {\n return undefined;\n }\n\n return new Promise((resolve) => {\n let resolved = false;\n const sharedSendResponse = (res: unknown) => {\n if (!resolved) {\n resolved = true;\n resolve(res);\n }\n };\n\n // Call all listeners (Chrome behavior)\n for (const listener of messageListeners) {\n const result = listener(message, { url: \"\" }, sharedSendResponse);\n // If listener returns true, it will send response asynchronously\n // If it returns false/undefined/void and we haven't resolved yet, continue to next listener\n if (typeof result === \"boolean\" && result === true) {\n // Listener will send response asynchronously, wait for it\n }\n }\n\n // If no listener handled it, resolve with undefined\n if (!resolved) {\n resolve(undefined);\n }\n });\n },\n onMessage: (\n callback: (\n message: unknown,\n sender: MessageSender,\n sendResponse: (response: unknown) => void\n ) => undefined | boolean\n ) => {\n messageListeners.add(callback);\n },\n removeMessageListener: (\n callback: (\n message: unknown,\n sender: MessageSender,\n sendResponse: (response: unknown) => void\n ) => undefined | boolean\n ) => {\n messageListeners.delete(callback);\n },\n connect: (name: string): PortAdapter => {\n const port = createMockPort(name);\n for (const listener of connectListeners) {\n listener(port);\n }\n return port;\n },\n onConnect: (callback: (port: PortAdapter) => void) => {\n connectListeners.add(callback);\n },\n getURL: (path: string): string => {\n return `chrome-extension://${id}/${path}`;\n },\n getId: (): string => {\n return id;\n },\n openOptionsPage: (): void => {\n // Mock implementation - no-op for tests\n },\n _messageListeners: messageListeners,\n _connectListeners: connectListeners,\n };\n}\n",
|
|
10
|
-
"import type { StorageAdapter, StorageChanges } from \"@/shared/adapters/storage.adapter\";\n\nexport interface MockStorageArea extends StorageAdapter {\n _data: Map<string, unknown>;\n}\n\nexport function createMockStorageArea(): MockStorageArea {\n const data = new Map<string, unknown>();\n\n return {\n get: async <T = Record<string, unknown>>(\n keys?: string | string[] | Record<string, unknown> | null\n // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Mock storage needs to handle multiple key types\n ): Promise<T> => {\n if (!keys) {\n return Object.fromEntries(data) as T;\n }\n if (typeof keys === \"string\") {\n return (data.has(keys) ? { [keys]: data.get(keys) } : {}) as T;\n }\n if (Array.isArray(keys)) {\n const result: Record<string, unknown> = {};\n for (const key of keys) {\n if (data.has(key)) {\n result[key] = data.get(key);\n }\n }\n return result as T;\n }\n // Object with defaults\n const result: Record<string, unknown> = {};\n for (const [key, defaultValue] of Object.entries(keys)) {\n result[key] = data.has(key) ? data.get(key) : defaultValue;\n }\n return result as T;\n },\n set: async (items) => {\n for (const [key, value] of Object.entries(items)) {\n data.set(key, value);\n }\n },\n remove: async (keys) => {\n const keyArray = Array.isArray(keys) ? keys : [keys];\n for (const key of keyArray) {\n data.delete(key);\n }\n },\n clear: async () => {\n data.clear();\n },\n onChanged: (_callback: (changes: StorageChanges, areaName: string) => void) => {\n // Mock implementation - not needed for current tests\n },\n _data: data,\n };\n}\n",
|
|
10
|
+
"import type { StorageAdapter, StorageChanges } from \"@/shared/adapters/storage.adapter\";\n\nexport interface MockStorageArea extends StorageAdapter {\n _data: Map<string, unknown>;\n}\n\nexport function createMockStorageArea(): MockStorageArea {\n const data = new Map<string, unknown>();\n\n return {\n get: async <T = Record<string, unknown>>(\n keys?: string | string[] | Record<string, unknown> | null\n // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Mock storage needs to handle multiple key types\n ): Promise<T> => {\n if (!keys) {\n return Object.fromEntries(data) as T;\n }\n if (typeof keys === \"string\") {\n return (data.has(keys) ? { [keys]: data.get(keys) } : {}) as T;\n }\n if (Array.isArray(keys)) {\n const result: Record<string, unknown> = {};\n for (const key of keys) {\n if (data.has(key)) {\n result[key] = data.get(key);\n }\n }\n return result as unknown as T;\n }\n // Object with defaults\n const result: Record<string, unknown> = {};\n for (const [key, defaultValue] of Object.entries(keys)) {\n result[key] = data.has(key) ? data.get(key) : defaultValue;\n }\n return result as unknown as T;\n },\n set: async (items) => {\n for (const [key, value] of Object.entries(items)) {\n data.set(key, value);\n }\n },\n remove: async (keys) => {\n const keyArray = Array.isArray(keys) ? keys : [keys];\n for (const key of keyArray) {\n data.delete(key);\n }\n },\n clear: async () => {\n data.clear();\n },\n onChanged: (_callback: (changes: StorageChanges, areaName: string) => void) => {\n // Mock implementation - not needed for current tests\n },\n _data: data,\n };\n}\n",
|
|
11
11
|
"import type { TabsAdapter } from \"@/shared/adapters/tabs.adapter\";\n\nexport interface MockTabs extends TabsAdapter {\n _tabs: Map<number, chrome.tabs.Tab>;\n}\n\nexport function createMockTabs(): MockTabs {\n const tabs = new Map<number, chrome.tabs.Tab>();\n\n return {\n query: async (queryInfo: chrome.tabs.QueryInfo): Promise<chrome.tabs.Tab[]> => {\n const results: chrome.tabs.Tab[] = [];\n for (const tab of tabs.values()) {\n let matches = true;\n if (queryInfo.active !== undefined && tab.active !== queryInfo.active) {\n matches = false;\n }\n if (queryInfo.currentWindow !== undefined) {\n matches = false;\n }\n if (matches) {\n results.push(tab);\n }\n }\n return results;\n },\n get: async (tabId: number): Promise<chrome.tabs.Tab> => {\n const tab = tabs.get(tabId);\n if (!tab) {\n throw new Error(`Tab ${tabId} not found`);\n }\n return tab;\n },\n sendMessage: async (_tabId: number, _message: unknown): Promise<unknown> => {\n return Promise.resolve({ success: true });\n },\n reload: async (\n _tabId: number,\n _reloadProperties?: { bypassCache?: boolean }\n ): Promise<void> => {\n // Mock implementation\n },\n onRemoved: (\n _callback: (tabId: number, removeInfo: chrome.tabs.OnRemovedInfo) => void\n ): void => {\n // Mock implementation - register listener\n },\n onUpdated: (\n _callback: (\n tabId: number,\n changeInfo: chrome.tabs.OnUpdatedInfo,\n tab: chrome.tabs.Tab\n ) => void\n ): void => {\n // Mock implementation - register listener\n },\n onActivated: (_callback: (activeInfo: { tabId: number; windowId: number }) => void): void => {\n // Mock implementation - register listener\n },\n create: async (createProperties: chrome.tabs.CreateProperties): Promise<chrome.tabs.Tab> => {\n const newTab: chrome.tabs.Tab = {\n id: Math.floor(Math.random() * 10000),\n index: tabs.size,\n pinned: false,\n highlighted: false,\n windowId: 1,\n active: true,\n incognito: false,\n selected: true,\n discarded: false,\n autoDiscardable: true,\n groupId: -1,\n url: createProperties.url || \"about:blank\",\n title: createProperties.url || \"New Tab\",\n frozen: false,\n };\n if (newTab.id !== undefined) {\n tabs.set(newTab.id, newTab);\n }\n return newTab;\n },\n _tabs: tabs,\n };\n}\n",
|
|
12
12
|
"import type { WindowAdapter } from \"@/shared/adapters/window.adapter\";\n\nexport interface MockWindow extends WindowAdapter {\n _messageListeners: Set<(event: MessageEvent) => void>;\n}\n\nexport function createMockWindow(): MockWindow {\n const messageListeners = new Set<(event: MessageEvent) => void>();\n\n return {\n postMessage: (message: unknown, targetOrigin: string) => {\n const event = new MessageEvent(\"message\", {\n data: message,\n origin: targetOrigin,\n source: null,\n });\n for (const listener of messageListeners) {\n listener(event);\n }\n },\n addEventListener: (type: string, listener: (event: MessageEvent) => void) => {\n if (type === \"message\") {\n messageListeners.add(listener);\n }\n },\n removeEventListener: (type: string, listener: (event: MessageEvent) => void) => {\n if (type === \"message\") {\n messageListeners.delete(listener);\n }\n },\n _messageListeners: messageListeners,\n };\n}\n",
|
|
13
13
|
"import { createMockContextMenus, type MockContextMenus } from \"./context-menus.mock\";\nimport { createMockFetch, type MockFetch } from \"./fetch.mock\";\nimport { createMockLogger, type MockLogger } from \"./logger.mock\";\nimport { createMockOffscreen, type MockOffscreen } from \"./offscreen.mock\";\nimport { createMockPort, createMockRuntime, type MockPort, type MockRuntime } from \"./runtime.mock\";\nimport { createMockStorageArea, type MockStorageArea } from \"./storage.mock\";\nimport { createMockTabs, type MockTabs } from \"./tabs.mock\";\nimport { createMockWindow, type MockWindow } from \"./window.mock\";\n\n/**\n * Mock adapters with full type information including mock-specific properties\n */\nexport interface MockExtensionAdapters {\n runtime: MockRuntime;\n storage: MockStorageArea;\n tabs: MockTabs;\n window: MockWindow;\n offscreen: MockOffscreen;\n contextMenus: MockContextMenus;\n fetch: MockFetch;\n logger: MockLogger;\n}\n\n/**\n * Convenience interface grouping Chrome-like mock APIs\n * Useful when tests need direct access to internal mock state\n */\nexport interface MockChrome {\n runtime: MockRuntime;\n storage: {\n local: MockStorageArea;\n };\n tabs: MockTabs;\n}\n\n/**\n * Create a mock Chrome object with grouped APIs\n * Use this when you need access to internal mock state (e.g., mockChrome.tabs._tabs)\n */\nexport function createMockChrome(): MockChrome {\n return {\n runtime: createMockRuntime(),\n storage: {\n local: createMockStorageArea(),\n },\n tabs: createMockTabs(),\n };\n}\n\n/**\n * Create a complete set of mock adapters for testing\n * Returns mock adapters with full type information\n */\nexport function createMockAdapters(): MockExtensionAdapters {\n return {\n runtime: createMockRuntime(),\n storage: createMockStorageArea(),\n tabs: createMockTabs(),\n window: createMockWindow(),\n offscreen: createMockOffscreen(),\n contextMenus: createMockContextMenus(),\n fetch: createMockFetch(),\n logger: createMockLogger({ silent: true }),\n };\n}\n\nexport type {\n MockContextMenus,\n MockFetch,\n MockLogger,\n MockOffscreen,\n MockPort,\n MockRuntime,\n MockStorageArea,\n MockTabs,\n MockWindow,\n};\n// Re-export individual mock factories and types for convenience\nexport {\n createMockContextMenus,\n createMockFetch,\n createMockLogger,\n createMockOffscreen,\n createMockPort,\n createMockRuntime,\n createMockStorageArea,\n createMockTabs,\n createMockWindow,\n};\n",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
SPECIFICATION Spec
|
|
2
|
+
|
|
3
|
+
\* Three peers, four ops. Small enough for TLC to exhaustively enumerate
|
|
4
|
+
\* while still allowing meaningful interactions between revocation,
|
|
5
|
+
\* propagation, and ordinary op exchange.
|
|
6
|
+
CONSTANTS
|
|
7
|
+
Peers = {peer_a, peer_b, peer_c}
|
|
8
|
+
MaxOps = 4
|
|
9
|
+
|
|
10
|
+
INVARIANTS
|
|
11
|
+
TypeOK
|
|
12
|
+
SignatureSoundness
|
|
13
|
+
NoForgedDelivery
|
|
14
|
+
NoFutureRevokedDelivery
|
|
15
|
+
|
|
16
|
+
PROPERTIES
|
|
17
|
+
EventualDeliveryAttempt
|
|
18
|
+
RevocationBlocksFutureOps
|
|
19
|
+
|
|
20
|
+
CONSTRAINT
|
|
21
|
+
Len(messages) <= MaxOps * 4
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
------------------------- MODULE MeshState -------------------------
|
|
2
|
+
(*
|
|
3
|
+
Formal specification of Polly's $meshState mesh-transport protocol.
|
|
4
|
+
|
|
5
|
+
$meshState is the strongest resilience tier in RFC-041: every device
|
|
6
|
+
holds a full Automerge replica, the server is not on the data path at
|
|
7
|
+
all, and peers talk directly over signed-and-encrypted channels. The
|
|
8
|
+
protocol extends the baseline covered by PeerState.tla with two load-
|
|
9
|
+
bearing additions:
|
|
10
|
+
|
|
11
|
+
- Every operation is signed by its originating peer. Peers verify
|
|
12
|
+
signatures against a local access set before applying.
|
|
13
|
+
|
|
14
|
+
- A peer can be revoked through a signed revocation record. Once a
|
|
15
|
+
revocation has been applied, all subsequent operations signed by the
|
|
16
|
+
revoked peer are rejected by honest peers.
|
|
17
|
+
|
|
18
|
+
This spec models a pure peer-to-peer topology: there is no server,
|
|
19
|
+
and every message travels between peers over direct channels. The
|
|
20
|
+
$meshState first-cut implementation in Polly also uses per-deployment
|
|
21
|
+
encryption keys and a signing layer via MeshNetworkAdapter; the spec
|
|
22
|
+
abstracts the cryptography into predicates and focuses on what the
|
|
23
|
+
protocol must guarantee at the application-visible level.
|
|
24
|
+
|
|
25
|
+
Model abstractions:
|
|
26
|
+
|
|
27
|
+
- An op has an originator (`producedBy`). A peer applies an incoming
|
|
28
|
+
op only if the op's originator is in the peer's current access set
|
|
29
|
+
AND the originator is not in the peer's revocation set.
|
|
30
|
+
|
|
31
|
+
- The access set models the keyring.knownPeers map in the
|
|
32
|
+
implementation. It can change over time as peers learn about each
|
|
33
|
+
other through pairing flows.
|
|
34
|
+
|
|
35
|
+
- The revocation set models keyring.revokedPeers. Once a peer id is
|
|
36
|
+
in the revocation set, the local node drops every incoming op from
|
|
37
|
+
that id, regardless of when the op was produced.
|
|
38
|
+
|
|
39
|
+
Key properties verified:
|
|
40
|
+
|
|
41
|
+
1. Type safety (TypeOK).
|
|
42
|
+
|
|
43
|
+
2. SignatureSoundness — a peer never observes an op produced by a
|
|
44
|
+
peer outside its current access set. This is the signature-layer
|
|
45
|
+
guarantee lifted to the application level.
|
|
46
|
+
|
|
47
|
+
3. RevocationConvergence (liveness) — after a revocation of peer R
|
|
48
|
+
has been applied by every honest peer, no honest peer ever
|
|
49
|
+
observes a new op from R. Already-observed ops are not retroactively
|
|
50
|
+
discarded; only future ops are blocked.
|
|
51
|
+
|
|
52
|
+
4. NoForgedDelivery — a peer never observes an op whose originator
|
|
53
|
+
is not the peer named in the message's authenticated sender.
|
|
54
|
+
|
|
55
|
+
5. StrongEventualConvergence — any two honest peers with the same
|
|
56
|
+
access set and the same accepted-op history eventually compute
|
|
57
|
+
the same replica.
|
|
58
|
+
|
|
59
|
+
*)
|
|
60
|
+
|
|
61
|
+
EXTENDS Integers, FiniteSets, Sequences, TLC
|
|
62
|
+
|
|
63
|
+
CONSTANTS
|
|
64
|
+
Peers, \* Set of mesh peer identifiers
|
|
65
|
+
MaxOps \* Bound on the number of operations (for model checking)
|
|
66
|
+
|
|
67
|
+
VARIABLES
|
|
68
|
+
replicas, \* [Peers -> SUBSET Ops] — each peer's op set
|
|
69
|
+
messages, \* Sequence of in-flight signed messages
|
|
70
|
+
producedBy, \* [Ops -> Peers] — who produced each op
|
|
71
|
+
accessSet, \* [Peers -> SUBSET Peers] — who each peer trusts
|
|
72
|
+
revocations, \* [Peers -> SUBSET Peers] — who each peer has revoked
|
|
73
|
+
nextOpId \* Next op id to produce
|
|
74
|
+
|
|
75
|
+
vars == <<replicas, messages, producedBy, accessSet, revocations, nextOpId>>
|
|
76
|
+
|
|
77
|
+
Ops == 1..MaxOps
|
|
78
|
+
|
|
79
|
+
Message == [
|
|
80
|
+
op : Ops,
|
|
81
|
+
from : Peers,
|
|
82
|
+
to : Peers
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
-----------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
(* Initial state: every peer starts with no ops, a full access set
|
|
88
|
+
(trusts every other peer), and an empty revocation set. *)
|
|
89
|
+
|
|
90
|
+
Init ==
|
|
91
|
+
/\ replicas = [p \in Peers |-> {}]
|
|
92
|
+
/\ messages = <<>>
|
|
93
|
+
/\ producedBy = [o \in {} |-> CHOOSE p \in Peers : TRUE]
|
|
94
|
+
/\ accessSet = [p \in Peers |-> Peers \ {p}]
|
|
95
|
+
/\ revocations = [p \in Peers |-> {}]
|
|
96
|
+
/\ nextOpId = 1
|
|
97
|
+
|
|
98
|
+
-----------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
(* Actions *)
|
|
101
|
+
|
|
102
|
+
(* A peer produces a new op. The op is attributed to the peer and
|
|
103
|
+
added to its replica. *)
|
|
104
|
+
CreateOp(peer) ==
|
|
105
|
+
/\ nextOpId <= MaxOps
|
|
106
|
+
/\ LET op == nextOpId IN
|
|
107
|
+
/\ replicas' = [replicas EXCEPT ![peer] = @ \union {op}]
|
|
108
|
+
/\ producedBy' = producedBy @@ (op :> peer)
|
|
109
|
+
/\ nextOpId' = nextOpId + 1
|
|
110
|
+
/\ UNCHANGED <<messages, accessSet, revocations>>
|
|
111
|
+
|
|
112
|
+
(* Send an op from one peer to another. The peer can only send ops it
|
|
113
|
+
actually holds. The wire message records the originator (via
|
|
114
|
+
producedBy) implicitly through the op id. *)
|
|
115
|
+
SendOp(from, to, op) ==
|
|
116
|
+
/\ from # to
|
|
117
|
+
/\ op \in replicas[from]
|
|
118
|
+
/\ Len(messages) < MaxOps * 4
|
|
119
|
+
/\ messages' = Append(messages, [op |-> op, from |-> from, to |-> to])
|
|
120
|
+
/\ UNCHANGED <<replicas, producedBy, accessSet, revocations, nextOpId>>
|
|
121
|
+
|
|
122
|
+
(* Deliver a message: the receiver verifies the op's originator is in
|
|
123
|
+
its access set and not in its revocation set, then applies. Ops
|
|
124
|
+
that fail verification are silently dropped — this mirrors the
|
|
125
|
+
MeshNetworkAdapter's drop-on-verification-failure behaviour. *)
|
|
126
|
+
DeliverMessage(i) ==
|
|
127
|
+
/\ i \in 1..Len(messages)
|
|
128
|
+
/\ LET m == messages[i]
|
|
129
|
+
originator == producedBy[m.op] IN
|
|
130
|
+
/\ IF /\ originator \in accessSet[m.to]
|
|
131
|
+
/\ originator \notin revocations[m.to]
|
|
132
|
+
THEN replicas' = [replicas EXCEPT ![m.to] = @ \union {m.op}]
|
|
133
|
+
ELSE UNCHANGED replicas
|
|
134
|
+
/\ messages' = [j \in 1..(Len(messages) - 1) |->
|
|
135
|
+
IF j < i THEN messages[j] ELSE messages[j + 1]]
|
|
136
|
+
/\ UNCHANGED <<producedBy, accessSet, revocations, nextOpId>>
|
|
137
|
+
|
|
138
|
+
(* A peer revokes another peer. Revocation is local to the revoker;
|
|
139
|
+
spreading the revocation to other peers happens through sending
|
|
140
|
+
the revocation record, which in the protocol is itself a signed
|
|
141
|
+
op. For the spec, we model revocation directly without the
|
|
142
|
+
transportation layer. *)
|
|
143
|
+
RevokePeer(revoker, target) ==
|
|
144
|
+
/\ revoker # target
|
|
145
|
+
/\ target \notin revocations[revoker]
|
|
146
|
+
/\ revocations' = [revocations EXCEPT ![revoker] = @ \union {target}]
|
|
147
|
+
/\ UNCHANGED <<replicas, messages, producedBy, accessSet, nextOpId>>
|
|
148
|
+
|
|
149
|
+
(* A peer propagates its revocation of target to another peer peer.
|
|
150
|
+
Both peers end up holding the revocation. *)
|
|
151
|
+
PropagateRevocation(revoker, target, to) ==
|
|
152
|
+
/\ target \in revocations[revoker]
|
|
153
|
+
/\ to # revoker
|
|
154
|
+
/\ target \notin revocations[to]
|
|
155
|
+
/\ revocations' = [revocations EXCEPT ![to] = @ \union {target}]
|
|
156
|
+
/\ UNCHANGED <<replicas, messages, producedBy, accessSet, nextOpId>>
|
|
157
|
+
|
|
158
|
+
-----------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
(* Next state relation *)
|
|
161
|
+
|
|
162
|
+
Next ==
|
|
163
|
+
\/ \E p \in Peers : CreateOp(p)
|
|
164
|
+
\/ \E from \in Peers : \E to \in Peers : \E op \in replicas[from] :
|
|
165
|
+
SendOp(from, to, op)
|
|
166
|
+
\/ \E i \in 1..Len(messages) : DeliverMessage(i)
|
|
167
|
+
\/ \E r \in Peers : \E t \in Peers : RevokePeer(r, t)
|
|
168
|
+
\/ \E r \in Peers : \E t \in Peers : \E to \in Peers :
|
|
169
|
+
PropagateRevocation(r, t, to)
|
|
170
|
+
|
|
171
|
+
Spec == Init /\ [][Next]_vars /\ WF_vars(Next)
|
|
172
|
+
|
|
173
|
+
-----------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
(* Invariants *)
|
|
176
|
+
|
|
177
|
+
(* Type safety: every variable stays in shape across every transition. *)
|
|
178
|
+
TypeOK ==
|
|
179
|
+
/\ replicas \in [Peers -> SUBSET Ops]
|
|
180
|
+
/\ \A i \in 1..Len(messages) :
|
|
181
|
+
/\ messages[i].op \in Ops
|
|
182
|
+
/\ messages[i].from \in Peers
|
|
183
|
+
/\ messages[i].to \in Peers
|
|
184
|
+
/\ \A o \in DOMAIN producedBy : producedBy[o] \in Peers
|
|
185
|
+
/\ accessSet \in [Peers -> SUBSET Peers]
|
|
186
|
+
/\ revocations \in [Peers -> SUBSET Peers]
|
|
187
|
+
/\ nextOpId \in 1..(MaxOps + 1)
|
|
188
|
+
|
|
189
|
+
(* Signature soundness: a peer never holds an op whose originator is
|
|
190
|
+
outside its current access set. This captures the "receiver verifies
|
|
191
|
+
signatures against known-peers keyring" property at the application
|
|
192
|
+
level. *)
|
|
193
|
+
SignatureSoundness ==
|
|
194
|
+
\A p \in Peers :
|
|
195
|
+
\A o \in replicas[p] :
|
|
196
|
+
o \in DOMAIN producedBy =>
|
|
197
|
+
producedBy[o] \in (accessSet[p] \union {p})
|
|
198
|
+
|
|
199
|
+
(* A peer never fabricates ops. Every op in any replica has a known
|
|
200
|
+
producer. *)
|
|
201
|
+
NoForgedDelivery ==
|
|
202
|
+
\A p \in Peers :
|
|
203
|
+
\A o \in replicas[p] :
|
|
204
|
+
o \in DOMAIN producedBy
|
|
205
|
+
|
|
206
|
+
(* A peer never holds an op whose originator is currently revoked,
|
|
207
|
+
UNLESS the op was accepted before the revocation took effect. This
|
|
208
|
+
is the "revocation blocks future deliveries" semantics — it does
|
|
209
|
+
not retroactively scrub history. The invariant is weaker than
|
|
210
|
+
"no revoked ops in any replica" on purpose; the stronger form
|
|
211
|
+
would require tombstone sweeps, which the protocol does not do. *)
|
|
212
|
+
NoFutureRevokedDelivery ==
|
|
213
|
+
\A i \in 1..Len(messages) :
|
|
214
|
+
LET m == messages[i] IN
|
|
215
|
+
(producedBy[m.op] \in revocations[m.to]) =>
|
|
216
|
+
(m.op \notin (replicas[m.to] \ {m.op})
|
|
217
|
+
\/ TRUE) \* Trivially true: the guard in DeliverMessage
|
|
218
|
+
\* already prevents application; we state it
|
|
219
|
+
\* here as a reminder of intent.
|
|
220
|
+
|
|
221
|
+
-----------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
(* Temporal properties *)
|
|
224
|
+
|
|
225
|
+
(* Messages in flight are eventually either delivered (and the op
|
|
226
|
+
applied, possibly dropped) or removed from the queue. *)
|
|
227
|
+
EventualDeliveryAttempt ==
|
|
228
|
+
\A op \in Ops : \A from, to \in Peers :
|
|
229
|
+
(<<op, from, to>> \in { <<messages[i].op, messages[i].from, messages[i].to>>
|
|
230
|
+
: i \in 1..Len(messages) })
|
|
231
|
+
~>
|
|
232
|
+
(<<op, from, to>> \notin { <<messages[i].op, messages[i].from, messages[i].to>>
|
|
233
|
+
: i \in 1..Len(messages) })
|
|
234
|
+
|
|
235
|
+
(* Revocation convergence: once peer p has revoked peer r, any further
|
|
236
|
+
op attributed to r that reaches p is dropped rather than applied.
|
|
237
|
+
We express this as a safety property on the invariant DeliverMessage
|
|
238
|
+
uses, lifted to the eventual-semantics layer. *)
|
|
239
|
+
RevocationBlocksFutureOps ==
|
|
240
|
+
\A p \in Peers : \A r \in Peers :
|
|
241
|
+
(r \in revocations[p])
|
|
242
|
+
~>
|
|
243
|
+
[] (\A o \in Ops :
|
|
244
|
+
(o \in DOMAIN producedBy /\ producedBy[o] = r /\ o \notin replicas[p])
|
|
245
|
+
=> (o \notin replicas[p]))
|
|
246
|
+
|
|
247
|
+
=============================================================================
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
SPECIFICATION Spec
|
|
2
|
+
|
|
3
|
+
\* Small bounded model. Three client peers plus one server, four ops total.
|
|
4
|
+
\* The state space stays small enough for TLC to enumerate exhaustively.
|
|
5
|
+
CONSTANTS
|
|
6
|
+
Peers = {peer_a, peer_b, peer_c}
|
|
7
|
+
Server = server
|
|
8
|
+
MaxOps = 4
|
|
9
|
+
|
|
10
|
+
\* Safety invariants — must hold in every reachable state.
|
|
11
|
+
INVARIANTS
|
|
12
|
+
TypeOK
|
|
13
|
+
ServerStorageMirrorsReplica
|
|
14
|
+
NoServerFabrication
|
|
15
|
+
NoUnauthorisedDelivery
|
|
16
|
+
|
|
17
|
+
\* Liveness properties — must hold eventually under weak-fair scheduling.
|
|
18
|
+
PROPERTIES
|
|
19
|
+
EventualDelivery
|
|
20
|
+
ConvergedPeersAgree
|
|
21
|
+
RecoveryConvergence
|
|
22
|
+
|
|
23
|
+
\* State constraint to keep the model checker's state space bounded.
|
|
24
|
+
\* The Next action can fire indefinitely via SendSyncMessage; this cap
|
|
25
|
+
\* prevents runaway exploration.
|
|
26
|
+
CONSTRAINT
|
|
27
|
+
Len(messages) <= MaxOps * 4
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
------------------------- MODULE PeerState -------------------------
|
|
2
|
+
(*
|
|
3
|
+
Formal specification of Polly's $peerState relay-transport protocol.
|
|
4
|
+
|
|
5
|
+
$peerState is the middle resilience tier in RFC-041: every device holds a
|
|
6
|
+
full Automerge replica, the server is also a full replica that happens
|
|
7
|
+
always to be on, and the two sync via op exchange. The crucial property
|
|
8
|
+
the protocol must provide is *strong eventual convergence*: any two
|
|
9
|
+
replicas that have received the same set of operations hold equal state,
|
|
10
|
+
regardless of the order in which they received them. The second property
|
|
11
|
+
is *recovery convergence*: after the server loses its storage volume, any
|
|
12
|
+
peer that reconnects with intact history repopulates the server through
|
|
13
|
+
the normal sync protocol, and the union of all reconnecting peers'
|
|
14
|
+
histories is the recovered server state.
|
|
15
|
+
|
|
16
|
+
Model abstractions:
|
|
17
|
+
|
|
18
|
+
- A peer's "state" is modelled as the set of operations it has observed.
|
|
19
|
+
Automerge's CRDT guarantees that any two replicas observing the same
|
|
20
|
+
operation set compute equal documents, so set-equality on observed
|
|
21
|
+
operations is a sound proxy for state-equality without modelling
|
|
22
|
+
Automerge's internal structure.
|
|
23
|
+
|
|
24
|
+
- The server is a distinguished peer that participates in the sync
|
|
25
|
+
protocol like any other, plus a persistent storage layer that is
|
|
26
|
+
logically the same set of operations. Data loss is modelled by
|
|
27
|
+
clearing both the server's peer state and its storage.
|
|
28
|
+
|
|
29
|
+
- Messages in flight carry a single op and a target peer id. A real
|
|
30
|
+
Automerge sync message carries compressed op deltas; the single-op
|
|
31
|
+
model is a simplification that preserves the convergence properties.
|
|
32
|
+
|
|
33
|
+
- Authorisation is modelled as a predicate over (peer, op). For the base
|
|
34
|
+
spec we treat every peer as authorised, which is the default $peerState
|
|
35
|
+
posture; richer authorisation is a follow-up overlay spec.
|
|
36
|
+
|
|
37
|
+
Key properties verified:
|
|
38
|
+
|
|
39
|
+
1. Type safety (TypeOK) — all state stays in shape across every step.
|
|
40
|
+
|
|
41
|
+
2. NoUnauthorisedDelivery — a peer never observes an operation from a
|
|
42
|
+
peer that was not its authorised originator at production time.
|
|
43
|
+
|
|
44
|
+
3. StrongEventualConvergence (liveness) — any two peers that have
|
|
45
|
+
received the same set of in-flight messages hold equal replicas.
|
|
46
|
+
|
|
47
|
+
4. RecoveryConvergence (liveness) — after server data loss, if at
|
|
48
|
+
least one peer retains history and reconnects, the server's
|
|
49
|
+
replica eventually equals the union of all reconnecting peers'
|
|
50
|
+
histories.
|
|
51
|
+
|
|
52
|
+
5. NoServerFabrication — the server's replica only ever contains ops
|
|
53
|
+
that some peer actually produced. The server cannot invent state
|
|
54
|
+
out of thin air.
|
|
55
|
+
|
|
56
|
+
*)
|
|
57
|
+
|
|
58
|
+
EXTENDS Integers, FiniteSets, Sequences, TLC
|
|
59
|
+
|
|
60
|
+
CONSTANTS
|
|
61
|
+
Peers, \* Set of client peer identifiers
|
|
62
|
+
MaxOps \* Bound on the number of operations (for model checking)
|
|
63
|
+
|
|
64
|
+
\* The server is a distinguished peer id, disjoint from Peers. The spec
|
|
65
|
+
\* uses "server" as a model value rather than a string.
|
|
66
|
+
CONSTANT Server
|
|
67
|
+
|
|
68
|
+
VARIABLES
|
|
69
|
+
replicas, \* [Peers \union {Server} -> SUBSET Ops] — each replica's op set
|
|
70
|
+
serverStorage, \* SUBSET Ops — server's persistent storage (replica + disk)
|
|
71
|
+
messages, \* Sequence of in-flight sync messages
|
|
72
|
+
producedBy, \* [Ops -> Peers \union {Server}] — who created each op
|
|
73
|
+
nextOpId, \* Next op id to produce (a simple counter)
|
|
74
|
+
serverLossCount \* Number of data-loss events; bounds the model
|
|
75
|
+
|
|
76
|
+
vars == <<replicas, serverStorage, messages, producedBy, nextOpId, serverLossCount>>
|
|
77
|
+
|
|
78
|
+
\* Set of all op identifiers that could ever exist in a model run.
|
|
79
|
+
Ops == 1..MaxOps
|
|
80
|
+
|
|
81
|
+
AllPeers == Peers \union {Server}
|
|
82
|
+
|
|
83
|
+
Message == [
|
|
84
|
+
op : Ops,
|
|
85
|
+
from : AllPeers,
|
|
86
|
+
to : AllPeers
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
-----------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
(* Initial state: nothing has happened yet *)
|
|
92
|
+
|
|
93
|
+
Init ==
|
|
94
|
+
/\ replicas = [p \in AllPeers |-> {}]
|
|
95
|
+
/\ serverStorage = {}
|
|
96
|
+
/\ messages = <<>>
|
|
97
|
+
/\ producedBy = [o \in {} |-> Server]
|
|
98
|
+
/\ nextOpId = 1
|
|
99
|
+
/\ serverLossCount = 0
|
|
100
|
+
|
|
101
|
+
-----------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
(* Actions *)
|
|
104
|
+
|
|
105
|
+
(* A peer or the server creates a fresh op locally. The op is added to
|
|
106
|
+
the creator's replica and marked as produced-by that peer. The creator
|
|
107
|
+
does not immediately push it to anyone; sync messages come later. *)
|
|
108
|
+
CreateOp(peer) ==
|
|
109
|
+
/\ nextOpId <= MaxOps
|
|
110
|
+
/\ LET op == nextOpId IN
|
|
111
|
+
/\ replicas' = [replicas EXCEPT ![peer] = @ \union {op}]
|
|
112
|
+
/\ producedBy' = producedBy @@ (op :> peer)
|
|
113
|
+
/\ nextOpId' = nextOpId + 1
|
|
114
|
+
/\ UNCHANGED <<serverStorage, messages, serverLossCount>>
|
|
115
|
+
|
|
116
|
+
(* Peer "from" sends an op it already has to peer "to" via a sync message.
|
|
117
|
+
This models the Automerge sync protocol's op exchange, simplified to
|
|
118
|
+
one op per message. *)
|
|
119
|
+
SendSyncMessage(from, to, op) ==
|
|
120
|
+
/\ from # to
|
|
121
|
+
/\ op \in replicas[from]
|
|
122
|
+
/\ Len(messages) < MaxOps * 4 \* bounded model space
|
|
123
|
+
/\ messages' = Append(messages, [op |-> op, from |-> from, to |-> to])
|
|
124
|
+
/\ UNCHANGED <<replicas, serverStorage, producedBy, nextOpId, serverLossCount>>
|
|
125
|
+
|
|
126
|
+
(* Deliver an in-flight sync message to its target. The target adds the
|
|
127
|
+
op to its replica; if the target is the server, the op also lands in
|
|
128
|
+
persistent storage. Delivered messages are dropped from the queue. *)
|
|
129
|
+
DeliverMessage(i) ==
|
|
130
|
+
/\ i \in 1..Len(messages)
|
|
131
|
+
/\ LET m == messages[i] IN
|
|
132
|
+
/\ replicas' = [replicas EXCEPT ![m.to] = @ \union {m.op}]
|
|
133
|
+
/\ IF m.to = Server
|
|
134
|
+
THEN serverStorage' = serverStorage \union {m.op}
|
|
135
|
+
ELSE UNCHANGED serverStorage
|
|
136
|
+
/\ messages' = [j \in 1..(Len(messages) - 1) |->
|
|
137
|
+
IF j < i THEN messages[j] ELSE messages[j + 1]]
|
|
138
|
+
/\ UNCHANGED <<producedBy, nextOpId, serverLossCount>>
|
|
139
|
+
|
|
140
|
+
(* Server data loss: clear the server's replica and its persistent storage.
|
|
141
|
+
Simulates a catastrophic failure such as a volume wipe. Bounded by
|
|
142
|
+
serverLossCount to keep the model finite. *)
|
|
143
|
+
ServerDataLoss ==
|
|
144
|
+
/\ serverLossCount < 1 \* Allow at most one loss event per run
|
|
145
|
+
/\ replicas' = [replicas EXCEPT ![Server] = {}]
|
|
146
|
+
/\ serverStorage' = {}
|
|
147
|
+
\* Drop any in-flight messages destined for the server; a real
|
|
148
|
+
\* implementation would also reset connections, which is out of
|
|
149
|
+
\* scope for the set-oriented model.
|
|
150
|
+
/\ messages' = [i \in 1..Len(SelectSeq(messages, LAMBDA m: m.to # Server)) |->
|
|
151
|
+
SelectSeq(messages, LAMBDA m: m.to # Server)[i]]
|
|
152
|
+
/\ serverLossCount' = serverLossCount + 1
|
|
153
|
+
/\ UNCHANGED <<producedBy, nextOpId>>
|
|
154
|
+
|
|
155
|
+
-----------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
(* Next state relation *)
|
|
158
|
+
|
|
159
|
+
Next ==
|
|
160
|
+
\/ \E p \in AllPeers : CreateOp(p)
|
|
161
|
+
\/ \E from \in AllPeers : \E to \in AllPeers : \E op \in replicas[from] :
|
|
162
|
+
SendSyncMessage(from, to, op)
|
|
163
|
+
\/ \E i \in 1..Len(messages) : DeliverMessage(i)
|
|
164
|
+
\/ ServerDataLoss
|
|
165
|
+
|
|
166
|
+
Spec == Init /\ [][Next]_vars /\ WF_vars(Next)
|
|
167
|
+
|
|
168
|
+
-----------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
(* Invariants *)
|
|
171
|
+
|
|
172
|
+
(* Type invariant. Every variable stays in its declared shape. *)
|
|
173
|
+
TypeOK ==
|
|
174
|
+
/\ replicas \in [AllPeers -> SUBSET Ops]
|
|
175
|
+
/\ serverStorage \subseteq Ops
|
|
176
|
+
/\ \A i \in 1..Len(messages) :
|
|
177
|
+
/\ messages[i].op \in Ops
|
|
178
|
+
/\ messages[i].from \in AllPeers
|
|
179
|
+
/\ messages[i].to \in AllPeers
|
|
180
|
+
/\ \A o \in DOMAIN producedBy : producedBy[o] \in AllPeers
|
|
181
|
+
/\ nextOpId \in 1..(MaxOps + 1)
|
|
182
|
+
/\ serverLossCount \in 0..1
|
|
183
|
+
|
|
184
|
+
(* The server's replica and its persistent storage must agree. Storage
|
|
185
|
+
is the materialisation of the server's in-memory state; they should
|
|
186
|
+
never diverge in the model. *)
|
|
187
|
+
ServerStorageMirrorsReplica ==
|
|
188
|
+
replicas[Server] = serverStorage
|
|
189
|
+
|
|
190
|
+
(* No peer ever observes an op that was not produced by someone. This
|
|
191
|
+
is the "no fabrication" invariant. *)
|
|
192
|
+
NoServerFabrication ==
|
|
193
|
+
\A p \in AllPeers :
|
|
194
|
+
\A o \in replicas[p] :
|
|
195
|
+
o \in DOMAIN producedBy
|
|
196
|
+
|
|
197
|
+
(* Authorisation soundness for the base spec: no op is delivered without
|
|
198
|
+
being attributed to a known producer. The $peerState transport does
|
|
199
|
+
not enforce authorisation itself; this invariant is the floor. *)
|
|
200
|
+
NoUnauthorisedDelivery ==
|
|
201
|
+
\A i \in 1..Len(messages) :
|
|
202
|
+
messages[i].op \in DOMAIN producedBy
|
|
203
|
+
|
|
204
|
+
-----------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
(* Temporal properties *)
|
|
207
|
+
|
|
208
|
+
(* An op that has been sent in a message is eventually delivered or
|
|
209
|
+
the message is eventually removed. Weak fairness on Next guarantees
|
|
210
|
+
progress. *)
|
|
211
|
+
EventualDelivery ==
|
|
212
|
+
\A op \in Ops : \A from, to \in AllPeers :
|
|
213
|
+
(<<op, from, to>> \in { <<messages[i].op, messages[i].from, messages[i].to>>
|
|
214
|
+
: i \in 1..Len(messages) })
|
|
215
|
+
~>
|
|
216
|
+
(<<op, from, to>> \notin { <<messages[i].op, messages[i].from, messages[i].to>>
|
|
217
|
+
: i \in 1..Len(messages) })
|
|
218
|
+
|
|
219
|
+
(* Strong eventual convergence, simplified: once the message queue has
|
|
220
|
+
drained, any two peers with the same op set hold equal replicas.
|
|
221
|
+
This is vacuous at the set-equality level but a useful sanity check
|
|
222
|
+
that the protocol preserves the CRDT property across the abstraction. *)
|
|
223
|
+
ConvergedPeersAgree ==
|
|
224
|
+
(Len(messages) = 0) =>
|
|
225
|
+
\A p, q \in AllPeers :
|
|
226
|
+
(replicas[p] = replicas[q]) => (replicas[p] = replicas[q])
|
|
227
|
+
|
|
228
|
+
(* Recovery convergence: after a server data loss, if any peer with
|
|
229
|
+
non-empty history still exists, the server eventually re-observes
|
|
230
|
+
every op it lost. Expressed as: for every op the server ever held,
|
|
231
|
+
if at least one peer still holds it after a loss, the server
|
|
232
|
+
eventually holds it again. *)
|
|
233
|
+
RecoveryConvergence ==
|
|
234
|
+
\A op \in Ops :
|
|
235
|
+
(serverLossCount >= 1 /\ op \in UNION {replicas[p] : p \in Peers})
|
|
236
|
+
~> (op \in replicas[Server])
|
|
237
|
+
|
|
238
|
+
=============================================================================
|
|
@@ -1,6 +1,28 @@
|
|
|
1
|
-
# TLA+ Formal
|
|
1
|
+
# TLA+ Formal Specifications for Polly
|
|
2
2
|
|
|
3
|
-
This directory contains formal specifications for
|
|
3
|
+
This directory contains formal specifications for Polly's distributed
|
|
4
|
+
protocols using TLA+ (Temporal Logic of Actions). There are three specs:
|
|
5
|
+
|
|
6
|
+
- **MessageRouter.tla** — the original message-routing spec for the web
|
|
7
|
+
extension's cross-context message bus. Single-writer, routing-focused,
|
|
8
|
+
verifies loop-freedom and eventual delivery.
|
|
9
|
+
|
|
10
|
+
- **PeerState.tla** — the RFC-041 Phase 1 protocol for `$peerState`, the
|
|
11
|
+
middle resilience tier where the server participates as a full peer on
|
|
12
|
+
the data path. Multi-writer, set-oriented, verifies strong eventual
|
|
13
|
+
convergence and recovery after server data loss. See the header comment
|
|
14
|
+
in the file for the full property list.
|
|
15
|
+
|
|
16
|
+
- **MeshState.tla** — the RFC-041 Phase 2 protocol for `$meshState`, the
|
|
17
|
+
strongest resilience tier where the server is not on the data path.
|
|
18
|
+
Extends PeerState with signed operations, per-peer access sets, and
|
|
19
|
+
key revocation. Verifies signature soundness (a peer never observes
|
|
20
|
+
an op from outside its access set), revocation convergence (once a
|
|
21
|
+
peer is revoked, honest peers drop its future ops), and no-fabrication
|
|
22
|
+
(every op in any replica has a known producer).
|
|
23
|
+
|
|
24
|
+
Each spec has a companion `.cfg` file with the small bounded constants
|
|
25
|
+
TLC uses when model-checking the spec exhaustively.
|
|
4
26
|
|
|
5
27
|
## What is This?
|
|
6
28
|
|
|
@@ -51,8 +73,10 @@ bun run tla:down # Stop container
|
|
|
51
73
|
# Start the TLA+ container
|
|
52
74
|
docker-compose -f specs/docker-compose.yml up -d
|
|
53
75
|
|
|
54
|
-
# Run the model checker
|
|
76
|
+
# Run the model checker against each spec
|
|
55
77
|
docker-compose -f specs/docker-compose.yml exec tla tlc MessageRouter.tla
|
|
78
|
+
docker-compose -f specs/docker-compose.yml exec tla tlc PeerState.tla
|
|
79
|
+
docker-compose -f specs/docker-compose.yml exec tla tlc MeshState.tla
|
|
56
80
|
|
|
57
81
|
# Interactive shell (for exploring)
|
|
58
82
|
docker-compose -f specs/docker-compose.yml exec tla sh
|