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

56 KiB
Raw Permalink Blame History

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:

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
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:

  /// 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:

  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 wherethis.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
cd extension && npm test -- git-host-extensions

Expected: 5 tests pass.

  • Step 7: Commit
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:

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
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:

  /// 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:

  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:

  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
cd extension && npm test -- git-host-extensions

Expected: 9 tests total pass.

  • Step 7: Commit
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:

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
cd extension && npm test -- setup/__tests__/probe

Expected: FAIL — module not found.

  • Step 3: Write implementation

Create extension/src/setup/probe.ts:

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
cd extension && npm test -- setup/__tests__/probe

Expected: 4 tests pass.

  • Step 5: Commit
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:

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:

  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:

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 renderStep3renderStep3New, attachStep3attachStep3New.

  • Step 3: Update progress bar

In render() at ~line 226, expand to 6 segments:

  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
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
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
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
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/):

.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:

      <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:

  document.getElementById('back-btn')?.addEventListener('click', () => {
    state.step = 0;
    state.error = null;
    render();
  });
  • Step 5: Build + smoke test
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
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:

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:

    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:

      ${renderProbeBanner()}

Then add the helper function:

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:

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:

  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:

.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
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
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:

      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):

    } 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
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
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
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
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
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
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:

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.
  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:

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)
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
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

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
cargo check
cd extension && npm run build && npm test

Expected: both pass; lockfile updates committed.

  • Step 4: Create CHANGELOG.md
# 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
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
cargo test
cd extension && npm test

Expected: all green.

  • Step 2: Tag

After confirming all the above tasks merged to main:

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):

/// 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
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
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):

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
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
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:

      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:

      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
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
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.