Files
relicario/docs/superpowers/plans/2026-04-27-attach-existing-vault.md
adlee-was-taken 7588a75bdc 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 <noreply@anthropic.com>
2026-04-27 17:42:00 -04:00

1534 lines
56 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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<void>;
```
- [ ] **Step 4: Implement on Gitea**
In `gitea.ts`, add after `writeFile`:
```typescript
async writeFileCreateOnly(path: string, content: Uint8Array, message: string): Promise<void> {
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<void> {
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<VaultProbe> {
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 '<div class="wizard-step"><p>Step 0 (placeholder)</p></div>'; }
function attachStep0(): void { /* filled in Task 5 */ }
function renderStep3Attach(): string { return '<div class="wizard-step"><p>Step 3b (placeholder)</p></div>'; }
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 = `
<div class="progress-bar">
<div class="step ${state.step > 0 ? 'done' : state.step === 0 ? 'current' : ''}"></div>
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
<div class="step ${state.step > 5 ? 'done' : state.step === 5 ? 'current' : ''}"></div>
</div>
`;
```
- [ ] **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 `
<div class="wizard-step">
<h3>set up relicario</h3>
<p class="muted" style="margin-bottom:16px;">
How are you using relicario on this device?
</p>
<div class="mode-cards">
<button class="mode-card ${isNew ? 'active' : ''}" data-mode="new">
<div class="mode-card-title">create new vault</div>
<p class="mode-card-blurb">
I'm setting up relicario for the first time. This will create a fresh
encrypted vault on a new or empty git repository.
</p>
</button>
<button class="mode-card ${isAttach ? 'active' : ''}" data-mode="attach">
<div class="mode-card-title">attach this device</div>
<p class="mode-card-blurb">
I already have a vault on another device. Connect this browser to it
using my passphrase and reference image.
</p>
</button>
</div>
<div class="form-actions" style="margin-top:24px;">
<button class="btn btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next</button>
</div>
</div>
`;
}
```
- [ ] **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
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="next-btn">next</button>
</div>
```
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: <code>${escapeHtml(probe.lastCommit.sha)}</code> by ${escapeHtml(probe.lastCommit.author)} on ${escapeHtml(probe.lastCommit.date.slice(0, 10))}.`
: '';
if (state.mode === 'new' && probe.exists) {
return `
<div class="banner banner-warn">
<strong>⚠ This repository already contains a relicario vault.</strong>
<p>${meta}</p>
<p>Creating a new vault here would overwrite the existing one and <strong>destroy all data inside</strong>.
To use this vault on this device, switch to <em>attach</em> mode instead.
If you really mean to start over, delete the repository via your git host's web UI and come back here.</p>
<div class="form-actions">
<button class="btn" id="switch-mode-btn" data-target="attach">switch to attach mode</button>
</div>
</div>`;
}
if (state.mode === 'attach' && !probe.exists) {
return `
<div class="banner banner-warn">
<strong>No vault found in this repo.</strong>
<p>Did you mean to create a new vault?</p>
<div class="form-actions">
<button class="btn" id="switch-mode-btn" data-target="new">switch to new-vault mode</button>
</div>
</div>`;
}
if (state.mode === 'attach' && probe.exists) {
return `
<div class="banner banner-ok">
<strong>✓ Existing vault found.</strong>
<p>${meta}</p>
<p>Continue to attach this device.</p>
</div>`;
}
// mode = new, !exists
return `
<div class="banner banner-ok">
<strong>✓ Repo is empty — ready to create a new vault.</strong>
</div>`;
}
```
- [ ] **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 `
<div class="wizard-step">
<h3>attach this device</h3>
<p class="muted" style="margin-bottom:12px;">
Use your existing passphrase and reference image to attach this browser
to your vault. We'll verify both before registering this device.
</p>
<div class="form-group">
<label class="label">reference image (JPEG)</label>
<div class="file-drop ${hasImage ? 'has-file' : ''}" id="ref-drop">
<input type="file" id="ref-input" accept="image/jpeg" style="display:none;">
${hasImage
? '<p class="secondary">reference image loaded</p>'
: '<p class="secondary">click to select your reference JPEG</p>'}
</div>
<p class="muted" style="margin-top:4px;">
The reference image is the JPEG you saved when you first created this vault —
<strong>not the original photo</strong>. It has the 256-bit secret embedded.
</p>
</div>
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
<div class="passphrase-field">
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter your passphrase" autocomplete="current-password">
<button type="button" class="eye-btn" id="eye-btn">${pToggle}</button>
</div>
</div>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="attach-btn" ${gateDisabled ? 'disabled' : ''}>
${state.attaching ? '<span class="spinner"></span> verifying...' : 'verify and attach'}
</button>
</div>
</div>
`;
}
```
- [ ] **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<boolean>((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 `
<div class="wizard-step">
<div class="success-box">
<h3>${isAttach ? 'device verified' : 'vault created'}</h3>
<p class="secondary">
${isAttach
? 'Your passphrase and reference image decrypt the vault successfully.'
: 'Your vault has been initialized and pushed to the repository.'}
</p>
</div>
${isAttach ? '' : `
<div class="form-group">
<label class="label">reference image</label>
<p class="muted" style="margin-bottom:8px;">
Download and store this image securely. It is your second factor for decryption.
Without it, you cannot unlock the vault.
</p>
<button class="btn btn-primary" id="download-ref-btn">download reference.jpg</button>
</div>
`}
${state.extensionDetected ? `
<div class="form-group" style="margin-top:16px;">
<label class="label">register this device</label>
<button class="btn btn-primary" id="push-config-btn" ${state.configPushed ? 'disabled' : ''}>
${state.configPushed ? 'device registered' : (isAttach ? 'attach this device' : 'register this device')}
</button>
${state.configPushed ? '<span class="test-result pass" style="margin-left:8px;">done</span>' : ''}
</div>
` : `
<div class="form-group" style="margin-top:16px;">
<label class="label">extension configuration</label>
<p class="muted" style="margin-bottom:8px;">
Copy this JSON and paste it into the extension setup, or save it for later.
</p>
<div class="config-blob" id="config-blob">${escapeHtml(configJson)}</div>
<button class="btn" id="copy-config-btn">copy to clipboard</button>
</div>
`}
</div>
`;
}
```
- [ ] **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 <pre-init-sha> -- .`).
- **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<String, JsError> {
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 49 (`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.