# Extension Org Vault GUI — Design Spec

- **Date:** 2026-06-20
- **Status:** Approved (brainstorming) — ready for writing-plans
- **Release target:** v0.9.0 (one multi-agent train, alongside the Pluggable Second Factor spec)
- **Anchor:** `main` post-v0.8.1 (`2fa4d68` tag; HEAD `59ebc28`)
- **Driver:** Product audit `docs/superpowers/reviews/2026-06-20-product-audit.md` recommendation #1 — the org vault backend (v0.8.0 + v0.8.1) is fully shipped but has **zero extension presence**; the enterprise feature is stranded behind the CLI.
- **Builds on:** `2026-06-06-relicario-enterprise-org-vault-design.md` (§ Extension — Org Context), `2026-06-20-extension-cli-parity-gap-analysis.md` (P3 cluster), `extension/ARCHITECTURE.md`.

## Purpose & scope

Bring the org (enterprise) vault to the browser extension at **read + write** parity, so org members can browse, view, add, edit, and delete shared credentials from the popup and vault tab — not only the CLI. Org **admin** operations (member/collection/grant/rotate/audit) stay CLI-only by design (high-trust, low-frequency; org spec § Extension scopes them out).

**In scope:** org context switching; grant-filtered browse/read of org items (all 7 types) in popup + vault tab; org item write (add/edit/rm, all 7 types); offline read-only indicator; SW acceptance tests.

**Out of scope:** org admin in the extension; per-collection cryptographic isolation, SSO/LDAP, read audit, HTTP plane (all org phase-2); webcam recovery scan.

## The org-write signing gate (highest risk — read first)

The org pre-receive hook **rejects unsigned commits unconditionally**: it shells `git verify-commit` and requires a device ed25519/SSH signature from a current member (`crates/relicario-server/src/main.rs:95-102`). The extension today pushes **unsigned** commits via the host Contents API (`extension/src/service-worker/gitea.ts` → `/api/v1/.../contents/{path}`), authored under the API token. The ed25519 signing primitive **exists in WASM** (`sign_for_git`, `crates/relicario-wasm/src/lib.rs:253`) but is **unused** by the extension — there is no signed-commit push path, and the Contents API cannot carry a caller-supplied signature.

Therefore org **write** from the extension must construct and push a **signed commit via the Git Data API** (blob → tree → commit-with-signature → update-ref). This is feasible but unproven against the host APIs.

**Stream A3 begins with a spike, gating the rest of A3:**

> Prove that a commit signed in the SW with `sign_for_git`, pushed via the **Gitea** Git Data API, passes server-side `git verify-commit` **and** `relicario-server verify-org-commit`. Then repeat for **GitHub**.

- **Spike passes** → A3 proceeds (signed-push GitHost path + org write UI); v0.9.0 ships read + write.
- **Spike fails** (host API strips/normalizes the SSH signature such that `git verify-commit` fails) → org write degrades to a **follow-up lift**; v0.9.0 still ships **org read (A0–A2, A4-read)** + the full Pluggable Second Factor spec. The spike is ~1 day and read + the other spec are unblocked regardless, so a failed spike wastes nothing.

The spike result is recorded back into this spec and `STATUS.md` before A3 build work starts.

## Architecture

An org vault is a second git repo alongside the personal vault, cryptographically isolated (org spec § Architecture). The org master key is a random 256-bit key, wrapped per member via ECIES (X25519 + XChaCha20-Poly1305) to their device ed25519 key. The extension mirrors the CLI: unwrap the org key with the device private key, decrypt items exactly as the personal vault does — but with the org key, not the Argon2id-derived personal key.

The extension keeps its existing architecture (SW is the crypto fortress; popup/vault are StateHost-driven view shells over `chrome.runtime.sendMessage`). Org support is an additive context, not a rewrite.

## Stream decomposition

### A0 · WASM org bridge (prerequisite)

`relicario-core::org` already performs ECIES unwrap and org item crypto for the CLI; none of it is exposed over `relicario-wasm` today (confirmed — no org exports in `crates/relicario-wasm/src/lib.rs`). Expose:

- `org_unwrap_key(keys_blob: &[u8], device_private_openssh: &str) -> OrgHandle` — unwrap `keys/<member-id>.enc` into the org master key held in WASM `Zeroizing`, returning an opaque slot handle in the same pattern as the personal `SessionHandle` (the key never crosses to JS).
- `org_item_encrypt/decrypt(OrgHandle, …)` and `org_manifest_encrypt/decrypt(OrgHandle, …)` — XChaCha20-Poly1305 with the org key directly (no Argon2id), reusing core.
- `org_handle_free(OrgHandle)` — zero the slot.

Everything else depends on A0.

### A1 · SW org foundation

- **Multi-context session.** `extension/src/service-worker/session.ts` is a single module-scope `SessionHandle | null` ("one vault per install"). Replace with a context map — `{ personal: Handle | null, orgs: Map<orgId, OrgHandle> }` plus a current-context pointer. The inactivity timer and `lock` zero **every** handle. Org handles are never written to `localStorage`, `IndexedDB`, or any persistent store (org spec line 231).
- **Org config storage.** `chrome.storage.local.orgConfigs: Array<{ orgId, displayName, hostType, hostUrl, repoPath, apiToken, memberId }>` — mirrors the personal `vaultConfig`. (The device key in `chrome.storage.local.device_private_key` is reused to unwrap org keys; no new device identity.)
- **Org GitHost.** One `GitHost` per org repo via the existing `createGitHost` factory.
- **Read flow.** On switch to an org: read public `members.json` + `collections.json` (unencrypted) → locate this device's member record by ed25519 fingerprint → take its `collections` grant list → `org_unwrap_key` → fetch + `org_manifest_decrypt` → **filter manifest entries to granted collection slugs** → cache. Items decrypt on demand via `org_item_decrypt`.
- **Offline.** If the org `GitHost` fetch throws a network error, serve the last-pulled manifest read-only and set an `orgOffline` flag.
- **New SW messages (popup-only):** `org_list_configs`, `org_switch { context: 'personal' | orgId }`, `org_list_items` (grant-filtered), `org_get_item`, `org_list_collections`. Each must be added to the `PopupMessage` union AND `POPUP_ONLY_TYPES` AND a handler arm (`extension/src/shared/messages.ts` — the three-place rule).

### A2 · Org read UI

- **Context switcher.** A top-level Personal / `<org>`… selector in the vault-tab sidebar (primary surface per org spec) and the popup header. Switching sends `org_switch` and reloads the list.
- **Reuse.** The `popup/components/*` renderers are `StateHost`-driven, so org item detail/type views render unchanged once the host projects org state (items, collections). A collection facet in the sidebar mirrors the existing type-category nav.
- **Offline indicator.** "org offline — writes disabled" banner when `orgOffline`.

### A3 · Org write (gated on the signing spike)

- **Signed-push GitHost path.** A new method that builds a commit through the Git Data API, signs the commit object with `sign_for_git` (device key from `chrome.storage.local`), pushes it, and updates the ref. (Generic enough that personal device-auth writes could later adopt it.)
- **SW write handlers.** `org_add_item`, `org_update_item`, `org_delete_item`: encrypt with the org handle, write to the **collection-scoped** path `items/<slug>/<id>.enc`, update the org manifest — **both writes** via signed push (the personal "manifest + item both written" invariant applies, `extension/ARCHITECTURE.md` § Invariants).
- **UI.** Add/edit reuse the existing per-type item forms; add gains a **granted-collection picker**. Delete = soft-delete (trash) in the org manifest, mirroring personal trash semantics — the org CLI already ships `rm`/`restore`/`purge` (v0.8.0), so the backend is ready.

### A4 · Org SW acceptance tests (vitest)

Per org spec § Extension Tests, plus write coverage: org context switch replaces the personal manifest with no cross-contamination; org master key appears only in the `Zeroizing` session, never in `localStorage`/`IndexedDB`; offline read-only triggers on a git network error; grant filtering hides ungranted collections; a write produces a signed commit the hook accepts (mock the hook contract).

## Data flow (read)

```
popup/vault → org_switch(orgId) → SW:
  read members.json + collections.json (public)
  → match device fingerprint → grants
  → org_unwrap_key(keys/<member-id>.enc, device_priv) → OrgHandle (Zeroizing, in WASM)
  → fetch manifest.enc → org_manifest_decrypt → filter to grants → cache
popup/vault → org_list_items → SW returns grant-filtered projection (titles/collections, no secrets)
popup/vault → org_get_item(id) → SW org_item_decrypt → resolved item
```

## Error handling

- **Device not a member** (fingerprint absent from `members.json`) → `not_an_org_member` — clear "this device isn't a member of <org>".
- **Ungranted collection** → filtered out on read; rejected client-side on write before push (and by the hook as defense in depth).
- **Offline** → read-only banner; writes blocked client-side with the indicator.
- **Signed push / hook rejection** → surfaced verbatim; the manifest write is not attempted if the item write fails (no half-mutation).
- Reuse the existing snake_case SW error convention + `humanizeError`.

## Living-docs impact

`extension/ARCHITECTURE.md` (org context, multi-context session, signed-push path, new messages), `docs/SECURITY.md` (extension org key handling + signed-commit write path), `ROADMAP.md`/`STATUS.md` (org parity shipped), `CHANGELOG.md`. The org spec's § Extension scope note (read-only phase-1) is superseded by this spec's read+write decision — note that in the org spec.

## Open risks

1. **Org-write signing spike** (§ above) — the gating unknown.
2. **Multi-context session refactor** touches the SW's most security-sensitive module (`session.ts`); the lock/timer-zeroes-all invariant must be preserved and tested.
3. **Git Data API divergence** Gitea vs GitHub for signed commits — the spike must cover both; if only one host works, ship org write for that host and record the limitation.
