# Attach existing vault — wizard split + clobber guard (v0.2.0)

**Status:** design
**Target release:** v0.2.0
**Scope:** extension only (`extension/src/setup/`, `extension/src/service-worker/`)
**Out of scope:** CLI `init` reconnect support, multi-vault per install, in-wizard "destroy and recreate" flow

## Background

Today the setup wizard (`extension/src/setup/setup.ts`) has one flow: create a brand-new vault. Step 2 only checks that the configured remote is reachable; it does not detect whether that remote already contains a Relicario vault. Step 3's "create vault" then writes `.relicario/salt`, `.relicario/params.json`, `.relicario/devices.json`, and `manifest.enc` unconditionally — silently overwriting any existing vault on the remote.

**Observed failure:** uninstalling and reinstalling the extension while pointed at a populated test repo wipes the manifest with no warning. The user's test entries are gone.

The service worker already exposes `add_device`, `save_setup`, `unlock`, and `manifest_decrypt` machinery. The building blocks for "attach this device to an existing vault" exist; only the wizard UI is missing.

## Goals

1. Provide a purely-GUI path to attach a new device to an existing vault, without touching the CLI.
2. Make destructive overwrite of an existing vault impossible from the wizard.
3. Verify the user's passphrase + reference image actually decrypt the existing vault before registering a new device key — no silently broken attachments.
4. Keep the existing "create new vault" flow working, with no behavioural regressions for greenfield setups.

## Non-goals

- Recovering from a partially-clobbered vault (out of scope; users with damaged remotes use git history).
- A "really nuke and recreate" escape hatch in the wizard. Users who genuinely want to start over delete the repo via the host's web UI.
- CLI parity. `relicario init` keeps its current "always fresh" semantics for now; a separate spec will cover CLI attach.

## UX flow

The wizard grows a leading **mode picker** (Step 0) and a parallel attach branch through Steps 3–5. Steps 1, 2, and 4 are shared between modes.

```
                ┌──────── Step 0: mode ────────┐
                │   create new   |   attach    │
                └──────────────┬───────────────┘
                               ▼
                ┌──── Step 1: host type ───────┐
                └──────────────┬───────────────┘
                               ▼
                ┌──── Step 2: host config ─────┐
                │   URL + repo + token + test  │
                │   → vault-presence probe     │
                └──────┬─────────────┬─────────┘
                  new  │             │ attach
                       ▼             ▼
       ┌── Step 3a: carrier JPEG ─┐ ┌── Step 3b: reference JPEG ──┐
       │ + passphrase + confirm   │ │ + passphrase                │
       │ + zxcvbn ≥ 3 gate        │ │ + verify-decrypt round-trip │
       └────────────┬─────────────┘ └─────────────┬───────────────┘
                    ▼                             ▼
                  ┌── Step 4: device name (shared) ──┐
                  └──────────────┬───────────────────┘
                                 ▼
            ┌── Step 5: register device + save config ──┐
            │   new: + download reference.jpg           │
            │   attach: skip download                   │
            └───────────────────────────────────────────┘
```

The progress bar grows from 5 to 6 segments; Step 0 is the new leading segment.

### Step 0: mode picker

Two large buttons. No host configuration, no other inputs. Sets `state.mode` to `'new'` or `'attach'`. Helper copy under each:

- *create new vault* — "I'm setting up Relicario for the first time. This will create a fresh encrypted vault on a new or empty git repository."
- *attach this device* — "I already have a vault on another device. Connect this browser to it using my passphrase and reference image."

### Step 1: host type

Unchanged. Gitea/GitHub toggle + token-creation instructions. Shared by both modes.

### Step 2: host config + presence probe

Connection test is unchanged. **After** a successful test, run a vault-presence probe before allowing transition to Step 3:

1. `host.listDir('.relicario')` — collect filenames.
2. `host.listDir('')` — check root for `manifest.enc`.
3. Vault is "present" if any of `.relicario/salt`, `.relicario/params.json`, `manifest.enc` exist.
4. If vault is present, also fetch the most-recent commit metadata (`sha`, `author`, `date`) via the host's commits API for display. This is best-effort — failure to fetch metadata does not block the flow.

The probe result drives a banner under the connection-test row, with one of four states:

| mode      | vault present | UI                                                                                   |
| --------- | ------------- | ------------------------------------------------------------------------------------ |
| `new`     | no            | green banner: "✓ repo is empty — ready to create a new vault." Next button enabled.  |
| `new`     | yes           | red banner + warning card. Next disabled. Buttons: `[switch to attach]` `[back]`.    |
| `attach`  | yes           | green banner + confirmation card with last-commit metadata. Next button enabled.     |
| `attach`  | no            | red banner: "no vault found in this repo." Buttons: `[switch to new mode]` `[back]`. |

The "switch mode" buttons preserve all entered host config so the user does not retype anything.

**Warning card copy (mode=new, vault present):**

> ⚠ This repository already contains a Relicario vault.
> Last commit: `<sha7>` by `<author>` on `<date>`.
>
> Creating a new vault here would overwrite the existing one and **destroy all data inside**. To use this vault on this device, switch to *attach* mode instead.
>
> If you really mean to start over, delete the repository via your git host's web UI and come back here.

No "type the repo name to confirm" escape; deliberate friction routed through the host's own UI.

### Step 3a: create vault (new mode)

Largely unchanged from today's Step 3. Carrier JPEG + passphrase + confirm + zxcvbn ≥ 3 gate. On submit, embed image secret, derive key, encrypt empty manifest, push files. The presence probe already ran in Step 2, so the upload here is conditional on the repo still being empty *at probe time* — race-window narrowing belongs in the write layer (see "TOCTOU" below).

### Step 3b: attach (attach mode)

New step. Inputs:

- **Reference image (JPEG)** — file picker. Help text emphasises *reference, not carrier*: "upload the reference JPEG you saved when you first created this vault. Not the original photo — the one with the embedded secret."
- **Passphrase** — single field, no confirm (user is proving they know it, not setting a new one). Re-uses the same password input + show/hide eye toggle as Step 3a.

No zxcvbn meter on this step — the user does not get to set a passphrase, only enter the existing one.

On submit:

1. `GET .relicario/salt`, `.relicario/params.json`, `manifest.enc` from host.
2. `wasm.unlock(passphrase, referenceJpegBytes, salt, paramsJson)` → handle.
3. `wasm.manifest_decrypt(handle, manifestEnc)` → JSON.
4. On any throw: `wasm.lock(handle)` if a handle was created, set `state.error = "Could not decrypt vault — wrong passphrase or reference image."`, stay on form, no remote writes.
5. On success: stash decrypted manifest JSON and live handle in `state.verifiedHandle`. Continue to Step 4.

The verified handle is held only for the duration of the wizard. It is **not** pushed to the SW — after Step 5 finishes and the user opens the popup, they unlock again normally. The handle is locked at end-of-wizard regardless.

### Step 4: device name

Unchanged. Default name `${browser} on ${os}`. Shared by both modes.

### Step 5: register device + save config

Differences by mode:

| element                           | new mode | attach mode |
| --------------------------------- | -------- | ----------- |
| success header                    | "vault created"          | "device attached" |
| reference.jpg download button     | shown                    | hidden            |
| save-config-to-extension button   | shown                    | shown             |
| add_device call                   | yes                      | yes               |

Both modes call `add_device` via the SW with a freshly-generated keypair, write the private key to `chrome.storage.local`, and have the SW push the new pubkey into `.relicario/devices.json`.

**Implementation note for the plan:** verify the SW's `add_device` handler reads `devices.json` from the host, appends the new entry, and writes it back (read-modify-write). If it currently overwrites with a single-entry array, that is a pre-existing bug surfaced by attach mode and must be fixed as part of this work.

## State changes

`WizardState` gains:

```ts
mode: 'new' | 'attach' | null;             // null until Step 0 chosen
referenceImageBytesAttach: Uint8Array | null;
vaultProbe: {
  exists: boolean;
  lastCommit?: { sha: string; author: string; date: string };
} | null;
verifiedHandle: number | null;             // WASM handle from Step 3b verify
```

`carrierImageBytes` is kept distinct from `referenceImageBytesAttach` so the two paths cannot accidentally read each other's bytes.

`step` is renumbered to 0–5 (was 1–5). The progress bar grows to 6 segments.

## TOCTOU on the new-vault write path

The Step 2 probe is best-effort. A user could pass the probe with an empty repo, then between Step 2 and Step 3a's push, another client (or a previous wizard run) could initialise the same repo. The wizard's defence is the git-host write layer, not a re-probe:

- GitHub Contents API: `PUT /repos/{owner}/{repo}/contents/{path}` without a `sha` parameter creates only; if the file exists it returns 422.
- Gitea Contents API: same semantics — `POST` to create, `PUT` (with `sha`) to update.

Verify in the implementation plan that `host.writeFile` on the new path uses create-only semantics when called from Step 3a. If it currently does blind PUT-or-create, harden it for this code path. This is defence in depth — if it fails, the user gets a writeFile error mid-push and aborts, which is non-destructive (worst case: they leave a partial set of files behind, fixable by a second run that detects the partial vault and refuses).

The attach path does not have this concern — it only writes `devices.json`, and that is read-modify-write under the SW's existing handler.

## Error UX summary

| condition                                       | behaviour                                                                                  |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------ |
| connection test fails                           | red banner, stay on Step 2                                                                 |
| probe fails (network)                           | red banner "could not check repo state — retry"; do not proceed to Step 3                   |
| mode=new, probe finds vault                     | warning card; only `[switch to attach]` or `[back]` advance                                |
| mode=attach, probe finds empty repo             | warning card; only `[switch to new]` or `[back]` advance                                   |
| mode=attach, decrypt fails in Step 3b           | red banner "wrong passphrase or reference image"; stay on form; lock any partial handle    |
| mode=new, conditional create rejects in Step 3a | red error referencing the file path that was rejected; advise re-running setup             |
| `add_device` fails                              | red banner on Step 5; config save still succeeds; user can retry                           |

## Version + rollout

This is the first user-facing feature delivery since v0.1.0 and includes a fix for an unflagged data-loss bug. Bump all package versions to **0.2.0**:

- `crates/relicario-core/Cargo.toml`
- `crates/relicario-cli/Cargo.toml`
- `crates/relicario-wasm/Cargo.toml`
- `extension/manifest.json`
- `extension/package.json`

Tag `v0.2.0` after merge. Release notes should call out:

1. **Fix:** running setup against a remote that already contained a vault would silently overwrite it. Setup now refuses to overwrite and offers an attach path instead.
2. **Feature:** wizard now supports attaching a new device to an existing vault directly from the GUI (passphrase + reference image, no CLI).

## Testing

Unit/integration coverage to add:

- `mode=new` happy path against an empty mock host — unchanged from existing tests.
- `mode=new` against a host that already returns `.relicario/salt` — wizard refuses, offers switch.
- `mode=attach` against an empty host — wizard refuses, offers switch.
- `mode=attach` happy path with valid passphrase + reference — `add_device` called, config saved.
- `mode=attach` with wrong passphrase — error displayed, no remote writes occur, no orphan device pubkey.
- `mode=attach` with mismatched reference image (right format, wrong embedded secret) — same as above.
- Mode-switch buttons preserve host URL / repo / token across the switch.

Manual verification:

- End-to-end on a real Gitea repo: create vault on workstation A, install fresh extension on workstation A, run attach wizard, verify popup unlocks and lists existing items unchanged.

## File touchpoints

- `extension/src/setup/setup.ts` — most of the work; new render functions, state additions, mode threading.
- `extension/src/setup/setup.html` — possibly minor adjustments for a 6-segment progress bar.
- `extension/src/service-worker/index.ts` — verify/adjust `add_device` handler if it does not read-modify-write `devices.json`.
- `extension/src/service-worker/git-host.ts` (or wherever `writeFile` lives) — verify create-only semantics on Step 3a's push.
- All five package version files (above).
