From af74e4c88b379276274e678ae61ace09c778a390 Mon Sep 17 00:00:00 2001 From: Bishop Bettini Date: Tue, 28 Jul 2026 20:19:49 -0400 Subject: [PATCH 1/2] feat:issue-1:simplify with a common shape for all pages --- package-lock.json | 14 + package.json | 2 +- packages/embed-protocol/package.json | 13 + packages/embed-protocol/src/index.ts | 134 +++++++++ .../embed-protocol/tests/coverage.test.ts | 37 +++ packages/embed-protocol/tests/index.test.ts | 84 ++++++ packages/embed-protocol/tsconfig.build.json | 16 ++ packages/embed-protocol/tsconfig.json | 14 + packages/router/package.json | 13 + packages/router/src/index.ts | 77 ++++++ packages/router/tests/coverage.test.ts | 37 +++ .../router/tests/index.test.ts | 16 +- packages/router/tsconfig.build.json | 16 ++ packages/router/tsconfig.json | 14 + pages/0x67/bundle-iife.json | 12 +- pages/0x67/globals.d.ts | 51 +++- pages/0x67/page.html | 4 +- pages/0x67/page.ts | 59 ++-- pages/cloud-google-drive/bundle-iife.json | 17 +- pages/cloud-google-drive/globals.d.ts | 51 +++- pages/cloud-google-drive/logic.ts | 63 +---- pages/cloud-google-drive/page.ts | 31 ++- pages/index/page.css | 6 - pages/index/page.html | 4 +- pages/local/build.json | 6 + pages/local/bundle-iife.json | 22 ++ pages/local/globals.d.ts | 58 ++++ pages/local/logic.ts | 22 ++ pages/{router => local}/page.css | 215 +++++++++------ pages/local/page.html | 64 +++++ pages/local/page.ts | 198 ++++++++++++++ pages/package.json | 4 +- pages/router/build.json | 6 - pages/router/bundle-iife.json | 6 - pages/router/globals.d.ts | 22 -- pages/router/logic.ts | 98 ------- pages/router/page.html | 46 ---- pages/router/page.ts | 95 ------- pages/tests/0x67-host.test.ts | 18 +- pages/tests/cloud-google-drive-logic.test.ts | 47 +--- pages/tests/cloud-google-drive-page.test.ts | 49 +++- pages/tests/local-logic.test.ts | 13 + pages/tests/local-page.test.ts | 257 ++++++++++++++++++ pages/tests/router-page.test.ts | 173 ------------ 44 files changed, 1473 insertions(+), 731 deletions(-) create mode 100644 packages/embed-protocol/package.json create mode 100644 packages/embed-protocol/src/index.ts create mode 100644 packages/embed-protocol/tests/coverage.test.ts create mode 100644 packages/embed-protocol/tests/index.test.ts create mode 100644 packages/embed-protocol/tsconfig.build.json create mode 100644 packages/embed-protocol/tsconfig.json create mode 100644 packages/router/package.json create mode 100644 packages/router/src/index.ts create mode 100644 packages/router/tests/coverage.test.ts rename pages/tests/router-logic.test.ts => packages/router/tests/index.test.ts (79%) create mode 100644 packages/router/tsconfig.build.json create mode 100644 packages/router/tsconfig.json create mode 100644 pages/local/build.json create mode 100644 pages/local/bundle-iife.json create mode 100644 pages/local/globals.d.ts create mode 100644 pages/local/logic.ts rename pages/{router => local}/page.css (62%) create mode 100644 pages/local/page.html create mode 100644 pages/local/page.ts delete mode 100644 pages/router/build.json delete mode 100644 pages/router/bundle-iife.json delete mode 100644 pages/router/globals.d.ts delete mode 100644 pages/router/logic.ts delete mode 100644 pages/router/page.html delete mode 100644 pages/router/page.ts create mode 100644 pages/tests/local-logic.test.ts create mode 100644 pages/tests/local-page.test.ts delete mode 100644 pages/tests/router-page.test.ts diff --git a/package-lock.json b/package-lock.json index 2b6e498..a0fc2f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -606,6 +606,10 @@ "resolved": "e2e", "link": true }, + "node_modules/embed-protocol": { + "resolved": "packages/embed-protocol", + "link": true + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -813,6 +817,10 @@ "node": ">=0.10.0" } }, + "node_modules/router": { + "resolved": "packages/router", + "link": true + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -1127,9 +1135,15 @@ "packages/chacha20": { "version": "0.0.0" }, + "packages/embed-protocol": { + "version": "0.0.0" + }, "packages/kdbx": { "version": "0.0.0" }, + "packages/router": { + "version": "0.0.0" + }, "pages": { "version": "0.0.0", "devDependencies": { diff --git a/package.json b/package.json index aabe135..773a116 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "scripts": { "clean": "rm -rf build dist", - "build:deps": "npm run build --workspace=argon2 --workspace=chacha20 --workspace=kdbx", + "build:deps": "npm run build --workspace=argon2 --workspace=chacha20 --workspace=kdbx --workspace=router --workspace=embed-protocol", "build": "npm run build:deps && npm run build --workspace=pages", "typecheck": "npm run build:deps && npm run typecheck --workspaces --if-present && tsc --noEmit -p tools/serve-dist/tsconfig.json", "lint": "biome check .", diff --git a/packages/embed-protocol/package.json b/packages/embed-protocol/package.json new file mode 100644 index 0000000..595d902 --- /dev/null +++ b/packages/embed-protocol/package.json @@ -0,0 +1,13 @@ +{ + "name": "embed-protocol", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "The same-origin postMessage protocol between a keepass-web implementation (e.g. 0x67) and whatever host page embeds it in an iframe.", + "sideEffects": false, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-include='src/**/*.ts' --test 'tests/**/*.test.ts'" + } +} diff --git a/packages/embed-protocol/src/index.ts b/packages/embed-protocol/src/index.ts new file mode 100644 index 0000000..e3776ae --- /dev/null +++ b/packages/embed-protocol/src/index.ts @@ -0,0 +1,134 @@ +/** + * `embed-protocol` — the same-origin postMessage contract between a + * keepass-web implementation (currently only `0x67`) and whatever host page + * embeds it in an iframe (the local-file chooser, the Google Drive + * connector, and future sources). + * + * Message shapes and guards used to be hand-written twice: once inline in + * 0x67/page.ts (the app side) and once in each host's own logic.ts (the host + * side). Nothing but manual care kept those two hand-written copies in sync. + * Centralizing both the guards *and* the builders here means the two ends of + * the protocol are provably using the same wire format, not just similarly + * shaped code. + * + * Six message types, each read by exactly one side and built by the other: + * app → host : kw-ready (built by the app, read by the host) + * host → app : kw-open (built by the host, read by the app) + * app → host : kw-save (built by the app, read by the host) + * host → app : kw-saved (built by the host, read by the app) + * host → app : kw-close-request (built by the host, read by the app) + * app → host : kw-close-ack (built by the app, read by the host) + * app → host : kw-close (built by the app, read by the host) + */ + +export interface ReadyMessage { + type: 'kw-ready'; +} + +export interface OpenMessage { + type: 'kw-open'; + filename: string; + bytes: ArrayBuffer; +} + +export interface SaveMessage { + type: 'kw-save'; + filename: string; + bytes: ArrayBuffer; +} + +export interface SavedMessage { + type: 'kw-saved'; + ok: boolean; + error?: string; +} + +export interface CloseRequestMessage { + type: 'kw-close-request'; +} + +export interface CloseAckMessage { + type: 'kw-close-ack'; +} + +export interface CloseMessage { + type: 'kw-close'; +} + +function hasType(data: unknown, type: string): data is { type: string } { + return ( + data !== null && typeof data === 'object' && (data as Record).type === type + ); +} + +function isFileMessage( + data: unknown, + type: 'kw-open' | 'kw-save', +): data is { type: string; filename: string; bytes: ArrayBuffer } { + if (!hasType(data, type)) return false; + const rec = data as Record; + return typeof rec.filename === 'string' && rec.bytes instanceof ArrayBuffer; +} + +// --- Guards ------------------------------------------------------------ + +export function isReadyMessage(data: unknown): data is ReadyMessage { + return hasType(data, 'kw-ready'); +} + +export function isOpenMessage(data: unknown): data is OpenMessage { + return isFileMessage(data, 'kw-open'); +} + +export function isSaveMessage(data: unknown): data is SaveMessage { + return isFileMessage(data, 'kw-save'); +} + +export function isSavedMessage(data: unknown): data is SavedMessage { + if (!hasType(data, 'kw-saved')) return false; + const rec = data as Record; + if (typeof rec.ok !== 'boolean') return false; + return rec.error === undefined || typeof rec.error === 'string'; +} + +export function isCloseRequestMessage(data: unknown): data is CloseRequestMessage { + return hasType(data, 'kw-close-request'); +} + +export function isCloseAckMessage(data: unknown): data is CloseAckMessage { + return hasType(data, 'kw-close-ack'); +} + +export function isCloseMessage(data: unknown): data is CloseMessage { + return hasType(data, 'kw-close'); +} + +// --- Builders ------------------------------------------------------------ + +export function readyMessage(): ReadyMessage { + return { type: 'kw-ready' }; +} + +export function openMessage(filename: string, bytes: ArrayBuffer): OpenMessage { + return { type: 'kw-open', filename, bytes }; +} + +export function saveMessage(filename: string, bytes: ArrayBuffer): SaveMessage { + return { type: 'kw-save', filename, bytes }; +} + +export function savedMessage(ok: boolean, error?: string): SavedMessage { + return error === undefined ? { type: 'kw-saved', ok } : { type: 'kw-saved', ok, error }; +} + +export function closeRequestMessage(): CloseRequestMessage { + return { type: 'kw-close-request' }; +} + +export function closeAckMessage(): CloseAckMessage { + return { type: 'kw-close-ack' }; +} + +export function closeMessage(): CloseMessage { + return { type: 'kw-close' }; +} diff --git a/packages/embed-protocol/tests/coverage.test.ts b/packages/embed-protocol/tests/coverage.test.ts new file mode 100644 index 0000000..dbb6310 --- /dev/null +++ b/packages/embed-protocol/tests/coverage.test.ts @@ -0,0 +1,37 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * node:test's coverage collector only reports on files V8 actually loads + * during the run — a source file with zero tests doesn't show up as 0%, it + * doesn't show up at all, and the overall percentage is computed only over + * what *is* loaded. That means a completely untested file can't fail the + * --test-coverage-* thresholds; it just silently doesn't count. See + * docs/CONTRIBUTING.md. + * + * Importing every file under src/ here closes that hole: once a file is + * loaded, any of its untested lines/branches/functions are visible in the + * report and do fail the threshold, the same as an untested branch in a file + * that's already exercised elsewhere. + */ + +const srcDir = fileURLToPath(new URL('../src', import.meta.url)); + +async function* walk(dir: string): AsyncGenerator { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + yield* walk(full); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + yield full; + } + } +} + +test('every src module loads, so an untested file cannot hide from coverage', async () => { + for await (const file of walk(srcDir)) { + await import(pathToFileURL(file).href); + } +}); diff --git a/packages/embed-protocol/tests/index.test.ts b/packages/embed-protocol/tests/index.test.ts new file mode 100644 index 0000000..c0aed2a --- /dev/null +++ b/packages/embed-protocol/tests/index.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + closeAckMessage, + closeMessage, + closeRequestMessage, + isCloseAckMessage, + isCloseMessage, + isCloseRequestMessage, + isOpenMessage, + isReadyMessage, + isSavedMessage, + isSaveMessage, + openMessage, + readyMessage, + savedMessage, + saveMessage, +} from '../src/index.ts'; + +test('readyMessage / isReadyMessage round-trip', () => { + assert.deepEqual(readyMessage(), { type: 'kw-ready' }); + assert.equal(isReadyMessage(readyMessage()), true); + assert.equal(isReadyMessage(null), false); + assert.equal(isReadyMessage(42), false); + assert.equal(isReadyMessage({ type: 'nope' }), false); +}); + +test('openMessage / isOpenMessage round-trip and reject malformed payloads', () => { + const bytes = new ArrayBuffer(4); + const msg = openMessage('vault.kdbx', bytes); + assert.deepEqual(msg, { type: 'kw-open', filename: 'vault.kdbx', bytes }); + assert.equal(isOpenMessage(msg), true); + assert.equal(isOpenMessage(null), false); + assert.equal(isOpenMessage('x'), false); + assert.equal(isOpenMessage({ type: 'kw-open', filename: 1, bytes }), false); + assert.equal(isOpenMessage({ type: 'kw-open', filename: 'a', bytes: 'no' }), false); +}); + +test('saveMessage / isSaveMessage round-trip and reject malformed payloads', () => { + const bytes = new ArrayBuffer(4); + const msg = saveMessage('vault.kdbx', bytes); + assert.deepEqual(msg, { type: 'kw-save', filename: 'vault.kdbx', bytes }); + assert.equal(isSaveMessage(msg), true); + assert.equal(isSaveMessage(null), false); + assert.equal(isSaveMessage({ type: 'kw-save', filename: 1, bytes }), false); + assert.equal(isSaveMessage({ type: 'kw-save', filename: 'a', bytes: 'no' }), false); +}); + +test('savedMessage / isSavedMessage round-trip, with and without an error', () => { + const ok = savedMessage(true); + assert.deepEqual(ok, { type: 'kw-saved', ok: true }); + assert.equal(isSavedMessage(ok), true); + + const failed = savedMessage(false, 'HTTP 403'); + assert.deepEqual(failed, { type: 'kw-saved', ok: false, error: 'HTTP 403' }); + assert.equal(isSavedMessage(failed), true); + + assert.equal(isSavedMessage(null), false); + assert.equal(isSavedMessage({ type: 'kw-saved', ok: 'nope' }), false); + assert.equal(isSavedMessage({ type: 'kw-saved', ok: true, error: 42 }), false); +}); + +test('closeRequestMessage / isCloseRequestMessage round-trip', () => { + assert.deepEqual(closeRequestMessage(), { type: 'kw-close-request' }); + assert.equal(isCloseRequestMessage(closeRequestMessage()), true); + assert.equal(isCloseRequestMessage(null), false); + assert.equal(isCloseRequestMessage({ type: 'nope' }), false); +}); + +test('closeAckMessage / isCloseAckMessage round-trip', () => { + assert.deepEqual(closeAckMessage(), { type: 'kw-close-ack' }); + assert.equal(isCloseAckMessage(closeAckMessage()), true); + assert.equal(isCloseAckMessage(null), false); + assert.equal(isCloseAckMessage(42), false); + assert.equal(isCloseAckMessage({ type: 'nope' }), false); +}); + +test('closeMessage / isCloseMessage round-trip', () => { + assert.deepEqual(closeMessage(), { type: 'kw-close' }); + assert.equal(isCloseMessage(closeMessage()), true); + assert.equal(isCloseMessage(null), false); + assert.equal(isCloseMessage(42), false); + assert.equal(isCloseMessage({ type: 'nope' }), false); +}); diff --git a/packages/embed-protocol/tsconfig.build.json b/packages/embed-protocol/tsconfig.build.json new file mode 100644 index 0000000..4a6c222 --- /dev/null +++ b/packages/embed-protocol/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "rootDir": ".", + "outDir": "../../build/packages/embed-protocol", + "declaration": true, + "rewriteRelativeImportExtensions": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/embed-protocol/tsconfig.json b/packages/embed-protocol/tsconfig.json new file mode 100644 index 0000000..c3330bd --- /dev/null +++ b/packages/embed-protocol/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/router/package.json b/packages/router/package.json new file mode 100644 index 0000000..fd072be --- /dev/null +++ b/packages/router/package.json @@ -0,0 +1,13 @@ +{ + "name": "router", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Format detection for keepass-web: identifies a KDBX-family file from its first 8 bytes and names the implementation that reads it.", + "sideEffects": false, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-include='src/**/*.ts' --test 'tests/**/*.test.ts'" + } +} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts new file mode 100644 index 0000000..473efbb --- /dev/null +++ b/packages/router/src/index.ts @@ -0,0 +1,77 @@ +/** + * `router` — identifies a KDBX-family file from its first 8 bytes and names + * the implementation (a page like `0x67.html`) that reads it. + * + * This package is shared, unmodified, across every chooser page (local file, + * Google Drive, and future sources): the decision "which implementation + * understands these bytes" doesn't depend on where the bytes came from, so + * it lives once here rather than being re-decided per chooser. + * + * Deliberately does not import `kdbx`: its whole job is routing based on 8 + * bytes, and it stays independently auditable by owning that logic outright + * rather than pulling in the full parser to do it. + */ + +/** First 32-bit signature shared by every KDBX-family file (little-endian on disk). */ +const SIGNATURE_1 = 0x9aa2d903; + +/** + * Top three bytes shared by every KDBX-family secondary signature; the low + * byte identifies the sub-format (see KNOWN_SECONDARY_SIGNATURES). + */ +const SIGNATURE_2_PREFIX = 0xb54bfb00; + +/** + * Sub-formats identified by the secondary signature's low byte. `implementation` + * is present only for a format this app actually has a reader for, and names + * the page a chooser should embed to open it — not a page to navigate to. + * + * A Map, not a plain object, specifically so the keys can stay hexadecimal + * literals (matching how the format's own spec refers to them) without + * tripping Biome's useSimpleNumberKeys rule, which only applies to object + * literal keys. + */ +const KNOWN_SECONDARY_SIGNATURES: ReadonlyMap = + new Map([ + [0x65, { label: 'KeePass 1.x (.kdb)' }], + [0x66, { label: 'KDBX pre-release' }], + [0x67, { label: 'KDBX 3.1 / 4.x', implementation: '0x67.html' }], + ]); + +export type FormatResult = + | { kind: 'invalid' } + | { kind: 'recognized'; secondaryByte: number; label: string; implementation?: string }; + +/** + * Identify a KDBX-family file from its first 8 bytes alone: the two + * signature UInt32s. Reads nothing else — the router's whole job is + * routing, not parsing. + */ +export function identifyFormat(header: Uint8Array): FormatResult { + if (header.length < 8) { + return { kind: 'invalid' }; + } + + const view = new DataView(header.buffer, header.byteOffset, header.byteLength); + if (view.getUint32(0, true) !== SIGNATURE_1) { + return { kind: 'invalid' }; + } + + const signature2 = view.getUint32(4, true); + if ((signature2 & 0xffffff00) >>> 0 !== SIGNATURE_2_PREFIX) { + return { kind: 'invalid' }; + } + + const secondaryByte = signature2 & 0xff; + const known = KNOWN_SECONDARY_SIGNATURES.get(secondaryByte); + if (known) { + return { kind: 'recognized', secondaryByte, ...known }; + } + + const hex = secondaryByte.toString(16).padStart(2, '0'); + return { + kind: 'recognized', + secondaryByte, + label: `unknown KDBX variant (secondary signature 0x${hex})`, + }; +} diff --git a/packages/router/tests/coverage.test.ts b/packages/router/tests/coverage.test.ts new file mode 100644 index 0000000..dbb6310 --- /dev/null +++ b/packages/router/tests/coverage.test.ts @@ -0,0 +1,37 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * node:test's coverage collector only reports on files V8 actually loads + * during the run — a source file with zero tests doesn't show up as 0%, it + * doesn't show up at all, and the overall percentage is computed only over + * what *is* loaded. That means a completely untested file can't fail the + * --test-coverage-* thresholds; it just silently doesn't count. See + * docs/CONTRIBUTING.md. + * + * Importing every file under src/ here closes that hole: once a file is + * loaded, any of its untested lines/branches/functions are visible in the + * report and do fail the threshold, the same as an untested branch in a file + * that's already exercised elsewhere. + */ + +const srcDir = fileURLToPath(new URL('../src', import.meta.url)); + +async function* walk(dir: string): AsyncGenerator { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + yield* walk(full); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + yield full; + } + } +} + +test('every src module loads, so an untested file cannot hide from coverage', async () => { + for await (const file of walk(srcDir)) { + await import(pathToFileURL(file).href); + } +}); diff --git a/pages/tests/router-logic.test.ts b/packages/router/tests/index.test.ts similarity index 79% rename from pages/tests/router-logic.test.ts rename to packages/router/tests/index.test.ts index 9e41c13..4f5ee37 100644 --- a/pages/tests/router-logic.test.ts +++ b/packages/router/tests/index.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { identifyFormat, must } from '../router/logic.ts'; +import { identifyFormat } from '../src/index.ts'; /** Build a valid 8-byte KDBX-family header with the given secondary signature byte. */ function header(secondaryByte: number): Uint8Array { @@ -28,12 +28,12 @@ test('identifyFormat rejects a first-signature match paired with an unrelated se assert.deepEqual(identifyFormat(buf), { kind: 'invalid' }); }); -test('identifyFormat recognizes KDBX 3.1 / 4.x (0x67) and points at the page that reads it', () => { +test('identifyFormat recognizes KDBX 3.1 / 4.x (0x67) and names the implementation that reads it', () => { assert.deepEqual(identifyFormat(header(0x67)), { kind: 'recognized', secondaryByte: 0x67, label: 'KDBX 3.1 / 4.x', - page: '0x67.html', + implementation: '0x67.html', }); }); @@ -60,13 +60,3 @@ test('identifyFormat labels an unrecognized secondary signature byte generically label: 'unknown KDBX variant (secondary signature 0x99)', }); }); - -test('must passes a present value through unchanged', () => { - assert.equal(must(42), 42); - assert.equal(must('x'), 'x'); -}); - -test('must throws for null or undefined', () => { - assert.throws(() => must(null), /expected element not found/); - assert.throws(() => must(undefined), /expected element not found/); -}); diff --git a/packages/router/tsconfig.build.json b/packages/router/tsconfig.build.json new file mode 100644 index 0000000..67f998b --- /dev/null +++ b/packages/router/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "rootDir": ".", + "outDir": "../../build/packages/router", + "declaration": true, + "rewriteRelativeImportExtensions": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/router/tsconfig.json b/packages/router/tsconfig.json new file mode 100644 index 0000000..c3330bd --- /dev/null +++ b/packages/router/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/pages/0x67/bundle-iife.json b/pages/0x67/bundle-iife.json index 97b3ff8..c462b35 100644 --- a/pages/0x67/bundle-iife.json +++ b/pages/0x67/bundle-iife.json @@ -2,6 +2,8 @@ "packagesDir": "../../build/packages", "output": "../../build/pages/0x67/bundle.js", "files": [ + "embed-protocol/src/index.js", + "chacha20/src/index.js", "argon2/src/blake2b.js", @@ -79,6 +81,14 @@ "ICON_PALETTE", "isoToLocalInputValue", "localInputValueToIso", - "defaultExpiryLocalInputValue" + "defaultExpiryLocalInputValue", + + "isOpenMessage", + "isSavedMessage", + "isCloseRequestMessage", + "readyMessage", + "saveMessage", + "closeAckMessage", + "closeMessage" ] } diff --git a/pages/0x67/globals.d.ts b/pages/0x67/globals.d.ts index f33be07..15f68fa 100644 --- a/pages/0x67/globals.d.ts +++ b/pages/0x67/globals.d.ts @@ -1,19 +1,56 @@ /** * Ambient declarations for the globals bundle.js injects into the page. * - * bundle.js concatenates the kdbx library, this page's own pure logic - * (entryField, groupPathTo, etc., extracted from page.ts into logic.ts so it - * can be unit tested without a DOM), and page.ts's own compiled output into - * one IIFE — see bundle-iife.json's "files" list. It also exposes these - * names as globals via `globalThis. = `, one entry per name in - * bundle-iife.json's "exports" list, matching what + * bundle.js concatenates the kdbx library, packages/embed-protocol, this + * page's own pure logic (entryField, groupPathTo, etc., extracted from + * page.ts into logic.ts so it can be unit tested without a DOM), and page.ts's + * own compiled output into one IIFE — see bundle-iife.json's "files" list. It + * also exposes these names as globals via `globalThis. = `, one + * entry per name in bundle-iife.json's "exports" list, matching what * pages/tests/0x67-page.test.ts sets up by hand. * * This file exists only so page.ts can be type-checked against that surface; * it declares only the members page.ts actually calls, mirroring the - * corresponding signatures in packages/kdbx/src and in logic.ts. + * corresponding signatures in packages/kdbx/src, packages/embed-protocol/src, + * and logic.ts. */ +interface OpenMessage { + type: 'kw-open'; + filename: string; + bytes: ArrayBuffer; +} +interface SaveMessage { + type: 'kw-save'; + filename: string; + bytes: ArrayBuffer; +} +interface SavedMessage { + type: 'kw-saved'; + ok: boolean; + error?: string; +} +interface CloseRequestMessage { + type: 'kw-close-request'; +} +interface ReadyMessage { + type: 'kw-ready'; +} +interface CloseAckMessage { + type: 'kw-close-ack'; +} +interface CloseMessage { + type: 'kw-close'; +} + +declare function isOpenMessage(data: unknown): data is OpenMessage; +declare function isSavedMessage(data: unknown): data is SavedMessage; +declare function isCloseRequestMessage(data: unknown): data is CloseRequestMessage; +declare function readyMessage(): ReadyMessage; +declare function saveMessage(filename: string, bytes: ArrayBuffer): SaveMessage; +declare function closeAckMessage(): CloseAckMessage; +declare function closeMessage(): CloseMessage; + interface XmlElement { readonly type: 'element'; name: string; diff --git a/pages/0x67/page.html b/pages/0x67/page.html index 5f4e5fe..57aaddd 100644 --- a/pages/0x67/page.html +++ b/pages/0x67/page.html @@ -283,13 +283,13 @@

Save database

Download the updated database file to your computer.

- +
diff --git a/pages/0x67/page.ts b/pages/0x67/page.ts index 299b4a3..0bfa9c8 100644 --- a/pages/0x67/page.ts +++ b/pages/0x67/page.ts @@ -688,7 +688,7 @@ function wireEntryListEvents(): void { dirty: false, }); if (isEmbedded()) { - postToHost({ type: 'kw-close' }); + postToHost(closeMessage()); } else { showUpload(); } @@ -1620,18 +1620,21 @@ function openMoveToDialog( // // This app is self-contained: opened directly, it never talks to another // window, and everything below is dormant. When it is embedded in a -// same-origin parent frame — the cloud connector page — that parent can hand -// it a vault to open and receive the edited vault back, without the app -// reimplementing any of its own file handling. +// same-origin parent frame — any chooser page, e.g. the local-file page or +// the Google Drive connector — that parent can hand it a vault to open and +// receive the edited vault back, without the app reimplementing any of its +// own file handling. // -// The protocol is six same-origin postMessage types: -// app → host : { type: 'kw-ready' } app booted, send a vault -// host → app : { type: 'kw-open', filename, bytes } open this vault (bytes: ArrayBuffer) -// app → host : { type: 'kw-save', filename, bytes } user saved; please persist (bytes: ArrayBuffer) -// host → app : { type: 'kw-saved', ok, error? } result of that persist -// host → app : { type: 'kw-close-request' } host wants to remove this iframe; may I? -// app → host : { type: 'kw-close-ack' } yes — nothing unsaved, or the user chose to discard -// app → host : { type: 'kw-close' } app's own ✕ was clicked; safe to remove me now +// The message shapes and guards are shared with every host in +// packages/embed-protocol, rather than hand-checked here, so the app and host +// side of the protocol can't drift out of sync with each other: +// app → host : kw-ready app booted, send a vault +// host → app : kw-open open this vault (filename, bytes: ArrayBuffer) +// app → host : kw-save user saved; please persist (filename, bytes: ArrayBuffer) +// host → app : kw-saved result of that persist (ok, error?) +// host → app : kw-close-request host wants to remove this iframe; may I? +// app → host : kw-close-ack yes — nothing unsaved, or the user chose to discard +// app → host : kw-close app's own ✕ was clicked; safe to remove me now // // Every inbound message is checked to come from the parent frame at this // page's own origin; anything else is ignored. Nothing here runs unless the @@ -1652,22 +1655,16 @@ function postToHost(message: object): void { function handleHostMessage(event: MessageEvent): void { if (event.origin !== HOST_ORIGIN || event.source !== window.parent) return; - const data = event.data as Record | null; - if (data === null || typeof data !== 'object') return; - - if ( - data.type === 'kw-open' && - typeof data.filename === 'string' && - data.bytes instanceof ArrayBuffer - ) { + + if (isOpenMessage(event.data)) { hostSession = true; - app.filename = data.filename; - app.file = data.bytes; + app.filename = event.data.filename; + app.file = event.data.bytes; showUnlock(); - } else if (data.type === 'kw-saved') { - notifyHostSaveResult(data.ok === true, typeof data.error === 'string' ? data.error : undefined); - } else if (data.type === 'kw-close-request') { - confirmDiscardIfDirty(() => postToHost({ type: 'kw-close-ack' })); + } else if (isSavedMessage(event.data)) { + notifyHostSaveResult(event.data.ok, event.data.error); + } else if (isCloseRequestMessage(event.data)) { + confirmDiscardIfDirty(() => postToHost(closeAckMessage())); } } @@ -1675,11 +1672,11 @@ async function saveToHost(status: HTMLElement, button: HTMLButtonElement): Promi const bytes = await must(app.db).save(); status.hidden = false; status.className = 'save-status'; - status.textContent = 'Saving to Google Drive…'; + status.textContent = 'Saving…'; button.disabled = true; pendingHostSave = { status, button }; // Copy into a fresh, exactly-sized ArrayBuffer for the structured clone. - postToHost({ type: 'kw-save', filename: app.filename, bytes: new Uint8Array(bytes).buffer }); + postToHost(saveMessage(app.filename, new Uint8Array(bytes).buffer)); } function notifyHostSaveResult(ok: boolean, error?: string): void { @@ -1689,11 +1686,11 @@ function notifyHostSaveResult(ok: boolean, error?: string): void { button.disabled = false; if (ok) { app.dirty = false; - status.textContent = 'Saved to Google Drive.'; + status.textContent = 'Saved.'; status.classList.add('ok'); // Retrying no longer makes sense once the save has succeeded — collapse // the footer to a single acknowledgement instead of leaving stale - // "Later" / "Save to Drive" actions from before the save was requested. + // "Later" / "Save" actions from before the save was requested. const dlg = byId('dlg-save'); must(dlg.querySelector('[data-role="save-later"]')).textContent = 'Close'; button.hidden = true; @@ -1713,7 +1710,7 @@ if (isEmbedded()) { // Announce readiness so the host knows it can send the vault. Handshaking // this way (rather than the host racing the iframe's load event) means the // host only sends once the listener above is definitely attached. - postToHost({ type: 'kw-ready' }); + postToHost(readyMessage()); } // Closing the tab, reloading, or navigating away with unsaved edits would diff --git a/pages/cloud-google-drive/bundle-iife.json b/pages/cloud-google-drive/bundle-iife.json index a8fcf24..6af8eec 100644 --- a/pages/cloud-google-drive/bundle-iife.json +++ b/pages/cloud-google-drive/bundle-iife.json @@ -1,13 +1,24 @@ { - "packagesDir": "../../build/pages/cloud-google-drive", + "packagesDir": "../../build/packages", "output": "../../build/pages/cloud-google-drive/bundle.js", - "files": ["logic.js", "page.js"], + "files": [ + "router/src/index.js", + "embed-protocol/src/index.js", + + "../pages/cloud-google-drive/logic.js", + "../pages/cloud-google-drive/page.js" + ], "exports": [ + "identifyFormat", "must", "buildDriveDownloadUrl", "buildDriveUpdateUrl", "isReadyMessage", "isSaveMessage", - "isCloseAckMessage" + "isCloseAckMessage", + "isCloseMessage", + "openMessage", + "savedMessage", + "closeRequestMessage" ] } diff --git a/pages/cloud-google-drive/globals.d.ts b/pages/cloud-google-drive/globals.d.ts index 7f41996..f2b763f 100644 --- a/pages/cloud-google-drive/globals.d.ts +++ b/pages/cloud-google-drive/globals.d.ts @@ -1,14 +1,16 @@ /** * Ambient declarations for the globals bundle.js injects into the connector - * page. bundle-iife concatenates this page's pure logic (logic.ts) and page.ts - * into one IIFE and hoists logic's exports onto globalThis, one per name in - * bundle-iife.json's "exports" list — mirroring what - * tests/cloud-google-drive-page.test.ts sets up by hand. + * page. bundle-iife concatenates packages/router, packages/embed-protocol, + * this page's pure logic (logic.ts), and page.ts into one IIFE and hoists the + * names below onto globalThis, one per name in bundle-iife.json's "exports" + * list — mirroring what tests/cloud-google-drive-page.test.ts sets up by + * hand. * * This file exists only so page.ts can be type-checked against that surface; it * declares just the members page.ts actually calls, mirroring the signatures in - * logic.ts. The Google SDK globals that page.ts also uses (GIS token client and - * the Picker, both loaded at runtime from Google) are declared at the bottom. + * packages/router/src, packages/embed-protocol/src, and logic.ts. The Google SDK + * globals that page.ts also uses (GIS token client and the Picker, both loaded + * at runtime from Google) are declared at the bottom. */ interface DriveFile { @@ -16,19 +18,50 @@ interface DriveFile { name: string; } +declare function identifyFormat( + header: Uint8Array, +): + | { kind: 'invalid' } + | { kind: 'recognized'; secondaryByte: number; label: string; implementation?: string }; + +interface ReadyMessage { + type: 'kw-ready'; +} +interface OpenMessage { + type: 'kw-open'; + filename: string; + bytes: ArrayBuffer; +} interface SaveMessage { type: 'kw-save'; filename: string; bytes: ArrayBuffer; } +interface SavedMessage { + type: 'kw-saved'; + ok: boolean; + error?: string; +} +interface CloseRequestMessage { + type: 'kw-close-request'; +} +interface CloseAckMessage { + type: 'kw-close-ack'; +} +interface CloseMessage { + type: 'kw-close'; +} declare function must(value: T | null | undefined): T; declare function buildDriveDownloadUrl(apiBase: string, id: string): string; declare function buildDriveUpdateUrl(uploadBase: string, id: string): string; -declare function isReadyMessage(data: unknown): boolean; +declare function isReadyMessage(data: unknown): data is ReadyMessage; declare function isSaveMessage(data: unknown): data is SaveMessage; -declare function isCloseAckMessage(data: unknown): boolean; -declare function isCloseMessage(data: unknown): boolean; +declare function isCloseAckMessage(data: unknown): data is CloseAckMessage; +declare function isCloseMessage(data: unknown): data is CloseMessage; +declare function openMessage(filename: string, bytes: ArrayBuffer): OpenMessage; +declare function savedMessage(ok: boolean, error?: string): SavedMessage; +declare function closeRequestMessage(): CloseRequestMessage; // --- Google SDKs (loaded at runtime from Google) --- // Declared loosely on purpose — these are foreign, remotely-loaded APIs, not diff --git a/pages/cloud-google-drive/logic.ts b/pages/cloud-google-drive/logic.ts index 9c7f944..0fc96d2 100644 --- a/pages/cloud-google-drive/logic.ts +++ b/pages/cloud-google-drive/logic.ts @@ -1,13 +1,15 @@ /** * Pure logic for the Google Drive connector: Drive REST URL construction and - * postMessage shape guards. None of it touches the DOM, the network, or - * module-level browser state, so — like router/logic.ts and 0x67/logic.ts — it - * is unit tested directly under plain Node (see - * tests/cloud-google-drive-logic.test.ts). + * the must()-style "fail loudly on a missing DOM node" guard page.ts needs. + * None of it touches the DOM, the network, or module-level browser state, so + * — like local's and 0x67's logic.ts — it is unit tested directly under + * plain Node (see tests/cloud-google-drive-logic.test.ts). * * Sign-in and file browsing are delegated to Google's own SDKs (GIS token * client and the Picker), loaded at runtime by page.ts, so there is no OAuth or - * file-listing logic here. + * file-listing logic here. Format detection (packages/router) and the + * embedded-app message protocol (packages/embed-protocol) are shared with + * every other chooser page rather than duplicated here. * * This is a real ES module. For the browser build, bundle-iife strips the * `export` keywords and hoists these names onto globalThis alongside page.ts — @@ -23,17 +25,10 @@ export interface DriveFile { name: string; } -/** The message the embedded 0x67 app posts when the user saves. */ -export interface SaveMessage { - type: 'kw-save'; - filename: string; - bytes: ArrayBuffer; -} - /** * Unwrap a possibly-missing DOM lookup, or fail loudly. page.ts's markup is * hand-authored, so a missing element means a real bug, not a state to handle - * gracefully. Lives here (rather than in page.ts) for the same reason router's + * gracefully. Lives here (rather than in page.ts) for the same reason local's * does: so its throw branch is exercisable directly from a logic test. */ export function must(value: T | null | undefined): T { @@ -52,45 +47,3 @@ export function buildDriveDownloadUrl(apiBase: string, id: string): string { export function buildDriveUpdateUrl(uploadBase: string, id: string): string { return `${uploadBase}/files/${encodeURIComponent(id)}?uploadType=media`; } - -/** True if `data` is the embedded app's "I'm ready for a vault" handshake. */ -export function isReadyMessage(data: unknown): boolean { - return ( - data !== null && - typeof data === 'object' && - (data as Record).type === 'kw-ready' - ); -} - -/** True if `data` is the embedded app's "please persist these bytes" message. */ -export function isSaveMessage(data: unknown): data is SaveMessage { - if (data === null || typeof data !== 'object') { - return false; - } - const rec = data as Record; - return ( - rec.type === 'kw-save' && typeof rec.filename === 'string' && rec.bytes instanceof ArrayBuffer - ); -} - -/** True if `data` is the embedded app's "safe to remove me now" reply to a - * `kw-close-request` — either nothing was unsaved, or the user chose to - * discard it. */ -export function isCloseAckMessage(data: unknown): boolean { - return ( - data !== null && - typeof data === 'object' && - (data as Record).type === 'kw-close-ack' - ); -} - -/** True if `data` is the embedded app's own "the user closed me" message — - * unprompted, unlike kw-close-ack, since the app already ran its own - * discard-confirmation before sending it. */ -export function isCloseMessage(data: unknown): boolean { - return ( - data !== null && - typeof data === 'object' && - (data as Record).type === 'kw-close' - ); -} diff --git a/pages/cloud-google-drive/page.ts b/pages/cloud-google-drive/page.ts index 92d0052..16ff478 100644 --- a/pages/cloud-google-drive/page.ts +++ b/pages/cloud-google-drive/page.ts @@ -251,7 +251,19 @@ async function openPickedFile(file: DriveFile): Promise { setPickStatus(`Could not open ${file.name} (HTTP ${response.status}).`); return; } - showHost(file, await response.arrayBuffer()); + const bytes = await response.arrayBuffer(); + const header = new Uint8Array(bytes, 0, Math.min(8, bytes.byteLength)); + const result = identifyFormat(header); + + if (result.kind === 'invalid') { + setPickStatus(`${file.name} doesn't look like a KDBX file — no recognized signature found.`); + return; + } + if (!result.implementation) { + setPickStatus(`${file.name} is ${result.label}, which isn't supported yet.`); + return; + } + showHost(file, bytes, result.implementation); } catch { setPickStatus(`Network error while opening ${file.name}.`); } @@ -261,7 +273,7 @@ async function openPickedFile(file: DriveFile): Promise { // Screen: Embedded app (0x67 in an iframe) // ============================================================ -function showHost(file: DriveFile, bytes: ArrayBuffer): void { +function showHost(file: DriveFile, bytes: ArrayBuffer, implementation: string): void { currentFile = file; pendingOpen = { filename: file.name, bytes }; setRoot(cloneTemplate('tpl-host')); @@ -272,7 +284,7 @@ function showHost(file: DriveFile, bytes: ArrayBuffer): void { window.addEventListener('message', handleFrameMessage); // Setting src last means the iframe's script (and its kw-ready handshake) // can't fire before the listener above is attached. - qs('#app-frame').src = '0x67.html'; + qs('#app-frame').src = implementation; } function tearDownIframe(): void { @@ -291,7 +303,7 @@ function tearDownIframe(): void { function requestCloseIframe(afterClose: () => void): void { pendingClose = afterClose; must(qs('#app-frame').contentWindow).postMessage( - { type: 'kw-close-request' }, + closeRequestMessage(), APP_ORIGIN, ); } @@ -304,7 +316,7 @@ function handleFrameMessage(event: MessageEvent): void { const source = event.source as Window; if (isReadyMessage(event.data)) { const open = must(pendingOpen); - source.postMessage({ type: 'kw-open', filename: open.filename, bytes: open.bytes }, APP_ORIGIN); + source.postMessage(openMessage(open.filename, open.bytes), APP_ORIGIN); } else if (isSaveMessage(event.data)) { void saveToDrive(event.data.bytes, source); } else if (isCloseAckMessage(event.data)) { @@ -326,15 +338,12 @@ async function saveToDrive(bytes: ArrayBuffer, source: Window): Promise { body: bytes, }); if (!response.ok) { - source.postMessage( - { type: 'kw-saved', ok: false, error: `HTTP ${response.status}` }, - APP_ORIGIN, - ); + source.postMessage(savedMessage(false, `HTTP ${response.status}`), APP_ORIGIN); return; } - source.postMessage({ type: 'kw-saved', ok: true }, APP_ORIGIN); + source.postMessage(savedMessage(true), APP_ORIGIN); } catch { - source.postMessage({ type: 'kw-saved', ok: false, error: 'network error' }, APP_ORIGIN); + source.postMessage(savedMessage(false, 'network error'), APP_ORIGIN); } } diff --git a/pages/index/page.css b/pages/index/page.css index 955d561..a80ed83 100644 --- a/pages/index/page.css +++ b/pages/index/page.css @@ -130,12 +130,6 @@ a:hover { color: var(--text); } -.open .note { - font-size: 0.8rem; - color: var(--muted); - margin-top: -0.25rem; -} - .trust { border: 1px solid var(--border); border-radius: 0.5rem; diff --git a/pages/index/page.html b/pages/index/page.html index d5e080f..fac4974 100644 --- a/pages/index/page.html +++ b/pages/index/page.html @@ -17,9 +17,7 @@

Your passwords.
Your storage.
Your browser.

diff --git a/pages/local/build.json b/pages/local/build.json new file mode 100644 index 0000000..48d95fe --- /dev/null +++ b/pages/local/build.json @@ -0,0 +1,6 @@ +{ + "template": "page.html", + "styles": ["page.css"], + "scripts": ["../../build/pages/local/bundle.js"], + "output": "../../dist/local.html" +} diff --git a/pages/local/bundle-iife.json b/pages/local/bundle-iife.json new file mode 100644 index 0000000..a1034cb --- /dev/null +++ b/pages/local/bundle-iife.json @@ -0,0 +1,22 @@ +{ + "packagesDir": "../../build/packages", + "output": "../../build/pages/local/bundle.js", + "files": [ + "router/src/index.js", + "embed-protocol/src/index.js", + + "../pages/local/logic.js", + "../pages/local/page.js" + ], + "exports": [ + "identifyFormat", + "isReadyMessage", + "isSaveMessage", + "isCloseAckMessage", + "isCloseMessage", + "openMessage", + "savedMessage", + "closeRequestMessage", + "must" + ] +} diff --git a/pages/local/globals.d.ts b/pages/local/globals.d.ts new file mode 100644 index 0000000..a1dcb22 --- /dev/null +++ b/pages/local/globals.d.ts @@ -0,0 +1,58 @@ +/** + * Ambient declarations for the globals bundle.js injects into the page. + * + * bundle-iife concatenates packages/router, packages/embed-protocol, this + * page's own pure logic (logic.ts), and page.ts's own compiled output into + * one IIFE, then hoists the names below onto globalThis — one entry per name + * in bundle-iife.json's "exports" list, matching what + * tests/local-page.test.ts sets up by hand. + * + * This file exists only so page.ts can be type-checked against that surface; + * it declares only the members page.ts actually calls, mirroring the + * corresponding signatures in packages/router/src, packages/embed-protocol/src, + * and logic.ts. + */ + +declare function identifyFormat( + header: Uint8Array, +): + | { kind: 'invalid' } + | { kind: 'recognized'; secondaryByte: number; label: string; implementation?: string }; + +interface ReadyMessage { + type: 'kw-ready'; +} +interface OpenMessage { + type: 'kw-open'; + filename: string; + bytes: ArrayBuffer; +} +interface SaveMessage { + type: 'kw-save'; + filename: string; + bytes: ArrayBuffer; +} +interface SavedMessage { + type: 'kw-saved'; + ok: boolean; + error?: string; +} +interface CloseRequestMessage { + type: 'kw-close-request'; +} +interface CloseAckMessage { + type: 'kw-close-ack'; +} +interface CloseMessage { + type: 'kw-close'; +} + +declare function isReadyMessage(data: unknown): data is ReadyMessage; +declare function isSaveMessage(data: unknown): data is SaveMessage; +declare function isCloseAckMessage(data: unknown): data is CloseAckMessage; +declare function isCloseMessage(data: unknown): data is CloseMessage; +declare function openMessage(filename: string, bytes: ArrayBuffer): OpenMessage; +declare function savedMessage(ok: boolean, error?: string): SavedMessage; +declare function closeRequestMessage(): CloseRequestMessage; + +declare function must(value: T | null | undefined): T; diff --git a/pages/local/logic.ts b/pages/local/logic.ts new file mode 100644 index 0000000..cff5c22 --- /dev/null +++ b/pages/local/logic.ts @@ -0,0 +1,22 @@ +/** + * Pure logic for the local-file chooser: just the must()-style "fail loudly + * on a missing DOM node" guard page.ts needs. Format detection lives in + * packages/router (shared with every chooser), and the embed protocol lives + * in packages/embed-protocol (shared with every host and with 0x67) — this + * page has no pure logic of its own beyond the DOM-lookup guard, which is + * kept here rather than in page.ts for the same reason every other + * connector's is: so its throw branch is exercisable directly from a logic + * test. + * + * This is a real ES module, exactly like router's and cloud-google-drive's + * logic.ts. For the browser build, bundle-iife strips the `export` keyword + * below and hoists this name onto globalThis — see local/bundle-iife.json. + * page.ts consumes it as a global, not via import — see globals.d.ts. + */ + +export function must(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('expected element not found'); + } + return value; +} diff --git a/pages/router/page.css b/pages/local/page.css similarity index 62% rename from pages/router/page.css rename to pages/local/page.css index 9e7efe5..bbeaec7 100644 --- a/pages/router/page.css +++ b/pages/local/page.css @@ -1,3 +1,7 @@ +/* ============================================================ + Reset and design tokens (shared palette with 0x67 / cloud-google-drive) + ============================================================ */ + *, *::before, *::after { @@ -18,6 +22,7 @@ :root { --bg: #eef2ef; + --surface: #ffffff; --surface-2: #eaefec; --border: #d8e2dc; --text: #0e1714; @@ -30,38 +35,26 @@ --success-dim: #eaf1ee; } +/* `height`, not `min-height`: the embedded-app screen's `.app-frame` iframe + * depends on `.screen-host { height: 100% }` resolving all the way up this + * chain — see cloud-google-drive/page.css, which this is mirrored from. */ body { background: var(--bg); color: var(--text); font-family: ui-monospace, monospace; - font-size: 16px; - line-height: 1.6; - min-height: 100vh; + font-size: 15px; + height: 100vh; + line-height: 1.5; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - padding: 2rem; -} - -main { - max-width: 480px; - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - gap: 1.5rem; - text-align: center; } .wordmark { display: inline-flex; align-items: center; - font-size: 1.1rem; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--muted); + font-weight: 700; + letter-spacing: 0.02em; + color: var(--accent); } .mark { @@ -95,17 +88,109 @@ main { border-radius: 3px; } -h1 { - font-size: 1.75rem; - font-weight: 700; - letter-spacing: -0.02em; - line-height: 1.2; +#root { + flex: 1; + min-height: 0; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.sponsor-cta { + flex-shrink: 0; + padding: 0.4rem 1rem 0; + font-size: 0.85rem; + color: var(--text); + text-align: center; +} + +footer { + flex-shrink: 0; + padding: 0.4rem 1rem; + border-top: 1px solid var(--border); + font-size: 0.8rem; + color: var(--muted); + text-align: center; +} + +footer a { + color: var(--accent); +} + +.sponsor-cta a { + color: var(--accent); + font-weight: 600; } .description { color: var(--muted); } +/* ============================================================ + Buttons and shared bits + ============================================================ */ + +.btn { + display: inline-block; + padding: 0.55rem 1.1rem; + border: 1px solid var(--border); + border-radius: 6px; + font: inherit; + cursor: pointer; + background: var(--surface-2); + color: var(--text); +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; + font-weight: 600; +} + +.btn-secondary { + background: var(--surface-2); +} + +.btn:hover { + filter: brightness(1.08); +} + +.back-link { + display: inline-block; + background: none; + border: none; + color: var(--muted); + font: inherit; + cursor: pointer; + text-decoration: none; +} + +.back-link:hover { + color: var(--text); +} + +/* ============================================================ + Screen: Choose a file (local drop zone) + ============================================================ */ + +.screen-chooser { + max-width: 480px; + margin: 0 auto; + padding: 3rem 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + text-align: center; +} + .drop-zone { border: 2px dashed var(--border); border-radius: 12px; @@ -135,41 +220,6 @@ h1 { font-size: 0.95rem; } -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - padding: 0.5rem 1.1rem; - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - text-decoration: none; - white-space: nowrap; - transition: opacity 0.1s; -} - -.btn-primary { - background: var(--accent); - border-color: var(--accent); - color: #fff; -} - -.btn-primary:hover { - opacity: 0.85; -} - -.btn-secondary { - background: var(--surface-2); - color: var(--text); -} - -.btn-secondary:hover { - background: var(--border); -} - .result { width: 100%; display: flex; @@ -196,35 +246,32 @@ h1 { background: var(--danger-dim); } -.links { +/* ============================================================ + Screen: Embedded app + ============================================================ */ + +.screen-host { display: flex; flex-direction: column; - gap: 0.75rem; - align-items: center; -} - -.sponsor-cta { - margin-top: 1.5rem; - font-size: 0.9rem; - color: var(--text); - text-align: center; + height: 100%; } -a { - color: var(--accent); - text-decoration: none; -} - -a:hover { - text-decoration: underline; +.host-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.6rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface); } -.sponsor-cta a { - font-weight: 600; +.host-filename { + color: var(--muted); + font-size: 0.9rem; } -footer { - margin-top: 1rem; - font-size: 0.8rem; - color: var(--border); +.app-frame { + flex: 1; + width: 100%; + border: none; } diff --git a/pages/local/page.html b/pages/local/page.html new file mode 100644 index 0000000..7f9e12a --- /dev/null +++ b/pages/local/page.html @@ -0,0 +1,64 @@ + + + + + + KeePass Web — Local file + + + + + +
+ + + + + + + + + + + + + diff --git a/pages/local/page.ts b/pages/local/page.ts new file mode 100644 index 0000000..e4b5462 --- /dev/null +++ b/pages/local/page.ts @@ -0,0 +1,198 @@ +// ============================================================ +// Local file connector +// ============================================================ +// +// Opens a KeePass database from this computer: the user drops or chooses a +// file, its bytes are read once, right here in this tab, and packages/router +// decides which implementation understands them. On a match, that +// implementation (currently only 0x67) is embedded in an iframe and handed +// the bytes over the same postMessage protocol the Google Drive connector +// uses (see packages/embed-protocol) — no second file picker, no re-reading +// the file, and nothing ever leaves the browser. +// +// The only way this differs from the Drive connector is what "save" means: +// Drive persists back to the Drive API, whereas here there is nowhere to +// persist to — save means downloading the updated bytes, exactly like 0x67's +// own standalone save flow already does. That is the one piece of this +// connector that is genuinely local-specific; everything else (the iframe +// lifecycle, the message guards, the close handshake) is shared with Drive. +// +// (must is declared in globals.d.ts and supplied at runtime by logic.ts; +// identifyFormat comes from packages/router and the kw-* message helpers +// from packages/embed-protocol — bundle-iife concatenates all of them +// alongside this file. See globals.d.ts.) + +const APP_ORIGIN = window.location.origin; + +// --- In-memory state (never persisted) ------------------------------------- + +let pendingOpen: { filename: string; bytes: ArrayBuffer } | null = null; +// Set while waiting for the embedded app to ack a kw-close-request, so +// handleFrameMessage knows what to run once it's safe to tear the iframe down. +let pendingClose: (() => void) | null = null; + +// ============================================================ +// DOM helpers +// ============================================================ + +function byId(id: string): T { + return must(document.getElementById(id) as T | null); +} + +function cloneTemplate(id: string): DocumentFragment { + return byId(id).content.cloneNode(true) as DocumentFragment; +} + +function setRoot(fragment: DocumentFragment): void { + const root = byId('root'); + root.innerHTML = ''; + root.appendChild(fragment); +} + +function qs(selector: string): T { + return must(byId('root').querySelector(selector)); +} + +// ============================================================ +// Screen: Choose a file +// ============================================================ + +function showChooser(): void { + setRoot(cloneTemplate('tpl-chooser')); + + const dropZone = qs('#drop-zone'); + const fileInput = qs('#file-input'); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('drag-over'); + }); + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('drag-over'); + }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + const f = e.dataTransfer?.files[0]; + if (f) void handleFile(f); + }); + fileInput.addEventListener('change', () => { + const f = fileInput.files?.[0]; + if (f) void handleFile(f); + }); + qs('#choose-another').addEventListener('click', resetChooser); +} + +function resetChooser(): void { + qs('#result').hidden = true; + const dropZone = qs('#drop-zone'); + dropZone.hidden = false; + dropZone.classList.remove('drag-over'); + qs('#file-input').value = ''; +} + +function showMessage(message: string, kind: 'warn' | 'error'): void { + qs('#drop-zone').hidden = true; + const resultEl = qs('#result'); + resultEl.hidden = false; + resultEl.className = `result result-${kind}`; + qs('#result-message').textContent = message; +} + +/** Read the whole file once: identifyFormat only needs the first 8 bytes, but + * opening it needs the rest anyway, so there is no separate sniff-then-reread + * step the way a link-based handoff would require. */ +async function handleFile(file: File): Promise { + const bytes = await file.arrayBuffer(); + const header = new Uint8Array(bytes, 0, Math.min(8, bytes.byteLength)); + const result = identifyFormat(header); + + if (result.kind === 'invalid') { + showMessage("This doesn't look like a KDBX file — no recognized signature was found.", 'error'); + return; + } + if (!result.implementation) { + showMessage(`Recognized as ${result.label}, which isn't supported yet.`, 'warn'); + return; + } + showHost(file.name, bytes, result.implementation); +} + +// ============================================================ +// Screen: Embedded implementation app +// ============================================================ + +function showHost(filename: string, bytes: ArrayBuffer, implementation: string): void { + pendingOpen = { filename, bytes }; + setRoot(cloneTemplate('tpl-host')); + qs('#host-filename').textContent = filename; + qs('[data-action="back-to-chooser"]').addEventListener('click', () => { + requestCloseIframe(tearDownIframe); + }); + window.addEventListener('message', handleFrameMessage); + // Setting src last means the iframe's script (and its kw-ready handshake) + // can't fire before the listener above is attached. + qs('#app-frame').src = implementation; +} + +function tearDownIframe(): void { + window.removeEventListener('message', handleFrameMessage); + pendingOpen = null; + showChooser(); +} + +/** Ask the embedded app whether it's safe to remove the iframe — it may have + * unsaved edits, in which case it shows its own discard-confirmation dialog + * and only acks if the user agrees. */ +function requestCloseIframe(afterClose: () => void): void { + pendingClose = afterClose; + must(qs('#app-frame').contentWindow).postMessage( + closeRequestMessage(), + APP_ORIGIN, + ); +} + +function handleFrameMessage(event: MessageEvent): void { + if (event.origin !== APP_ORIGIN) return; + const iframe = document.getElementById('app-frame') as HTMLIFrameElement | null; + if (iframe === null || event.source === null || event.source !== iframe.contentWindow) return; + + const source = event.source as Window; + if (isReadyMessage(event.data)) { + const open = must(pendingOpen); + source.postMessage(openMessage(open.filename, open.bytes), APP_ORIGIN); + } else if (isSaveMessage(event.data)) { + downloadAndAck(event.data.filename, event.data.bytes, source); + } else if (isCloseAckMessage(event.data)) { + const afterClose = pendingClose; + pendingClose = null; + afterClose?.(); + } else if (isCloseMessage(event.data)) { + // App-initiated (its own ✕ button) — no request/ack round-trip needed. + tearDownIframe(); + } +} + +/** Local "save" is a download: there is no remembered location to write back + * to, so the closest equivalent to Drive's write-back is the same + * Blob-download 0x67 already does standalone. This always succeeds unless the + * browser itself refuses the download, so — unlike Drive's saveToDrive — + * there is no error path to report back. */ +function downloadAndAck(filename: string, bytes: ArrayBuffer, source: Window): void { + const blob = new Blob([bytes]); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + source.postMessage(savedMessage(true), APP_ORIGIN); +} + +// ============================================================ +// Boot +// ============================================================ + +showChooser(); diff --git a/pages/package.json b/pages/package.json index 5c6afcf..d7b2c4b 100644 --- a/pages/package.json +++ b/pages/package.json @@ -6,8 +6,8 @@ "description": "KeePass Web — the browser app itself, built into self-contained HTML distributables.", "scripts": { "typecheck": "tsc --noEmit", - "test": "node --experimental-strip-types --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-include='0x67/**/*.ts' --test-coverage-include='index/**/*.ts' --test-coverage-include='router/**/*.ts' --test-coverage-include='cloud-google-drive/**/*.ts' --test-coverage-exclude='**/*.d.ts' --test 'tests/**/*.test.ts'", - "build": "tsc --project tsconfig.build.json && node --experimental-strip-types ../tools/build/bundle-iife/src/index.ts 0x67/bundle-iife.json && node --experimental-strip-types ../tools/build/bundle-iife/src/index.ts router/bundle-iife.json && node --experimental-strip-types ../tools/build/bundle-iife/src/index.ts cloud-google-drive/bundle-iife.json && node --experimental-strip-types ../tools/build/inliner/src/index.ts 0x67/build.json && node --experimental-strip-types ../tools/build/inliner/src/index.ts router/build.json && node --experimental-strip-types ../tools/build/inliner/src/index.ts index/build.json && node --experimental-strip-types ../tools/build/inliner/src/index.ts cloud-google-drive/build.json" + "test": "node --experimental-strip-types --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-include='0x67/**/*.ts' --test-coverage-include='index/**/*.ts' --test-coverage-include='local/**/*.ts' --test-coverage-include='cloud-google-drive/**/*.ts' --test-coverage-exclude='**/*.d.ts' --test 'tests/**/*.test.ts'", + "build": "tsc --project tsconfig.build.json && node --experimental-strip-types ../tools/build/bundle-iife/src/index.ts 0x67/bundle-iife.json && node --experimental-strip-types ../tools/build/bundle-iife/src/index.ts local/bundle-iife.json && node --experimental-strip-types ../tools/build/bundle-iife/src/index.ts cloud-google-drive/bundle-iife.json && node --experimental-strip-types ../tools/build/inliner/src/index.ts 0x67/build.json && node --experimental-strip-types ../tools/build/inliner/src/index.ts local/build.json && node --experimental-strip-types ../tools/build/inliner/src/index.ts index/build.json && node --experimental-strip-types ../tools/build/inliner/src/index.ts cloud-google-drive/build.json" }, "devDependencies": { "@types/jsdom": "^28.0.3", diff --git a/pages/router/build.json b/pages/router/build.json deleted file mode 100644 index 56b7bd8..0000000 --- a/pages/router/build.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "template": "page.html", - "styles": ["page.css"], - "scripts": ["../../build/pages/router/bundle.js"], - "output": "../../dist/router.html" -} diff --git a/pages/router/bundle-iife.json b/pages/router/bundle-iife.json deleted file mode 100644 index 5be02c6..0000000 --- a/pages/router/bundle-iife.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "packagesDir": "../../build/pages/router", - "output": "../../build/pages/router/bundle.js", - "files": ["logic.js", "page.js"], - "exports": ["identifyFormat", "must"] -} diff --git a/pages/router/globals.d.ts b/pages/router/globals.d.ts deleted file mode 100644 index 7cf32ca..0000000 --- a/pages/router/globals.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Ambient declarations for the globals bundle.js injects into the page. - * - * bundle.js concatenates this page's own pure logic (identifyFormat and - * must, extracted from page.ts into logic.ts so they can be unit tested - * without a DOM) with page.ts's own compiled output into one IIFE — see - * bundle-iife.json's "files" list. It also exposes both names as globals via - * `globalThis. = `, one entry per name in bundle-iife.json's - * "exports" list, matching what pages/tests/router-page.test.ts sets up by - * hand. - * - * This file exists only so page.ts can be type-checked against that - * surface; it declares only the members page.ts actually calls, mirroring - * the corresponding signatures in logic.ts. - */ - -type FormatResult = - | { kind: 'invalid' } - | { kind: 'recognized'; secondaryByte: number; label: string; page?: string }; - -declare function identifyFormat(header: Uint8Array): FormatResult; -declare function must(value: T | null | undefined): T; diff --git a/pages/router/logic.ts b/pages/router/logic.ts deleted file mode 100644 index 3cf647c..0000000 --- a/pages/router/logic.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Pure logic for the router: format detection from a file's first 8 bytes, - * plus the same must()-style "fail loudly on a missing DOM node" guard - * page.ts needs. None of this touches the DOM, so unlike page.ts it can be — - * and is — unit tested directly under plain Node (see - * tests/router-logic.test.ts). - * - * This is a real ES module, exactly like 0x67/logic.ts, so ordinary imports - * work in tests. For the browser build, bundle-iife strips the `export` - * keyword below and hoists these names onto globalThis — see - * router/bundle-iife.json. page.ts consumes them as globals, not via import - * — see globals.d.ts. - * - * The router deliberately does not import packages/kdbx: its whole job is - * routing based on 8 bytes, and it stays independently auditable by owning - * that logic outright rather than pulling in the full parser to do it. - */ - -/** First 32-bit signature shared by every KDBX-family file (little-endian on disk). */ -const SIGNATURE_1 = 0x9aa2d903; - -/** - * Top three bytes shared by every KDBX-family secondary signature; the low - * byte identifies the sub-format (see KNOWN_SECONDARY_SIGNATURES). - */ -const SIGNATURE_2_PREFIX = 0xb54bfb00; - -/** - * Sub-formats identified by the secondary signature's low byte. `page` is - * present only for a format this app actually has a reader for. - * - * A Map, not a plain object, specifically so the keys can stay hexadecimal - * literals (matching how the format's own spec and packages/kdbx/src/constants.ts - * refer to them) without tripping Biome's useSimpleNumberKeys rule, which - * only applies to object literal keys. - */ -const KNOWN_SECONDARY_SIGNATURES: ReadonlyMap = new Map([ - [0x65, { label: 'KeePass 1.x (.kdb)' }], - [0x66, { label: 'KDBX pre-release' }], - [0x67, { label: 'KDBX 3.1 / 4.x', page: '0x67.html' }], -]); - -export type FormatResult = - | { kind: 'invalid' } - | { kind: 'recognized'; secondaryByte: number; label: string; page?: string }; - -/** - * Identify a KDBX-family file from its first 8 bytes alone: the two - * signature UInt32s. Reads nothing else — the router's whole job is - * routing, not parsing. - */ -export function identifyFormat(header: Uint8Array): FormatResult { - if (header.length < 8) { - return { kind: 'invalid' }; - } - - const view = new DataView(header.buffer, header.byteOffset, header.byteLength); - if (view.getUint32(0, true) !== SIGNATURE_1) { - return { kind: 'invalid' }; - } - - const signature2 = view.getUint32(4, true); - if ((signature2 & 0xffffff00) >>> 0 !== SIGNATURE_2_PREFIX) { - return { kind: 'invalid' }; - } - - const secondaryByte = signature2 & 0xff; - const known = KNOWN_SECONDARY_SIGNATURES.get(secondaryByte); - if (known) { - return { kind: 'recognized', secondaryByte, ...known }; - } - - const hex = secondaryByte.toString(16).padStart(2, '0'); - return { - kind: 'recognized', - secondaryByte, - label: `unknown KDBX variant (secondary signature 0x${hex})`, - }; -} - -/** - * Unwrap a possibly-missing DOM lookup, or fail loudly. page.ts's markup is - * static and hand-authored, so a missing element means a real bug, not a - * state to handle gracefully. - * - * Lives here rather than in page.ts itself (contrast 0x67/page.ts, which - * defines its own copy): router has a single static screen, so there's no - * later DOM mutation that naturally re-triggers a lookup the way 0x67's - * repeated screen re-renders do, which makes testing the throw branch - * through page.ts alone impractical. Keeping it here instead lets - * tests/router-logic.test.ts exercise it directly. - */ -export function must(value: T | null | undefined): T { - if (value === null || value === undefined) { - throw new Error('expected element not found'); - } - return value; -} diff --git a/pages/router/page.html b/pages/router/page.html deleted file mode 100644 index e84177c..0000000 --- a/pages/router/page.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - KeePass Web — Open a database - - - - -
-
KeePass Web
-

What kind of database is this?

-

- Drop a database file below. Only its first 8 bytes are read, right here - in your browser — nothing is uploaded, and no other part of the file is - touched. -

- -
-
🔎
-

Drop a database file here

- -
- - - - - - -
- - - - diff --git a/pages/router/page.ts b/pages/router/page.ts deleted file mode 100644 index c93f8ad..0000000 --- a/pages/router/page.ts +++ /dev/null @@ -1,95 +0,0 @@ -// ============================================================ -// DOM helpers -// (must() is declared in globals.d.ts and supplied at runtime by logic.ts — -// see globals.d.ts for why it lives there instead of here.) -// ============================================================ - -function byId(id: string): T { - return must(document.getElementById(id) as T | null); -} - -// ============================================================ -// Elements -// (This page has a single static screen, so — unlike 0x67/page.ts — these -// are looked up once, not re-queried per screen render.) -// ============================================================ - -const dropZone = byId('drop-zone'); -const fileInput = byId('file-input'); -const resultEl = byId('result'); -const resultMessage = byId('result-message'); -const resultLink = byId('result-link'); -const chooseAnotherBtn = byId('choose-another'); - -// ============================================================ -// Result rendering -// ============================================================ - -function showResult(result: FormatResult): void { - dropZone.hidden = true; - resultEl.hidden = false; - - if (result.kind === 'invalid') { - resultEl.className = 'result result-error'; - resultMessage.textContent = - "This doesn't look like a KDBX file — no recognized signature was found."; - resultLink.hidden = true; - return; - } - - if (result.page) { - resultEl.className = 'result result-ok'; - resultMessage.textContent = - `Recognized as ${result.label}. Open that page and select this same file again — ` + - `nothing about it is uploaded automatically.`; - resultLink.textContent = `Open ${result.page}`; - resultLink.href = result.page; - resultLink.hidden = false; - } else { - resultEl.className = 'result result-warn'; - resultMessage.textContent = `Recognized as ${result.label}, which isn't supported yet.`; - resultLink.hidden = true; - } -} - -function reset(): void { - resultEl.hidden = true; - resultLink.hidden = true; - dropZone.hidden = false; - dropZone.classList.remove('drag-over'); - fileInput.value = ''; -} - -/** Read only the first 8 bytes of `file` — everything identifyFormat needs, - * and nothing more; the rest of the file is never touched. */ -async function handleFile(file: File): Promise { - const header = new Uint8Array(await file.slice(0, 8).arrayBuffer()); - showResult(identifyFormat(header)); -} - -// ============================================================ -// Events -// ============================================================ - -fileInput.addEventListener('change', () => { - const f = fileInput.files?.[0]; - if (f) handleFile(f); -}); - -dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('drag-over'); -}); - -dropZone.addEventListener('dragleave', () => { - dropZone.classList.remove('drag-over'); -}); - -dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('drag-over'); - const f = e.dataTransfer?.files[0]; - if (f) handleFile(f); -}); - -chooseAnotherBtn.addEventListener('click', reset); diff --git a/pages/tests/0x67-host.test.ts b/pages/tests/0x67-host.test.ts index 36bf705..0ea5806 100644 --- a/pages/tests/0x67-host.test.ts +++ b/pages/tests/0x67-host.test.ts @@ -18,6 +18,7 @@ import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { JSDOM } from 'jsdom'; +import * as embedProtocol from '../../packages/embed-protocol/src/index.ts'; import { addEntryAttachment, appendChild, @@ -121,6 +122,7 @@ Object.assign(globalThis, { pushHistorySnapshot, restoreHistoryEntry, deleteHistoryEntry, + ...embedProtocol, ...logic, }); @@ -235,7 +237,7 @@ test('0x67 embedded in a host frame', async (t) => { assert.equal(q('#db-filename').textContent, 'from-drive.kdbx'); }); - await t.test('unlocks, and the save dialog offers Drive write-back, not download', async () => { + await t.test('unlocks, and the save dialog offers host write-back, not download', async () => { q('#master-password').value = PASSWORD; q('#unlock-form').dispatchEvent( new dom.window.Event('submit', { bubbles: true, cancelable: true }), @@ -254,7 +256,7 @@ test('0x67 embedded in a host frame', async (t) => { assert.equal(dq('[data-action="download"]').hidden, true); }); - await t.test('Save to Drive posts kw-save, then reports success on kw-saved', async () => { + await t.test('Save posts kw-save to the host, then reports success on kw-saved', async () => { const before = hostInbox.length; click(dq('[data-action="save-host"]')); await waitFor(() => hostInbox.length > before); @@ -263,15 +265,12 @@ test('0x67 embedded in a host frame', async (t) => { assert.equal(msg.type, 'kw-save'); assert.equal(msg.filename, 'from-drive.kdbx'); assert.ok(msg.bytes instanceof ArrayBuffer && msg.bytes.byteLength > 0); - assert.equal( - dq('[data-role="save-status"]').textContent, - 'Saving to Google Drive…', - ); + assert.equal(dq('[data-role="save-status"]').textContent, 'Saving…'); assert.equal(dq('[data-action="save-host"]').disabled, true); sendFromHost({ type: 'kw-saved', ok: true }); const status = dq('[data-role="save-status"]'); - assert.equal(status.textContent, 'Saved to Google Drive.'); + assert.equal(status.textContent, 'Saved.'); assert.ok(status.classList.contains('ok')); assert.equal(dq('[data-action="save-host"]').disabled, false); // Success collapses the footer to a single "Close" action — retrying @@ -324,10 +323,7 @@ test('0x67 embedded in a host frame', async (t) => { click(dq('[data-action="save-host"]')); await waitFor(() => hostInbox.length > before); sendFromHost({ type: 'kw-saved', ok: true }); - assert.equal( - dq('[data-role="save-status"]').textContent, - 'Saved to Google Drive.', - ); + assert.equal(dq('[data-role="save-status"]').textContent, 'Saved.'); assert.equal(dq('[data-role="save-later"]').textContent, 'Close'); click(dq('[data-role="save-later"]')); }, diff --git a/pages/tests/cloud-google-drive-logic.test.ts b/pages/tests/cloud-google-drive-logic.test.ts index 7f89175..3db56ca 100644 --- a/pages/tests/cloud-google-drive-logic.test.ts +++ b/pages/tests/cloud-google-drive-logic.test.ts @@ -1,22 +1,15 @@ /** * Unit tests for cloud-google-drive/logic.ts — the connector's pure Drive-URL - * and message-guard helpers. All DOM-free, so exercised directly here (contrast + * and DOM-lookup helpers. All DOM-free, so exercised directly here (contrast * cloud-google-drive-page.test.ts, which drives page.ts through jsdom). * Sign-in and file browsing live in Google's SDKs, so there is no OAuth logic - * to test here. + * to test here. Format detection (packages/router) and the embedded-app + * message protocol (packages/embed-protocol) have their own test suites. */ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { - buildDriveDownloadUrl, - buildDriveUpdateUrl, - isCloseAckMessage, - isCloseMessage, - isReadyMessage, - isSaveMessage, - must, -} from '../cloud-google-drive/logic.ts'; +import { buildDriveDownloadUrl, buildDriveUpdateUrl, must } from '../cloud-google-drive/logic.ts'; test('must returns a present value and throws on null/undefined', () => { assert.equal(must('x'), 'x'); @@ -35,35 +28,3 @@ test('buildDriveDownloadUrl / buildDriveUpdateUrl encode the id', () => { 'https://up.example/v3/files/id9?uploadType=media', ); }); - -test('isReadyMessage recognises the handshake', () => { - assert.equal(isReadyMessage({ type: 'kw-ready' }), true); - assert.equal(isReadyMessage(null), false); - assert.equal(isReadyMessage(42), false); - assert.equal(isReadyMessage({ type: 'nope' }), false); -}); - -test('isSaveMessage requires a filename string and ArrayBuffer bytes', () => { - assert.equal( - isSaveMessage({ type: 'kw-save', filename: 'a.kdbx', bytes: new ArrayBuffer(2) }), - true, - ); - assert.equal(isSaveMessage(null), false); - assert.equal(isSaveMessage('x'), false); - assert.equal(isSaveMessage({ type: 'kw-save', filename: 1, bytes: new ArrayBuffer(2) }), false); - assert.equal(isSaveMessage({ type: 'kw-save', filename: 'a', bytes: 'no' }), false); -}); - -test('isCloseAckMessage recognises the close acknowledgement', () => { - assert.equal(isCloseAckMessage({ type: 'kw-close-ack' }), true); - assert.equal(isCloseAckMessage(null), false); - assert.equal(isCloseAckMessage(42), false); - assert.equal(isCloseAckMessage({ type: 'nope' }), false); -}); - -test('isCloseMessage recognises the app-initiated close', () => { - assert.equal(isCloseMessage({ type: 'kw-close' }), true); - assert.equal(isCloseMessage(null), false); - assert.equal(isCloseMessage(42), false); - assert.equal(isCloseMessage({ type: 'nope' }), false); -}); diff --git a/pages/tests/cloud-google-drive-page.test.ts b/pages/tests/cloud-google-drive-page.test.ts index 80ad411..b7e320a 100644 --- a/pages/tests/cloud-google-drive-page.test.ts +++ b/pages/tests/cloud-google-drive-page.test.ts @@ -14,6 +14,8 @@ import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { JSDOM } from 'jsdom'; +import * as embedProtocol from '../../packages/embed-protocol/src/index.ts'; +import { identifyFormat } from '../../packages/router/src/index.ts'; import * as logic from '../cloud-google-drive/logic.ts'; // ============================================================ @@ -25,6 +27,15 @@ const html = readFileSync(htmlPath, 'utf8'); const dom = new JSDOM(html, { url: 'https://example.com/keepass/', pretendToBeVisual: true }); const APP_ORIGIN = 'https://example.com'; +/** A valid 8-byte KDBX 3.1/4.x header, padded to `length` bytes. */ +function kdbxBytes(length = 16): ArrayBuffer { + const buf = new ArrayBuffer(length); + const view = new DataView(buf); + view.setUint32(0, 0x9aa2d903, true); + view.setUint32(4, 0xb54bfb67, true); + return buf; +} + Object.defineProperty(globalThis, 'document', { value: dom.window.document as unknown as Document, configurable: true, @@ -127,7 +138,7 @@ Object.defineProperty(globalThis, 'google', { }); Object.defineProperty(globalThis, 'gapi', { value: gapiMock, configurable: true, writable: true }); -Object.assign(globalThis, logic); +Object.assign(globalThis, { identifyFormat, ...embedProtocol, ...logic }); await import('../cloud-google-drive/page.ts'); @@ -270,11 +281,43 @@ test('Google Drive connector', async (t) => { ); }); + await t.test('picking a file with no recognizable signature is reported', async () => { + handlers.download = async () => ({ + ok: true, + status: 200, + arrayBuffer: async () => new ArrayBuffer(8), + }); + await pick({ id: 'f1', name: 'random.bin' }); + await waitFor(() => + /random.bin doesn't look like a KDBX file/.test(q('#pick-status').textContent ?? ''), + ); + }); + + await t.test('picking a recognized-but-unsupported file (.kdb) is reported', async () => { + handlers.download = async () => ({ + ok: true, + status: 200, + arrayBuffer: async () => { + const buf = new ArrayBuffer(8); + const view = new DataView(buf); + view.setUint32(0, 0x9aa2d903, true); + view.setUint32(4, 0xb54bfb65, true); + return buf; + }, + }); + await pick({ id: 'f1', name: 'old.kdb' }); + await waitFor(() => + /old.kdb is KeePass 1\.x \(\.kdb\), which isn't supported yet/.test( + q('#pick-status').textContent ?? '', + ), + ); + }); + await t.test('picking a file successfully embeds the app', async () => { handlers.download = async () => ({ ok: true, status: 200, - arrayBuffer: async () => new ArrayBuffer(16), + arrayBuffer: async () => kdbxBytes(), }); await pick({ id: 'f1', name: 'vault.kdbx' }); await waitFor(() => q('#app-frame') !== null); @@ -383,7 +426,7 @@ test('Google Drive connector', async (t) => { handlers.download = async () => ({ ok: true, status: 200, - arrayBuffer: async () => new ArrayBuffer(16), + arrayBuffer: async () => kdbxBytes(), }); await pick({ id: 'f2', name: 'vault2.kdbx' }); await waitFor(() => q('#app-frame') !== null); diff --git a/pages/tests/local-logic.test.ts b/pages/tests/local-logic.test.ts new file mode 100644 index 0000000..4134591 --- /dev/null +++ b/pages/tests/local-logic.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { must } from '../local/logic.ts'; + +test('must passes a present value through unchanged', () => { + assert.equal(must(42), 42); + assert.equal(must('x'), 'x'); +}); + +test('must throws for null or undefined', () => { + assert.throws(() => must(null), /expected element not found/); + assert.throws(() => must(undefined), /expected element not found/); +}); diff --git a/pages/tests/local-page.test.ts b/pages/tests/local-page.test.ts new file mode 100644 index 0000000..0dab7a5 --- /dev/null +++ b/pages/tests/local-page.test.ts @@ -0,0 +1,257 @@ +/** + * Behavioral tests for local/page.ts, driven through the real page.html + * markup in jsdom. Combines what router-page.test.ts used to cover (drop a + * file, detect its format) with what cloud-google-drive-page.test.ts covers + * for the embedded-app message protocol, since local/page.ts now does both + * in one page: read the file once, decide the implementation via + * packages/router, then embed it and hand over the bytes — no re-selecting + * the same file on a second page. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { JSDOM } from 'jsdom'; +import * as embedProtocol from '../../packages/embed-protocol/src/index.ts'; +import { identifyFormat } from '../../packages/router/src/index.ts'; +import { must } from '../local/logic.ts'; + +const htmlPath = fileURLToPath(new URL('../local/page.html', import.meta.url)); +const html = readFileSync(htmlPath, 'utf8'); +const dom = new JSDOM(html, { url: 'https://example.com/keepass/', pretendToBeVisual: true }); +const APP_ORIGIN = 'https://example.com'; + +Object.defineProperty(globalThis, 'document', { + value: dom.window.document as unknown as Document, + configurable: true, + writable: true, +}); +Object.defineProperty(globalThis, 'window', { + value: dom.window as unknown as Window & typeof globalThis, + configurable: true, + writable: true, +}); + +// Anchor-click downloads would otherwise trip jsdom's "not implemented: +// navigation" — record the attempted filename instead, exactly like +// tests/0x67-page.test.ts does for its own download flow. +const downloadNames: string[] = []; +dom.window.HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) { + downloadNames.push(this.download); +}; + +Object.assign(globalThis, { identifyFormat, ...embedProtocol, must }); + +await import('../local/page.ts'); + +// ============================================================ +// Helpers +// ============================================================ + +const doc = dom.window.document; +const root = (): HTMLElement => doc.getElementById('root') as HTMLElement; +const q = (selector: string): T => + root().querySelector(selector) as T; + +function dispatch(el: EventTarget, type: string, extra?: Record): Event { + const evt = new dom.window.Event(type, { bubbles: true, cancelable: true }); + if (extra) Object.assign(evt, extra); + el.dispatchEvent(evt); + return evt; +} + +function click(el: Element): void { + dispatch(el, 'click'); +} + +function setFiles(input: HTMLInputElement, files: File[]): void { + Object.defineProperty(input, 'files', { value: files, configurable: true }); +} + +function makeFile(name: string, bytes: Uint8Array): File { + return new File([bytes as unknown as BlobPart], name); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error('waitFor: timed out'); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +/** Build a valid 8-byte KDBX-family header with the given secondary signature byte. */ +function header(secondaryByte: number): Uint8Array { + const buf = new Uint8Array(8); + const view = new DataView(buf.buffer); + view.setUint32(0, 0x9aa2d903, true); + view.setUint32(4, 0xb54bfb00 | secondaryByte, true); + return buf; +} + +function sendMessage(data: unknown, opts: { origin?: string; source?: unknown } = {}): void { + const evt = new dom.window.Event('message'); + Object.assign(evt, { + data, + origin: opts.origin ?? APP_ORIGIN, + source: 'source' in opts ? opts.source : dom.window, + }); + dom.window.dispatchEvent(evt); +} + +// ============================================================ +// Walkthrough +// ============================================================ + +test('local file connector', async (t) => { + await t.test('boots showing the drop zone, with the result panel hidden', () => { + assert.equal(q('#drop-zone').hidden, false); + assert.equal(q('#result').hidden, true); + }); + + await t.test('dragover/dragleave toggle the drag-over class', () => { + const dropZone = q('#drop-zone'); + const over = dispatch(dropZone, 'dragover'); + assert.equal(over.defaultPrevented, true); + assert.equal(dropZone.classList.contains('drag-over'), true); + + dispatch(dropZone, 'dragleave'); + assert.equal(dropZone.classList.contains('drag-over'), false); + }); + + await t.test('a drop event with no files does nothing', () => { + dispatch(q('#drop-zone'), 'drop', { dataTransfer: { files: [] } }); + assert.equal(q('#result').hidden, true); + }); + + await t.test('a change event with no file selected does nothing', () => { + setFiles(q('#file-input'), []); + dispatch(q('#file-input'), 'change'); + assert.equal(q('#result').hidden, true); + }); + + await t.test('a file with no recognizable signature shows an error', async () => { + setFiles(q('#file-input'), [ + makeFile('random.bin', new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])), + ]); + dispatch(q('#file-input'), 'change'); + + await waitFor(() => q('#result').hidden === false); + assert.equal(q('#result').className, 'result result-error'); + assert.match( + q('#result-message').textContent ?? '', + /doesn't look like a KDBX file/, + ); + }); + + await t.test('choosing another file resets back to the drop zone', () => { + click(q('#choose-another')); + assert.equal(q('#drop-zone').hidden, false); + assert.equal(q('#result').hidden, true); + assert.equal(q('#file-input').value, ''); + }); + + await t.test('a recognized-but-unsupported file (.kdb) warns, with no iframe', async () => { + const dropZone = q('#drop-zone'); + const evt = dispatch(dropZone, 'drop', { + dataTransfer: { files: [makeFile('old.kdb', header(0x65))] }, + }); + assert.equal(evt.defaultPrevented, true); + + await waitFor(() => q('#result').hidden === false); + assert.equal(q('#result').className, 'result result-warn'); + assert.match( + q('#result-message').textContent ?? '', + /KeePass 1\.x \(\.kdb\), which isn't supported yet/, + ); + click(q('#choose-another')); + }); + + await t.test('dropping a recognized file embeds its implementation', async () => { + const dropZone = q('#drop-zone'); + dispatch(dropZone, 'drop', { dataTransfer: { files: [makeFile('vault.kdbx', header(0x67))] } }); + + await waitFor(() => q('#app-frame') !== null); + assert.equal(q('#host-filename').textContent, 'vault.kdbx'); + assert.equal(q('#app-frame').getAttribute('src'), '0x67.html'); + }); + + // --- Embedded-app message protocol ------------------------------------- + + const frameInbox: Array<{ message: Record; origin: string }> = []; + const frameWin = { + postMessage(message: Record, origin: string): void { + frameInbox.push({ message, origin }); + }, + }; + + await t.test('frame messages that fail the guard are ignored', () => { + const frame = q('#app-frame'); + Object.defineProperty(frame, 'contentWindow', { value: frameWin, configurable: true }); + + sendMessage({ type: 'kw-ready' }, { origin: 'https://evil.example', source: frameWin }); + sendMessage({ type: 'kw-ready' }, { source: null }); + sendMessage({ type: 'kw-ready' }, { source: { not: 'the frame' } }); + assert.equal(frameInbox.length, 0); + }); + + await t.test('kw-ready triggers kw-open with the file bytes', () => { + sendMessage({ type: 'kw-ready' }, { source: frameWin }); + assert.equal(frameInbox.length, 1); + const msg = frameInbox[0]?.message; + assert.equal(msg?.type, 'kw-open'); + assert.equal(msg?.filename, 'vault.kdbx'); + assert.ok(msg?.bytes instanceof ArrayBuffer); + }); + + await t.test('kw-save triggers a local download and reports success', () => { + const before = downloadNames.length; + sendMessage( + { type: 'kw-save', filename: 'vault.kdbx', bytes: new ArrayBuffer(8) }, + { source: frameWin }, + ); + assert.deepEqual(downloadNames.slice(before), ['vault.kdbx']); + assert.deepEqual(frameInbox.at(-1)?.message, { type: 'kw-saved', ok: true }); + }); + + await t.test('a stray kw-close-ack with nothing pending is a harmless no-op', () => { + const before = frameInbox.length; + sendMessage({ type: 'kw-close-ack' }, { source: frameWin }); + assert.equal(frameInbox.length, before, 'nothing posted back'); + assert.ok(q('#app-frame'), 'still showing the host screen'); + }); + + await t.test('back to chooser asks the app first, and only leaves once it acks', () => { + click(q('[data-action="back-to-chooser"]')); + assert.ok(q('#app-frame'), 'still on the host screen — waiting for the app to confirm'); + const req = frameInbox.at(-1)?.message; + assert.equal(req?.type, 'kw-close-request'); + + sendMessage({ type: 'kw-close-ack' }, { source: frameWin }); + assert.ok(q('#drop-zone'), 'now back at the chooser'); + }); + + await t.test('a frame message after back to chooser completed is ignored', () => { + const before = frameInbox.length; + sendMessage({ type: 'kw-ready' }, { source: frameWin }); + assert.equal(frameInbox.length, before, 'nothing more posted — the iframe is gone'); + }); + + await t.test('an app-initiated close tears down the iframe without a round trip', async () => { + const dropZone = q('#drop-zone'); + dispatch(dropZone, 'drop', { + dataTransfer: { files: [makeFile('vault2.kdbx', header(0x67))] }, + }); + await waitFor(() => q('#app-frame') !== null); + Object.defineProperty(q('#app-frame'), 'contentWindow', { + value: frameWin, + configurable: true, + }); + + const before = frameInbox.length; + sendMessage({ type: 'kw-close' }, { source: frameWin }); + assert.equal(frameInbox.length, before, 'no reply expected — the app already confirmed itself'); + assert.ok(q('#drop-zone'), 'back at the chooser, no request/ack round trip needed'); + }); +}); diff --git a/pages/tests/router-page.test.ts b/pages/tests/router-page.test.ts deleted file mode 100644 index ce41919..0000000 --- a/pages/tests/router-page.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Behavioral tests for router/page.ts, the one file in this workspace that - * touches real DOM APIs at module scope. Plain Node has no DOM, so - * pages/tests/coverage.test.ts deliberately lets a bare import of it throw — - * that's the honest signal that it had no test coverage at all. - * - * This file gives it real coverage instead, using jsdom (a devDependency - * scoped to this workspace) to provide a `document`, exactly like - * tests/0x67-page.test.ts does for the app page. - */ - -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { JSDOM } from 'jsdom'; -import { identifyFormat, must } from '../router/logic.ts'; - -const htmlPath = fileURLToPath(new URL('../router/page.html', import.meta.url)); -const html = readFileSync(htmlPath, 'utf8'); -const dom = new JSDOM(html, { url: 'https://example.com/keepass/', pretendToBeVisual: true }); - -Object.defineProperty(globalThis, 'document', { - value: dom.window.document as unknown as Document, - configurable: true, - writable: true, -}); - -// Hoist this page's own pure logic onto globalThis, exactly like bundle.js -// does in the real browser build (see bundle-iife.json's "exports" list). -Object.assign(globalThis, { identifyFormat, must }); - -await import('../router/page.ts'); - -// ============================================================ -// Test helpers -// ============================================================ - -const doc = dom.window.document; -const byId = (id: string): T => doc.getElementById(id) as unknown as T; - -function dispatch(el: EventTarget, type: string, extra?: Record): Event { - const evt = new dom.window.Event(type, { bubbles: true, cancelable: true }); - if (extra) Object.assign(evt, extra); - el.dispatchEvent(evt); - return evt; -} - -function setFiles(input: HTMLInputElement, files: File[]): void { - Object.defineProperty(input, 'files', { value: files, configurable: true }); -} - -function makeFile(name: string, bytes: Uint8Array): File { - return new File([bytes as unknown as BlobPart], name); -} - -async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { - const start = Date.now(); - while (!predicate()) { - if (Date.now() - start > timeoutMs) { - throw new Error('waitFor: timed out waiting for condition'); - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } -} - -/** Build a valid 8-byte KDBX-family header with the given secondary signature byte. */ -function header(secondaryByte: number): Uint8Array { - const buf = new Uint8Array(8); - const view = new DataView(buf.buffer); - view.setUint32(0, 0x9aa2d903, true); - view.setUint32(4, 0xb54bfb00 | secondaryByte, true); - return buf; -} - -// ============================================================ -// The walkthrough -// ============================================================ - -test('router page', async (t) => { - await t.test('boots showing the drop zone, with the result panel hidden', () => { - assert.equal(byId('drop-zone').hidden, false); - assert.equal(byId('result').hidden, true); - }); - - await t.test('dragover/dragleave toggle the drag-over class', () => { - const dropZone = byId('drop-zone'); - const over = dispatch(dropZone, 'dragover'); - assert.equal(over.defaultPrevented, true); - assert.equal(dropZone.classList.contains('drag-over'), true); - - dispatch(dropZone, 'dragleave'); - assert.equal(dropZone.classList.contains('drag-over'), false); - }); - - await t.test('a drop event with no files does nothing', () => { - const dropZone = byId('drop-zone'); - dispatch(dropZone, 'drop', { dataTransfer: { files: [] } }); - assert.equal(byId('result').hidden, true); - }); - - await t.test('a change event with no file selected does nothing', () => { - const fileInput = byId('file-input'); - setFiles(fileInput, []); - dispatch(fileInput, 'change'); - assert.equal(byId('result').hidden, true); - }); - - await t.test('dropping a recognized 0x67 file shows a link to open it', async () => { - const dropZone = byId('drop-zone'); - const file = makeFile('vault.kdbx', header(0x67)); - const evt = dispatch(dropZone, 'drop', { dataTransfer: { files: [file] } }); - assert.equal(evt.defaultPrevented, true); - assert.equal(dropZone.classList.contains('drag-over'), false); - - await waitFor(() => byId('result').hidden === false); - assert.equal(byId('drop-zone').hidden, true); - assert.equal(byId('result').className, 'result result-ok'); - - const link = byId('result-link'); - assert.equal(link.hidden, false); - assert.equal(link.getAttribute('href'), '0x67.html'); - assert.match( - byId('result-message').textContent ?? '', - /Recognized as KDBX 3\.1 \/ 4\.x\. Open that page/, - ); - }); - - await t.test('choosing another file resets back to the drop zone', () => { - byId('choose-another').dispatchEvent( - new dom.window.Event('click', { bubbles: true }), - ); - assert.equal(byId('drop-zone').hidden, false); - assert.equal(byId('result').hidden, true); - assert.equal(byId('result-link').hidden, true); - assert.equal(byId('file-input').value, ''); - }); - - await t.test( - 'choosing a recognized-but-unsupported file (.kdb) warns, with no link', - async () => { - const fileInput = byId('file-input'); - setFiles(fileInput, [makeFile('old.kdb', header(0x65))]); - dispatch(fileInput, 'change'); - - await waitFor(() => byId('result').hidden === false); - assert.equal(byId('result').className, 'result result-warn'); - assert.equal(byId('result-link').hidden, true); - assert.match( - byId('result-message').textContent ?? '', - /KeePass 1\.x \(\.kdb\), which isn't supported yet/, - ); - - byId('choose-another').dispatchEvent( - new dom.window.Event('click', { bubbles: true }), - ); - }, - ); - - await t.test('choosing a file with no recognizable signature shows an error', async () => { - const fileInput = byId('file-input'); - setFiles(fileInput, [makeFile('random.bin', new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))]); - dispatch(fileInput, 'change'); - - await waitFor(() => byId('result').hidden === false); - assert.equal(byId('result').className, 'result result-error'); - assert.equal(byId('result-link').hidden, true); - assert.match( - byId('result-message').textContent ?? '', - /doesn't look like a KDBX file/, - ); - }); -}); From 36bb2310901c039c67d00be6d07abb48191b6cd2 Mon Sep 17 00:00:00 2001 From: Bishop Bettini Date: Tue, 28 Jul 2026 20:33:38 -0400 Subject: [PATCH 2/2] fix(test):relocation failure --- ...lt.test.ts => local-hidden-result.test.ts} | 4 +- e2e/local-to-app-embed.test.ts | 73 +++++++++++++++++++ e2e/router-to-app-navigation.test.ts | 73 ------------------- 3 files changed, 75 insertions(+), 75 deletions(-) rename e2e/{router-hidden-result.test.ts => local-hidden-result.test.ts} (90%) create mode 100644 e2e/local-to-app-embed.test.ts delete mode 100644 e2e/router-to-app-navigation.test.ts diff --git a/e2e/router-hidden-result.test.ts b/e2e/local-hidden-result.test.ts similarity index 90% rename from e2e/router-hidden-result.test.ts rename to e2e/local-hidden-result.test.ts index 1f94313..9500f0f 100644 --- a/e2e/router-hidden-result.test.ts +++ b/e2e/local-hidden-result.test.ts @@ -31,8 +31,8 @@ after(async () => { await server.close(); }); -test('router.html: the #result panel is actually invisible on load, not just marked [hidden]', async () => { - await page.goto(`${server.origin}/router.html`, { waitUntil: 'networkidle0' }); +test('local.html: the #result panel is actually invisible on load, not just marked [hidden]', async () => { + await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' }); const { hasAttribute, renderedHeight } = await page.evaluate(() => { const result = document.getElementById('result') as HTMLElement; diff --git a/e2e/local-to-app-embed.test.ts b/e2e/local-to-app-embed.test.ts new file mode 100644 index 0000000..86f6037 --- /dev/null +++ b/e2e/local-to-app-embed.test.ts @@ -0,0 +1,73 @@ +/** Real, navigation-free handoff from local.html to the embedded 0x67 app — + * jsdom tests each page in isolation and can't exercise a real iframe. + * + * Regression coverage for the fix that replaced router.html's link-based + * handoff — which discarded the File object on navigation, forcing the user + * to reselect the same file on 0x67.html's own upload screen — with an + * in-page iframe embed that hands the already-read bytes straight to the app + * over postMessage. */ +import assert from 'node:assert/strict'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import puppeteer, { type Browser, type ElementHandle, type Page } from 'puppeteer-core'; +import { resolveChromePath } from './support/chrome.ts'; +import { type DistServer, startDistServer } from './support/dist-server.ts'; +import { writeKdbxFixture } from './support/fixture.ts'; +import { resolveLaunchOptions } from './support/launch-options.ts'; + +const distDir = fileURLToPath(new URL('../dist', import.meta.url)); + +let server: DistServer; +let browser: Browser; +let page: Page; + +before(async () => { + server = await startDistServer(distDir); + browser = await puppeteer.launch({ + executablePath: resolveChromePath(), + ...resolveLaunchOptions(), + args: ['--no-sandbox'], + }); + page = await browser.newPage(); +}); + +after(async () => { + await browser.close(); + await server.close(); +}); + +test('dropping a file on local.html embeds a working 0x67 app that unlocks the same file, with no reselection', async () => { + const fixture = await writeKdbxFixture(); + + await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' }); + + // waitForSelector can't infer the element type from an id selector. + const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle; + assert.ok(fileInput, 'the file input exists'); + await fileInput.uploadFile(fixture.path); + + const iframeElement = await page.waitForSelector('#app-frame'); + assert.ok(iframeElement, 'a recognized file embeds the app in an iframe'); + assert.equal( + page.url(), + `${server.origin}/local.html`, + 'still on local.html — recognizing the file did not navigate away', + ); + + const iframeFrame = await iframeElement.contentFrame(); + assert.ok(iframeFrame, 'the iframe has a content frame'); + + // No second file input here: the bytes local.html already read are handed + // straight to the embedded app, so it opens directly on its unlock screen. + const passwordInput = await iframeFrame.waitForSelector('#master-password'); + assert.ok(passwordInput, 'the embedded app went straight to its unlock screen'); + await passwordInput.type(fixture.password); + await iframeFrame.click('#unlock-btn'); + + await iframeFrame.waitForSelector('.entry-table'); + const titleText = await iframeFrame.$eval('.entry-table-title', (el) => el.textContent); + assert.ok( + titleText?.includes(fixture.entryTitle), + `unlocked vault shows the fixture entry, got "${titleText}"`, + ); +}); diff --git a/e2e/router-to-app-navigation.test.ts b/e2e/router-to-app-navigation.test.ts deleted file mode 100644 index f652636..0000000 --- a/e2e/router-to-app-navigation.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** Real navigation from router.html to 0x67.html via a real link click — - * jsdom tests each page in isolation and can't exercise this. The "Open" - * link carries no handoff, so the file is selected again on the far side, - * same as a real user would. */ -import assert from 'node:assert/strict'; -import { after, before, test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import puppeteer, { type Browser, type ElementHandle, type Page } from 'puppeteer-core'; -import { resolveChromePath } from './support/chrome.ts'; -import { type DistServer, startDistServer } from './support/dist-server.ts'; -import { writeKdbxFixture } from './support/fixture.ts'; -import { resolveLaunchOptions } from './support/launch-options.ts'; - -const distDir = fileURLToPath(new URL('../dist', import.meta.url)); - -let server: DistServer; -let browser: Browser; -let page: Page; - -before(async () => { - server = await startDistServer(distDir); - browser = await puppeteer.launch({ - executablePath: resolveChromePath(), - ...resolveLaunchOptions(), - args: ['--no-sandbox'], - }); - page = await browser.newPage(); -}); - -after(async () => { - await browser.close(); - await server.close(); -}); - -test('dropping a file on the router, then following its real "Open" link, boots a working 0x67.html that unlocks the same file', async () => { - const fixture = await writeKdbxFixture(); - - await page.goto(`${server.origin}/router.html`, { waitUntil: 'networkidle0' }); - - // waitForSelector can't infer the element type from an id selector. - const routerFileInput = (await page.waitForSelector( - '#file-input', - )) as ElementHandle; - assert.ok(routerFileInput, 'the file input exists'); - await routerFileInput.uploadFile(fixture.path); - - const openLink = await page.waitForSelector('#result-link:not([hidden])'); - assert.ok(openLink, 'a real, visible "Open" link appears once the format is recognized'); - const href = await openLink.evaluate((el) => el.getAttribute('href')); - assert.equal(href, '0x67.html'); - - await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle0' }), openLink.click()]); - - assert.ok(page.url().endsWith('/0x67.html'), `expected to land on 0x67.html, got ${page.url()}`); - - // No handoff — select the same file again on 0x67.html's own upload screen. - const appFileInput = (await page.waitForSelector( - '#file-input', - )) as ElementHandle; - await appFileInput.uploadFile(fixture.path); - - const passwordInput = await page.waitForSelector('#master-password'); - assert.ok(passwordInput, 'the unlock screen rendered on the newly-navigated document'); - await passwordInput.type(fixture.password); - await page.click('#unlock-btn'); - - await page.waitForSelector('.entry-table'); - const titleText = await page.$eval('.entry-table-title', (el) => el.textContent); - assert.ok( - titleText?.includes(fixture.entryTitle), - `unlocked vault shows the fixture entry, got "${titleText}"`, - ); -});