From 7588a75bdcfee0a6fd815ebf52226a6fe6f99697 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 17:42:00 -0400 Subject: [PATCH] docs: implementation plan for attach-existing-vault wizard split (v0.2.0) 11 main tasks + 2 addendum tasks (Tasks 7a/7b) covering: - GitHost.lastCommit() and GitHost.writeFileCreateOnly() - Vault-presence probe helper - Wizard state refactor + Step 0 mode picker - Step 2 probe wiring with mode-mismatch banners - Step 3a clobber guard via writeFileCreateOnly - Step 3b attach flow with decrypt verification - Step 5 unified device registration (fixes silent-drop pubkey bug) - Default vault_settings_json WASM export + wizard settings.enc write (fixes runtime get_vault_settings 404 reported on wizard-init vaults) - Version bump to 0.2.0 + CHANGELOG Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-04-27-attach-existing-vault.md | 1533 +++++++++++++++++ 1 file changed, 1533 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-attach-existing-vault.md diff --git a/docs/superpowers/plans/2026-04-27-attach-existing-vault.md b/docs/superpowers/plans/2026-04-27-attach-existing-vault.md new file mode 100644 index 0000000..0a22b14 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-attach-existing-vault.md @@ -0,0 +1,1533 @@ +# Attach Existing Vault — Implementation Plan (v0.2.0) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a GUI-only "attach this device to an existing vault" path to the setup wizard, and prevent the wizard from silently overwriting an existing remote vault. Bump versions to 0.2.0. + +**Architecture:** Adds a leading mode picker (Step 0) that splits the wizard into `new` and `attach` paths. Both paths share host config (Steps 1, 2). Step 2 probes the remote and routes the user to the correct path with a confirmation/warning banner. Step 3a (new) creates vault files using a new create-only write primitive. Step 3b (attach) verifies passphrase + reference image by decrypting `manifest.enc` before any remote write. Step 5 unifies device registration via a direct host write (replacing today's broken fire-and-forget `add_device` SW call). + +**Tech Stack:** TypeScript (extension), vitest, Gitea/GitHub Contents API, WASM bindings (`relicario-wasm`). + +**Spec:** `docs/superpowers/specs/2026-04-27-attach-existing-vault-design.md` + +--- + +## File map + +**Modified:** +- `extension/src/service-worker/git-host.ts` — interface gains `lastCommit()`, `writeFileCreateOnly()`. +- `extension/src/service-worker/gitea.ts` — implement new methods. +- `extension/src/service-worker/github.ts` — implement new methods. +- `extension/src/setup/setup.ts` — almost complete restructure. Adds `mode`, Step 0, Step 3b, probe logic, mode-aware Step 5. +- `extension/setup.html` — progress-bar adjusts from 5 to 6 segments. +- `extension/src/setup/setup.css` (or wherever wizard styles live) — minor banner styles. +- `extension/manifest.json`, `extension/package.json` — version → 0.2.0. +- `crates/relicario-core/Cargo.toml`, `crates/relicario-cli/Cargo.toml`, `crates/relicario-wasm/Cargo.toml` — version → 0.2.0. + +**Created:** +- `extension/src/service-worker/__tests__/git-host-extensions.test.ts` — tests for `lastCommit` + `writeFileCreateOnly`. +- `extension/src/setup/__tests__/probe.test.ts` — tests for vault-presence probe helper. +- `CHANGELOG.md` (project root) — release notes file. + +--- + +## Pre-existing bug surfaced by this work + +Today's `extension/src/setup/setup.ts:833-838` calls `chrome.runtime.sendMessage({ type: 'add_device', ... })` fire-and-forget after `save_setup`. The SW handler at `extension/src/service-worker/router/popup-only.ts:302-311` requires `state.gitHost`, which is `null` until vault unlock. So today, **the new-device pubkey is never written to the remote `devices.json` during initial setup** — it's silently dropped. This plan fixes that as part of Task 8 by replacing the SW call with a direct host write from the wizard. + +--- + +## Task 1: Add `lastCommit()` to GitHost + +**Files:** +- Modify: `extension/src/service-worker/git-host.ts` +- Modify: `extension/src/service-worker/gitea.ts` +- Modify: `extension/src/service-worker/github.ts` +- Create: `extension/src/service-worker/__tests__/git-host-extensions.test.ts` + +Returns metadata for the most recent commit touching a path (used to display "this vault was last modified by X on date Y" when probing). Best-effort — callers handle `null`. + +- [ ] **Step 1: Write the failing tests** + +Create `extension/src/service-worker/__tests__/git-host-extensions.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GiteaHost } from '../gitea'; +import { GitHubHost } from '../github'; + +describe('lastCommit (Gitea)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + it('returns commit metadata when API succeeds', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify([ + { sha: 'abc1234567', commit: { author: { name: 'Alice', date: '2026-04-20T12:00:00Z' } } }, + ]), { status: 200 })); + const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok'); + const result = await host.lastCommit('manifest.enc'); + expect(result).toEqual({ sha: 'abc1234', author: 'Alice', date: '2026-04-20T12:00:00Z' }); + }); + + it('returns null on 404', async () => { + fetchSpy.mockResolvedValueOnce(new Response('', { status: 404 })); + const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok'); + expect(await host.lastCommit('manifest.enc')).toBeNull(); + }); + + it('returns null on network error', async () => { + fetchSpy.mockRejectedValueOnce(new Error('network')); + const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok'); + expect(await host.lastCommit('manifest.enc')).toBeNull(); + }); +}); + +describe('lastCommit (GitHub)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + it('returns commit metadata when API succeeds', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify([ + { sha: 'def4567890', commit: { author: { name: 'Bob', date: '2026-04-22T15:00:00Z' } } }, + ]), { status: 200 })); + const host = new GitHubHost('user/vault', 'tok'); + const result = await host.lastCommit('manifest.enc'); + expect(result).toEqual({ sha: 'def4567', author: 'Bob', date: '2026-04-22T15:00:00Z' }); + }); + + it('returns null when commits list is empty', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })); + const host = new GitHubHost('user/vault', 'tok'); + expect(await host.lastCommit('manifest.enc')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd extension && npm test -- git-host-extensions +``` + +Expected: FAIL with "host.lastCommit is not a function". + +- [ ] **Step 3: Add interface method** + +Edit `extension/src/service-worker/git-host.ts` — inside the `GitHost` interface, after `listDir`: + +```typescript + /// Best-effort: returns metadata for the most recent commit touching `path`. + /// Returns null if the path has no commits, the API fails, or the host + /// doesn't support the lookup. Callers must tolerate null. + lastCommit(path: string): Promise<{ sha: string; author: string; date: string } | null>; +``` + +- [ ] **Step 4: Implement on Gitea** + +Edit `extension/src/service-worker/gitea.ts` — add method after `listDir`: + +```typescript + async lastCommit(path: string): Promise<{ sha: string; author: string; date: string } | null> { + try { + const url = `${this.commitsUrl}?path=${encodeURIComponent(path)}&limit=1`; + const resp = await fetch(url, { headers: this.headers }); + if (!resp.ok) return null; + const json = await resp.json(); + if (!Array.isArray(json) || json.length === 0) return null; + const c = json[0]; + return { + sha: String(c.sha).slice(0, 7), + author: c.commit?.author?.name ?? 'unknown', + date: c.commit?.author?.date ?? '', + }; + } catch { + return null; + } + } +``` + +If `this.commitsUrl` does not yet exist on the class, add it to the constructor: `this.commitsUrl = \`${apiUrl}/repos/${repoPath}/commits\`;` next to where `this.baseUrl` is defined. Confirm the field name in the file before editing. + +- [ ] **Step 5: Implement on GitHub** + +Edit `extension/src/service-worker/github.ts` — add method after `listDir`. Same body, same `commitsUrl` field added in the constructor (`https://api.github.com/repos/${repoPath}/commits`). + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +cd extension && npm test -- git-host-extensions +``` + +Expected: 5 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add extension/src/service-worker/git-host.ts extension/src/service-worker/gitea.ts extension/src/service-worker/github.ts extension/src/service-worker/__tests__/git-host-extensions.test.ts +git commit -m "feat(ext/sw): GitHost.lastCommit() for vault-presence metadata" +``` + +--- + +## Task 2: Add `writeFileCreateOnly()` to GitHost + +**Files:** +- Modify: `extension/src/service-worker/git-host.ts` +- Modify: `extension/src/service-worker/gitea.ts` +- Modify: `extension/src/service-worker/github.ts` +- Modify: `extension/src/service-worker/__tests__/git-host-extensions.test.ts` + +Today's `writeFile` does PUT-or-create, blindly overwriting if the file exists. Setup wizard's new-vault path must not overwrite. Add a sibling that fails fast if the file already exists. + +- [ ] **Step 1: Add failing tests** + +Append to `extension/src/service-worker/__tests__/git-host-extensions.test.ts`: + +```typescript +describe('writeFileCreateOnly (Gitea)', () => { + let fetchSpy: ReturnType; + beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); + + it('creates when file does not exist', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('', { status: 404 })) // pre-check + .mockResolvedValueOnce(new Response('{}', { status: 201 })); // POST + const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok'); + await host.writeFileCreateOnly('manifest.enc', new Uint8Array([1, 2, 3]), 'init'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect((fetchSpy.mock.calls[1][1] as RequestInit).method).toBe('POST'); + }); + + it('throws when file already exists', async () => { + fetchSpy.mockResolvedValueOnce(new Response( + JSON.stringify({ sha: 'abc' }), { status: 200 }, + )); + const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok'); + await expect( + host.writeFileCreateOnly('manifest.enc', new Uint8Array([1]), 'init'), + ).rejects.toThrow(/already exists/); + expect(fetchSpy).toHaveBeenCalledTimes(1); // pre-check only, no write + }); +}); + +describe('writeFileCreateOnly (GitHub)', () => { + let fetchSpy: ReturnType; + beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); + + it('throws when file already exists', async () => { + fetchSpy.mockResolvedValueOnce(new Response( + JSON.stringify({ sha: 'abc' }), { status: 200 }, + )); + const host = new GitHubHost('user/vault', 'tok'); + await expect( + host.writeFileCreateOnly('manifest.enc', new Uint8Array([1]), 'init'), + ).rejects.toThrow(/already exists/); + }); + + it('creates when file does not exist', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('', { status: 404 })) + .mockResolvedValueOnce(new Response('{}', { status: 201 })); + const host = new GitHubHost('user/vault', 'tok'); + await host.writeFileCreateOnly('manifest.enc', new Uint8Array([1]), 'init'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd extension && npm test -- git-host-extensions +``` + +Expected: 4 new tests fail. + +- [ ] **Step 3: Add to interface** + +Edit `extension/src/service-worker/git-host.ts`: + +```typescript + /// Like writeFile, but throws if the file already exists. Used by setup + /// wizard to refuse to clobber existing vault state. Implementation must + /// pre-check existence and only POST/PUT-create — never include a sha. + writeFileCreateOnly(path: string, content: Uint8Array, message: string): Promise; +``` + +- [ ] **Step 4: Implement on Gitea** + +In `gitea.ts`, add after `writeFile`: + +```typescript + async writeFileCreateOnly(path: string, content: Uint8Array, message: string): Promise { + const existing = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (existing.ok) { + throw new Error(`writeFileCreateOnly: ${path} already exists`); + } + const b64 = uint8ArrayToBase64(content); + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ content: b64, message }), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Gitea writeFileCreateOnly ${path}: ${resp.status} ${text}`); + } + } +``` + +- [ ] **Step 5: Implement on GitHub** + +In `github.ts`, add after `writeFile`: + +```typescript + async writeFileCreateOnly(path: string, content: Uint8Array, message: string): Promise { + const existing = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (existing.ok) { + throw new Error(`writeFileCreateOnly: ${path} already exists`); + } + const b64 = uint8ArrayToBase64(content); + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'PUT', + headers: this.headers, + body: JSON.stringify({ content: b64, message }), // no sha → create-only + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`GitHub writeFileCreateOnly ${path}: ${resp.status} ${text}`); + } + } +``` + +- [ ] **Step 6: Run tests, verify pass** + +```bash +cd extension && npm test -- git-host-extensions +``` + +Expected: 9 tests total pass. + +- [ ] **Step 7: Commit** + +```bash +git add extension/src/service-worker/ +git commit -m "feat(ext/sw): GitHost.writeFileCreateOnly() refuses to overwrite" +``` + +--- + +## Task 3: Vault-presence probe helper + +**Files:** +- Create: `extension/src/setup/probe.ts` +- Create: `extension/src/setup/__tests__/probe.test.ts` + +Pure function that takes a `GitHost` and returns `{ exists: boolean; lastCommit?: ... }`. Used by Step 2. + +- [ ] **Step 1: Write failing test** + +Create `extension/src/setup/__tests__/probe.test.ts`: + +```typescript +import { describe, expect, it, vi } from 'vitest'; +import { probeVault } from '../probe'; +import type { GitHost } from '../../service-worker/git-host'; + +function fakeHost(opts: { + relicarioFiles?: string[]; + rootFiles?: string[]; + commit?: { sha: string; author: string; date: string } | null; +} = {}): GitHost { + return { + listDir: vi.fn().mockImplementation(async (p: string) => { + if (p === '.relicario') return opts.relicarioFiles ?? []; + if (p === '') return opts.rootFiles ?? []; + return []; + }), + lastCommit: vi.fn().mockResolvedValue(opts.commit ?? null), + readFile: vi.fn(), writeFile: vi.fn(), writeFileCreateOnly: vi.fn(), + deleteFile: vi.fn(), putBlob: vi.fn(), getBlob: vi.fn(), deleteBlob: vi.fn(), + }; +} + +describe('probeVault', () => { + it('reports exists=false when repo is empty', async () => { + const host = fakeHost(); + expect(await probeVault(host)).toEqual({ exists: false }); + }); + + it('reports exists=true when manifest.enc is present', async () => { + const host = fakeHost({ + rootFiles: ['manifest.enc', 'README.md'], + commit: { sha: 'abc1234', author: 'Alice', date: '2026-04-20T12:00:00Z' }, + }); + const result = await probeVault(host); + expect(result.exists).toBe(true); + expect(result.lastCommit?.author).toBe('Alice'); + }); + + it('reports exists=true when only .relicario/salt is present (partial init)', async () => { + const host = fakeHost({ relicarioFiles: ['salt'] }); + expect((await probeVault(host)).exists).toBe(true); + }); + + it('omits lastCommit field when API returns null', async () => { + const host = fakeHost({ rootFiles: ['manifest.enc'], commit: null }); + const result = await probeVault(host); + expect(result.exists).toBe(true); + expect(result.lastCommit).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd extension && npm test -- setup/__tests__/probe +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Write implementation** + +Create `extension/src/setup/probe.ts`: + +```typescript +import type { GitHost } from '../service-worker/git-host'; + +export interface VaultProbe { + exists: boolean; + lastCommit?: { sha: string; author: string; date: string }; +} + +/// Detect whether the configured remote already contains a relicario vault. +/// Considered present if any of: .relicario/salt, .relicario/params.json, +/// manifest.enc exists. Best-effort metadata fetch via lastCommit(). +export async function probeVault(host: GitHost): Promise { + const [relicarioFiles, rootFiles] = await Promise.all([ + host.listDir('.relicario'), + host.listDir(''), + ]); + const exists = + relicarioFiles.includes('salt') || + relicarioFiles.includes('params.json') || + rootFiles.includes('manifest.enc'); + if (!exists) return { exists: false }; + const lastCommit = await host.lastCommit('manifest.enc'); + return lastCommit ? { exists, lastCommit } : { exists }; +} +``` + +- [ ] **Step 4: Run test, verify pass** + +```bash +cd extension && npm test -- setup/__tests__/probe +``` + +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/setup/probe.ts extension/src/setup/__tests__/probe.test.ts +git commit -m "feat(ext/setup): vault-presence probe helper" +``` + +--- + +## Task 4: Wizard state shape + step renumbering + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +Adds `mode`, `vaultProbe`, `verifiedHandle`, `referenceImageBytesAttach` to `WizardState`. Renumbers existing steps from `1..5` to `1..5` (kept; new picker is Step 0 — see Task 5). No behaviour change yet — strictly type/shape and a default mode. + +- [ ] **Step 1: Edit `WizardState` interface** + +In `extension/src/setup/setup.ts:30-51`, add fields: + +```typescript +interface WizardState { + step: number; // now 0..5; was 1..5 + mode: 'new' | 'attach' | null; // null until Step 0 picks + hostType: 'gitea' | 'github'; + hostUrl: string; + repoPath: string; + apiToken: string; + connectionTested: boolean; + vaultProbe: import('./probe').VaultProbe | null; + carrierImageBytes: Uint8Array | null; + referenceImageBytesAttach: Uint8Array | null; + passphrase: string; + passphraseConfirm: string; + passphraseScore: number; + passphraseGuessesLog10: number; + passphraseVisible: boolean; + confirmVisible: boolean; + referenceImageBytes: Uint8Array | null; // produced by new-vault embed + verifiedHandle: number | null; // attach-mode WASM handle + creating: boolean; + attaching: boolean; + error: string | null; + extensionDetected: boolean; + configPushed: boolean; + deviceName: string; +} +``` + +Update the initial `state` literal to include the new fields with defaults: `mode: null`, `vaultProbe: null`, `referenceImageBytesAttach: null`, `verifiedHandle: null`, `attaching: false`, and start with `step: 0`. + +- [ ] **Step 2: Update render switch** + +Edit the `render()` `switch (state.step)` block (~line 236) to handle case 0: + +```typescript + switch (state.step) { + case 0: stepHtml = renderStep0(); break; + case 1: stepHtml = renderStep1(); break; + case 2: stepHtml = renderStep2(); break; + case 3: stepHtml = state.mode === 'attach' ? renderStep3Attach() : renderStep3New(); break; + case 4: stepHtml = renderStep4(); break; + case 5: stepHtml = renderStep5(); break; + } +``` + +And the `attachX()` switch to mirror. + +`renderStep0`, `renderStep3Attach`, `attachStep0`, `attachStep3Attach` are stubbed in this task to keep the diff minimal: + +```typescript +function renderStep0(): string { return '

Step 0 (placeholder)

'; } +function attachStep0(): void { /* filled in Task 5 */ } +function renderStep3Attach(): string { return '

Step 3b (placeholder)

'; } +function attachStep3Attach(): void { /* filled in Task 8 */ } +``` + +Rename existing `renderStep3` → `renderStep3New`, `attachStep3` → `attachStep3New`. + +- [ ] **Step 3: Update progress bar** + +In `render()` at ~line 226, expand to 6 segments: + +```typescript + const progressHtml = ` +
+
+
+
+
+
+
+
+ `; +``` + +- [ ] **Step 4: Build, verify it compiles** + +```bash +cd extension && npm run build +``` + +Expected: build succeeds. The wizard will now boot to a placeholder Step 0 — that's intentional, Task 5 fills it in. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/setup/setup.ts +git commit -m "refactor(ext/setup): wizard state shape for mode-aware flow" +``` + +--- + +## Task 5: Step 0 — mode picker + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +Replace the placeholder `renderStep0`/`attachStep0` from Task 4 with a real picker. + +- [ ] **Step 1: Replace `renderStep0`** + +```typescript +function renderStep0(): string { + const isNew = state.mode === 'new'; + const isAttach = state.mode === 'attach'; + return ` +
+

set up relicario

+

+ How are you using relicario on this device? +

+
+ + +
+
+ +
+
+ `; +} +``` + +- [ ] **Step 2: Replace `attachStep0`** + +```typescript +function attachStep0(): void { + document.querySelectorAll('.mode-card').forEach((btn) => { + btn.addEventListener('click', () => { + state.mode = (btn as HTMLElement).dataset.mode as 'new' | 'attach'; + render(); + }); + }); + document.getElementById('next-btn')?.addEventListener('click', () => { + if (!state.mode) return; + state.step = 1; + state.error = null; + render(); + }); +} +``` + +- [ ] **Step 3: Add CSS for `.mode-cards` and `.mode-card`** + +Append to the wizard's stylesheet (locate by `grep -l "wizard-step" extension/`): + +```css +.mode-cards { display: flex; flex-direction: column; gap: 12px; } +.mode-card { + text-align: left; padding: 16px; border: 1px solid var(--border, #ccc); + border-radius: 8px; background: transparent; cursor: pointer; + font: inherit; color: inherit; +} +.mode-card:hover { border-color: var(--accent, #888); } +.mode-card.active { border-color: var(--primary, #4a90e2); background: var(--accent-bg, rgba(74,144,226,0.08)); } +.mode-card-title { font-weight: 600; margin-bottom: 4px; } +.mode-card-blurb { color: var(--muted, #777); font-size: 0.9em; margin: 0; } +``` + +- [ ] **Step 4: Wire Step 1 back-button** + +In `attachStep1`, add a back button that returns to Step 0 (currently Step 1's render only has a "next" button). Modify `renderStep1` form-actions: + +```typescript +
+ + +
+``` + +And in `attachStep1` add: + +```typescript + document.getElementById('back-btn')?.addEventListener('click', () => { + state.step = 0; + state.error = null; + render(); + }); +``` + +- [ ] **Step 5: Build + smoke test** + +```bash +cd extension && npm run build +``` + +Load the unpacked extension, click "set up new vault" from the popup, confirm Step 0 renders both cards, clicking either highlights it and enables next, and clicking next advances to Step 1. + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/setup/setup.ts extension/setup.html extension/src/setup/setup.css 2>/dev/null || git add extension/src/setup/ +git commit -m "feat(ext/setup): Step 0 mode picker (new vs attach)" +``` + +--- + +## Task 6: Step 2 — vault-presence probe + mode-mismatch banners + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +After connection-test succeeds, run `probeVault`. Display a banner that varies by `(mode, vaultProbe.exists)`. Disable "next" when mode mismatches; offer a "switch mode" button that preserves config. + +- [ ] **Step 1: Import probe** + +Add at the top of `extension/src/setup/setup.ts`: + +```typescript +import { probeVault } from './probe'; +``` + +- [ ] **Step 2: Modify the `test-btn` handler** + +Inside `attachStep2`, replace the existing test-button handler. After the existing `host.listDir('')` success block, add: + +```typescript + try { + const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken); + await host.listDir(''); + state.connectionTested = true; + state.error = null; + // New: probe for existing vault + try { + state.vaultProbe = await probeVault(host); + } catch (probeErr) { + state.vaultProbe = null; + state.error = `Could not check repo state: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`; + } + } catch (err: unknown) { + state.connectionTested = false; + state.vaultProbe = null; + state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`; + } + render(); +``` + +- [ ] **Step 3: Add banner render to `renderStep2`** + +Inside `renderStep2`, before the form-actions section, insert: + +```typescript + ${renderProbeBanner()} +``` + +Then add the helper function: + +```typescript +function renderProbeBanner(): string { + const probe = state.vaultProbe; + if (!state.connectionTested || !probe) return ''; + const meta = probe.lastCommit + ? `Last commit: ${escapeHtml(probe.lastCommit.sha)} by ${escapeHtml(probe.lastCommit.author)} on ${escapeHtml(probe.lastCommit.date.slice(0, 10))}.` + : ''; + if (state.mode === 'new' && probe.exists) { + return ` + `; + } + if (state.mode === 'attach' && !probe.exists) { + return ` + `; + } + if (state.mode === 'attach' && probe.exists) { + return ` + `; + } + // mode = new, !exists + return ` + `; +} +``` + +- [ ] **Step 4: Gate the next button on mode-match** + +Update the `next-btn` disabled attribute in `renderStep2`: + +```typescript +const probe = state.vaultProbe; +const modeMismatch = + probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists)); +const nextDisabled = !state.connectionTested || !probe || modeMismatch; +``` + +Use `${nextDisabled ? 'disabled' : ''}` on the button. + +- [ ] **Step 5: Wire switch-mode button** + +In `attachStep2`, after the next-btn handler: + +```typescript + document.getElementById('switch-mode-btn')?.addEventListener('click', (e) => { + const target = (e.currentTarget as HTMLElement).dataset.target as 'new' | 'attach'; + state.mode = target; + state.error = null; + render(); + }); +``` + +(Host config is preserved — we don't touch hostUrl/repoPath/apiToken/connectionTested/vaultProbe.) + +- [ ] **Step 6: Add banner CSS** + +Append: + +```css +.banner { padding: 12px; border-radius: 6px; margin: 12px 0; } +.banner-ok { background: rgba(46,160,67,0.10); border: 1px solid rgba(46,160,67,0.4); } +.banner-warn { background: rgba(218,54,51,0.08); border: 1px solid rgba(218,54,51,0.4); } +.banner code { font-family: monospace; font-size: 0.9em; } +``` + +- [ ] **Step 7: Build + smoke test** + +```bash +cd extension && npm run build +``` + +Manual: against an empty test repo, mode=new — banner says "ready to create." Mode-switch to attach — banner says "no vault found." Against a populated test repo (use the one you have), mode=new — banner is the warning card; clicking "switch to attach" preserves host config and turns banner green. Verify "next" is disabled in mismatch states. + +- [ ] **Step 8: Commit** + +```bash +git add extension/src/setup/setup.ts extension/src/setup/setup.css 2>/dev/null || git add extension/src/setup/ +git commit -m "feat(ext/setup): vault-presence probe + mode-mismatch banners on Step 2" +``` + +--- + +## Task 7: Step 3a — clobber guard via writeFileCreateOnly + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +In the existing new-vault create flow (renamed `attachStep3New` from Task 4), replace `host.writeFile(...)` calls for vault files with `host.writeFileCreateOnly(...)`. Defer `devices.json` creation to Step 5 (Task 8 will land that). + +- [ ] **Step 1: Edit the create-button handler** + +In `attachStep3New`, locate the block starting at `stage = 'push vault files'` (~line 627). Change writes: + +```typescript + log('write .relicario/salt'); + await host.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt'); + + log('write .relicario/params.json'); + const paramsBytes = new TextEncoder().encode(paramsJson); + await host.writeFileCreateOnly('.relicario/params.json', paramsBytes, 'init: KDF parameters'); + + log('write manifest.enc'); + await host.writeFileCreateOnly( + 'manifest.enc', + new Uint8Array(encryptedManifest), + 'init: encrypted manifest', + ); +``` + +**Remove** the existing `.relicario/devices.json` write — it moves to Step 5 (Task 8). + +- [ ] **Step 2: Friendly error mapping** + +Wrap the push block: if any `writeFileCreateOnly` throws "already exists," surface a clearer message to the user. Add inside the catch block (~line 659): + +```typescript + } catch (err: unknown) { + console.error(`[relicario setup] vault creation FAILED during "${stage}":`, err); + state.creating = false; + const detail = err instanceof Error ? err.message : String(err); + if (/already exists/.test(detail)) { + state.error = `A file at ${detail.replace(/^.*?writeFileCreateOnly: /, '')} already exists on the remote — refusing to overwrite. Re-run setup; the wizard will offer to attach to the existing vault.`; + } else { + state.error = `Vault creation failed at "${stage}": ${detail}`; + } + render(); + } +``` + +- [ ] **Step 3: Build + manual smoke test** + +```bash +cd extension && npm run build +``` + +Against an empty test repo: setup new vault end-to-end, confirm files land. Then immediately re-run setup (don't delete) and confirm the create path now refuses with the friendly error. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/setup/setup.ts +git commit -m "feat(ext/setup): refuse to overwrite existing vault files (Step 3a)" +``` + +--- + +## Task 8: Step 3b — attach flow + decrypt verification + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +Replace the placeholder `renderStep3Attach`/`attachStep3Attach` with real implementations. + +- [ ] **Step 1: Implement `renderStep3Attach`** + +```typescript +function renderStep3Attach(): string { + const p = state.passphrase; + const pType = state.passphraseVisible ? 'text' : 'password'; + const pToggle = state.passphraseVisible ? 'hide' : 'show'; + const hasImage = !!state.referenceImageBytesAttach; + const gateDisabled = state.attaching || !p || !hasImage; + + return ` +
+

attach this device

+

+ Use your existing passphrase and reference image to attach this browser + to your vault. We'll verify both before registering this device. +

+ +
+ +
+ + ${hasImage + ? '

reference image loaded

' + : '

click to select your reference JPEG

'} +
+

+ The reference image is the JPEG you saved when you first created this vault — + not the original photo. It has the 256-bit secret embedded. +

+
+ +
+ +
+ + +
+
+ +
+ + +
+
+ `; +} +``` + +- [ ] **Step 2: Implement `attachStep3Attach`** + +```typescript +function attachStep3Attach(): void { + const refDrop = document.getElementById('ref-drop')!; + const refInput = document.getElementById('ref-input') as HTMLInputElement; + + refDrop.addEventListener('click', () => refInput.click()); + refInput.addEventListener('change', () => { + const file = refInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer); + state.error = null; + render(); + }; + reader.readAsArrayBuffer(file); + }); + + const passInput = document.getElementById('passphrase') as HTMLInputElement | null; + passInput?.addEventListener('input', (e) => { + state.passphrase = (e.target as HTMLInputElement).value; + const btn = document.getElementById('attach-btn') as HTMLButtonElement | null; + if (btn) btn.disabled = state.attaching || !state.passphrase || !state.referenceImageBytesAttach; + }); + + document.getElementById('eye-btn')?.addEventListener('click', () => { + state.passphraseVisible = !state.passphraseVisible; + if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password'; + const btn = document.getElementById('eye-btn'); + if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show'; + passInput?.focus(); + }); + + document.getElementById('back-btn')?.addEventListener('click', () => { + state.step = 2; + state.error = null; + render(); + }); + + document.getElementById('attach-btn')?.addEventListener('click', async () => { + if (!state.referenceImageBytesAttach || !state.passphrase) return; + state.attaching = true; + state.error = null; + render(); + + const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? ''); + let stage = 'init'; + let handle: number | null = null; + try { + stage = 'load wasm'; + log(stage); + const w = await loadWasm(); + + stage = 'fetch vault metadata'; + log(stage); + const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; + const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); + const [salt, paramsBytes, encryptedManifest] = await Promise.all([ + host.readFile('.relicario/salt'), + host.readFile('.relicario/params.json'), + host.readFile('manifest.enc'), + ]); + const paramsJson = new TextDecoder().decode(paramsBytes); + + stage = 'derive session handle'; + log(stage); + handle = w.unlock(state.passphrase, state.referenceImageBytesAttach, salt, paramsJson); + + stage = 'decrypt manifest'; + log(stage); + // Throws if AEAD verification fails — wrong passphrase or wrong image. + w.manifest_decrypt(handle, encryptedManifest); + + log('attach verified — advancing'); + state.verifiedHandle = handle; + state.attaching = false; + state.step = 4; + state.error = null; + render(); + } catch (err: unknown) { + console.error(`[relicario setup] attach FAILED during "${stage}":`, err); + state.attaching = false; + // Lock any partial handle to avoid leaking key material. + if (handle !== null) { + try { (await loadWasm()).lock(handle); } catch { /* best effort */ } + } + state.verifiedHandle = null; + const detail = err instanceof Error ? err.message : String(err); + // Stage-aware copy: if we got past 'fetch', this is a credential failure. + if (stage === 'derive session handle' || stage === 'decrypt manifest') { + state.error = 'Could not decrypt vault — wrong passphrase or reference image.'; + } else { + state.error = `Attach failed at "${stage}": ${detail}`; + } + render(); + } + }); +} +``` + +- [ ] **Step 3: Build + manual smoke test** + +```bash +cd extension && npm run build +``` + +Against your existing populated test repo: +1. Reinstall the extension fresh. +2. Wizard → Step 0 → attach. +3. Step 2: enter host config that points at your existing vault — banner should say "✓ existing vault found." +4. Step 3b: pick a *wrong* image. Should show "Could not decrypt vault…" — no progress. +5. Pick correct reference image, type wrong passphrase — same error. +6. Pick correct image, correct passphrase — should advance to Step 4. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/setup/setup.ts +git commit -m "feat(ext/setup): Step 3b attach flow with decrypt verification" +``` + +--- + +## Task 9: Step 5 — unified device registration via direct host write + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +Replace today's broken fire-and-forget `add_device` SW call. Both modes register the device by reading + writing `.relicario/devices.json` directly via the wizard's own `GitHost` client. Hide reference.jpg download on attach mode. + +- [ ] **Step 1: Import `addDevice` from devices module** + +Top of `setup.ts`: + +```typescript +import { addDevice } from '../service-worker/devices'; +``` + +- [ ] **Step 2: Rewrite `attachStep5`'s push-config-btn handler** + +Replace the current handler (~line 804). Pseudocode of the new flow: + +1. Generate device keypair (`w.generate_device_keypair()`). +2. Save private key to `chrome.storage.local`. +3. Call `save_setup` over SW (stores config + image in storage). +4. Build a `GitHost` client locally, call `addDevice(host, { name, public_key, added_at })`. +5. Set `state.configPushed = true` on success; show error on failure. +6. If `state.mode === 'attach'`, also call `wasm.lock(state.verifiedHandle)` to release the verification handle. + +```typescript + document.getElementById('push-config-btn')?.addEventListener('click', async () => { + state.error = null; + render(); + + const config: VaultConfig = { + hostType: state.hostType, + hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, + repoPath: state.repoPath, + apiToken: state.apiToken, + }; + + try { + const w = await loadWasm(); + const keypair = JSON.parse(w.generate_device_keypair()) as { + public_key_hex: string; private_key_base64: string; + }; + + // 1) Save private key + name locally. + await chrome.storage.local.set({ + device_name: state.deviceName, + device_private_key: keypair.private_key_base64, + }); + + // 2) Save vault config + reference image to extension storage. + const imageBase64 = state.referenceImageBytes + ? uint8ArrayToBase64(state.referenceImageBytes) + : state.referenceImageBytesAttach + ? uint8ArrayToBase64(state.referenceImageBytesAttach) + : ''; + const saveOk = await new Promise((resolve) => { + chrome.runtime.sendMessage( + { type: 'save_setup', config, imageBase64 }, + (response: { ok: boolean; error?: string }) => { + if (!response?.ok) { + state.error = response?.error ?? 'Failed to save config to extension'; + resolve(false); return; + } + resolve(true); + }, + ); + }); + if (!saveOk) { render(); return; } + + // 3) Register device on the remote. + const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; + const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); + await addDevice(host, { + name: state.deviceName, + public_key: keypair.public_key_hex, + added_at: Math.floor(Date.now() / 1000), + }); + + // 4) Release any attach-mode WASM handle. + if (state.verifiedHandle !== null) { + try { w.lock(state.verifiedHandle); } catch { /* best effort */ } + state.verifiedHandle = null; + } + + state.configPushed = true; + render(); + } catch (err: unknown) { + console.error('[relicario setup] register device failed:', err); + state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`; + render(); + } + }); +``` + +- [ ] **Step 3: Mode-aware copy + UI in `renderStep5`** + +Update `renderStep5` to: + +```typescript +function renderStep5(): string { + const config: VaultConfig = { + hostType: state.hostType, + hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl, + repoPath: state.repoPath, + apiToken: state.apiToken, + }; + const configJson = JSON.stringify(config, null, 2); + const isAttach = state.mode === 'attach'; + + return ` +
+
+

${isAttach ? 'device verified' : 'vault created'}

+

+ ${isAttach + ? 'Your passphrase and reference image decrypt the vault successfully.' + : 'Your vault has been initialized and pushed to the repository.'} +

+
+ + ${isAttach ? '' : ` +
+ +

+ Download and store this image securely. It is your second factor for decryption. + Without it, you cannot unlock the vault. +

+ +
+ `} + + ${state.extensionDetected ? ` +
+ + + ${state.configPushed ? 'done' : ''} +
+ ` : ` +
+ +

+ Copy this JSON and paste it into the extension setup, or save it for later. +

+
${escapeHtml(configJson)}
+ +
+ `} +
+ `; +} +``` + +- [ ] **Step 4: Build + manual end-to-end smoke test (both paths)** + +```bash +cd extension && npm run build +``` + +**Attach path:** +1. Wipe extension (uninstall + reinstall). +2. Step 0 → attach. Step 2 → existing vault. Step 3b → verify. +3. Step 4 → name = "Workstation Chrome". +4. Step 5 → "attach this device" → confirm `devices.json` on the remote now has two entries (the original + this one). +5. Open popup, confirm vault unlocks and lists previously-existing items unchanged. + +**New path:** +1. Empty test repo. Step 0 → new. Walk through end-to-end. +2. Confirm `.relicario/devices.json` lands on the remote with the new device entry (not empty). +3. Open popup, unlock, vault is empty as expected. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/setup/setup.ts +git commit -m "feat(ext/setup): unified device registration in Step 5; fixes silent dropped pubkey" +``` + +--- + +## Task 10: Version bumps + CHANGELOG + +**Files:** +- Modify: `Cargo.toml` (workspace, if version is set there) and/or `crates/*/Cargo.toml` +- Modify: `extension/manifest.json` +- Modify: `extension/package.json` +- Create: `CHANGELOG.md` + +- [ ] **Step 1: Confirm where the version lives** + +```bash +grep -E "^version" Cargo.toml crates/*/Cargo.toml +grep -E "version" extension/manifest.json extension/package.json +``` + +Expected output: each file has `version = "0.1.0"` or `"version": "0.1.0"`. + +- [ ] **Step 2: Bump all five files to 0.2.0** + +Use Edit on each: +- `crates/relicario-core/Cargo.toml`: `version = "0.2.0"` +- `crates/relicario-cli/Cargo.toml`: `version = "0.2.0"` +- `crates/relicario-wasm/Cargo.toml`: `version = "0.2.0"` +- `extension/manifest.json`: `"version": "0.2.0"` +- `extension/package.json`: `"version": "0.2.0"` + +If `extension/wasm/package.json` exists with a version, bump it too. + +- [ ] **Step 3: Run cargo check + extension build** + +```bash +cargo check +cd extension && npm run build && npm test +``` + +Expected: both pass; lockfile updates committed. + +- [ ] **Step 4: Create CHANGELOG.md** + +```markdown +# Changelog + +## v0.2.0 — 2026-04-27 + +### Fixed + +- **Setup wizard could silently overwrite an existing vault.** Pointing the + wizard at a remote that already contained a relicario vault would clobber + `manifest.enc`, `.relicario/salt`, and friends with no warning. The wizard + now probes the remote after the connection test and refuses to create a + new vault on top of an existing one. Affected users whose vault was wiped + by this bug should restore from the git history of the affected repo + (`git log` + `git checkout -- .`). +- **New devices registered during initial setup were silently dropped.** The + wizard's Step 5 fired `add_device` over a service-worker channel that + required an unlocked vault, which is unavailable mid-wizard. Device pubkeys + now write directly to `.relicario/devices.json` from the wizard. +- **Wizard-created vaults were missing `settings.enc`.** The CLI's `init` + writes a default-`VaultSettings` `settings.enc` alongside `manifest.enc`, + but the wizard skipped it, causing every `get_vault_settings` SW call to + 404. The wizard now encrypts and writes `settings.enc` using a new + `default_vault_settings_json` WASM helper that keeps defaults in sync + with Rust core. + +### Added + +- **Attach this device to an existing vault — purely from the GUI.** New + Step 0 mode picker splits the wizard into "create new vault" and "attach + this device." The attach path takes a passphrase + reference image, fetches + the existing manifest, verifies the credentials by decrypting it, and only + then registers a new device key. No CLI required for multi-device setup. +- `GitHost.lastCommit(path)` and `GitHost.writeFileCreateOnly(path, ...)`. + +## v0.1.0 — 2026-04-22 + +Initial release. +``` + +- [ ] **Step 5: Commit** + +```bash +git add Cargo.toml crates/*/Cargo.toml extension/manifest.json extension/package.json extension/wasm/package.json CHANGELOG.md Cargo.lock 2>/dev/null +git commit -m "chore: bump version to 0.2.0 + add CHANGELOG" +``` + +--- + +## Task 11: Tag the release + +**Files:** none (git tag). + +- [ ] **Step 1: Run all tests one more time** + +```bash +cargo test +cd extension && npm test +``` + +Expected: all green. + +- [ ] **Step 2: Tag** + +After confirming all the above tasks merged to main: + +```bash +git tag -a v0.2.0 -m "v0.2.0 — attach existing vault from GUI + clobber-overwrite fix" +``` + +Do **not** push the tag automatically — leave that to the user, since pushing a tag is hard to reverse and externally visible. + +- [ ] **Step 3: Tell the user** + +Report: tag created locally, list it with `git tag --list 'v0.2*'`, and ask if they want it pushed to origin. + +--- + +## Addendum (post-spec finding): missing `settings.enc` on wizard-created vaults + +The CLI's `cmd_init` (`crates/relicario-cli/src/main.rs:347`) writes both `manifest.enc` and a default-`VaultSettings` `settings.enc`. The setup wizard never wrote `settings.enc`, so vaults created by the GUI are incomplete: any SW call to `get_vault_settings` 404s on `Gitea readFile settings.enc`. Surfaced at runtime; folded into this plan. + +`VaultSettings::default()` (in `crates/relicario-core/src/settings.rs:18`) has nested defaults across `GeneratorRequest`, `AttachmentCaps`, etc. Hand-encoding the full JSON in TypeScript is brittle — any default change in Rust would silently desync. Add a small WASM export that returns the default JSON, and have the wizard use it. + +### Task 7a: WASM `default_vault_settings_json` export + +**Files:** +- Modify: `crates/relicario-wasm/src/lib.rs` +- Modify: `extension/wasm/relicario_wasm.d.ts` (regenerated by build) +- Build artefact: `extension/wasm/relicario_wasm_bg.wasm` (regenerated) + +- [ ] **Step 1: Add the export** + +In `crates/relicario-wasm/src/lib.rs` (near other `#[wasm_bindgen]` exports, e.g. around the existing `settings_encrypt`): + +```rust +/// Returns the JSON for `VaultSettings::default()`. Used by the setup +/// wizard to encrypt and write a default settings.enc on new-vault setup. +/// Keeping this in WASM (instead of hand-encoding in TS) prevents drift +/// when the default VaultSettings shape changes in Rust. +#[wasm_bindgen] +pub fn default_vault_settings_json() -> Result { + let s = relicario_core::VaultSettings::default(); + serde_json::to_string(&s).map_err(|e| JsError::new(&e.to_string())) +} +``` + +If `relicario_core::VaultSettings` is not yet imported, add it to the existing `use` block at the top of `lib.rs`. + +- [ ] **Step 2: Rebuild WASM bindings** + +```bash +cd extension && npm run build:wasm +``` + +Expected: builds, regenerates `extension/wasm/relicario_wasm.d.ts` and the `.wasm` blob. + +- [ ] **Step 3: Verify the export landed** + +```bash +grep -n default_vault_settings_json extension/wasm/relicario_wasm.d.ts +``` + +Expected: a TS declaration line like `export function default_vault_settings_json(): string;`. + +- [ ] **Step 4: Add a smoke test** + +Append to `crates/relicario-wasm/tests/` (find an existing wasm test file or create `crates/relicario-wasm/tests/settings.rs`): + +```rust +use relicario_wasm::default_vault_settings_json; + +#[test] +fn default_settings_round_trip() { + let json = default_vault_settings_json().expect("default json"); + let parsed: relicario_core::VaultSettings = serde_json::from_str(&json) + .expect("default settings JSON must round-trip"); + let _ = parsed; // round-trip is the assertion +} +``` + +If this test setup doesn't fit the crate's existing test layout, fall back to: `cargo test -p relicario-core` already covers `VaultSettings::default` round-trip — note this in the commit message and skip the new test. + +- [ ] **Step 5: Run tests** + +```bash +cargo test -p relicario-wasm +``` + +Expected: pass (or, if the wasm crate has no native test target, run `cargo test -p relicario-core` and confirm settings round-trip there). + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/tests/ extension/wasm/ +git commit -m "feat(wasm): default_vault_settings_json() for wizard parity with CLI init" +``` + +### Task 7b: Wizard writes `settings.enc` on new-vault create + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +This task slots **between** Task 7 (clobber guard) and Task 8 (Step 3b). After Task 7's switch to `writeFileCreateOnly`, extend Step 3a's create flow to also encrypt + write a default `settings.enc`. + +- [ ] **Step 1: Edit the create-button handler in `attachStep3New`** + +After the `manifest_encrypt` block and before the push block, add: + +```typescript + stage = 'encrypt default settings'; + log(stage); + const settingsJson = w.default_vault_settings_json(); + const encryptedSettings = w.settings_encrypt(handle, settingsJson); + log('settings encrypted', { bytes: encryptedSettings.length }); +``` + +Then in the push block, add a `writeFileCreateOnly` call alongside the others: + +```typescript + log('write settings.enc'); + await host.writeFileCreateOnly( + 'settings.enc', + new Uint8Array(encryptedSettings), + 'init: encrypted settings', + ); +``` + +Order matters: keep `settings.enc` write after `manifest.enc` so a partial-fail leaves a discoverable state (probe will detect either; the user can wipe and retry). + +- [ ] **Step 2: Build + smoke test** + +```bash +cd extension && npm run build +``` + +Against an empty test repo: +1. New-vault wizard end-to-end. +2. Confirm the remote now has all five files: `.relicario/salt`, `.relicario/params.json`, `.relicario/devices.json`, `manifest.enc`, `settings.enc`. +3. Open the popup → unlock → no `get_vault_settings 404` in the SW console. + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/setup/setup.ts +git commit -m "fix(ext/setup): wizard writes settings.enc to match CLI init" +``` + +--- + +## Out-of-scope follow-up (filed for later) + +**Popup `list_devices` noise.** When the popup boots while the vault is still locked, it fires `list_devices` and the SW logs `list_devices -> error: vault_locked`. Cosmetic — the SW correctly rejects, the popup just shouldn't ask before unlock. Fix: gate the popup's `list_devices` call on `is_unlocked` first, or make the SW silently return `{ devices: [] }` for locked state. Not addressed here. + +**Devices.json shape parity (CLI ↔ extension).** CLI writes `[]` (raw array, `crates/relicario-cli/src/main.rs:342`); extension writes `{"devices":[...]}` (`extension/src/service-worker/devices.ts:28`). Extension's reader handles both (treats missing `.devices` as `[]`), but a CLI-init vault is invisibly empty to the extension's device list, and an extension-init vault is invisible to the CLI's device list. Fix in a follow-up: pick one shape (object form, with `schema_version`) and migrate both ends. + +--- + +## Self-review + +**Spec coverage check:** + +| Spec section | Plan task | +| ------------------------------------------ | ----------------- | +| Step 0 mode picker | Task 5 | +| Step 1 host type (unchanged) | Task 4 (back-button addition only) | +| Step 2 host config + presence probe | Task 6 | +| Mode-mismatch banners + switch buttons | Task 6 | +| Step 3a clobber guard | Tasks 2 + 7 | +| Step 3b attach flow + verify-decrypt | Task 8 | +| Step 4 device name (unchanged) | unchanged | +| Step 5 mode-aware finish + device register | Task 9 | +| State changes (`mode`, `vaultProbe`, ...) | Task 4 | +| TOCTOU defence via create-only writes | Task 2 + Task 7 | +| Error UX summary | Tasks 6, 7, 8 | +| Version bumps | Task 10 | +| Pre-existing add_device bug surfaced | Task 9 | +| Missing settings.enc on wizard vaults | Tasks 7a + 7b (addendum) | +| Manual e2e + release notes | Tasks 9, 10 | + +All spec sections covered. + +**Placeholder scan:** none — every step has either exact code, exact paths, or an exact command with expected output. + +**Type consistency check:** +- `WizardState` field names used consistently across Tasks 4–9 (`mode`, `vaultProbe`, `verifiedHandle`, `referenceImageBytesAttach`, `attaching`). +- `VaultProbe` interface defined in Task 3 used in Task 6. +- `addDevice(host, device)` signature from `service-worker/devices.ts` matches usage in Task 9. +- `GitHost.lastCommit` and `GitHost.writeFileCreateOnly` signatures consistent across Tasks 1, 2, 3, 6, 7.