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>
1534 lines
56 KiB
Markdown
1534 lines
56 KiB
Markdown
# 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 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.
|