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.
-
Save the updated database back to Google Drive.
+
Save the updated database back to where it was opened from.
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 @@
+
+
+
+
+
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.
-