Files
relicario/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md
adlee-was-taken f9b8f3821d feat(ext/sw): org_switch + org read messages (grant-filtered, offline-aware, cache cleared on lock)
Wires 4 SW messages to the org-vault.ts read functions (Task 5b):
- org_switch: opens org vault via openOrg(), caches OrgHandleState, sets
  session context; switching to 'personal' resets context without openOrg.
  Offline-aware: if openOrg throws a network error and a prior cached state
  exists, reuses it read-only (offline:true) via exported isNetworkError.
- org_list_items: returns cached, grant-filtered OrgManifestEntry[] (not
  personal ManifestEntry[]) via listOrgItems(requireCurrentOrgState()).
- org_get_item: decrypts a single org item via getOrgItem.
- org_list_collections: returns granted Collection[] via listOrgCollections.

Three-place rule satisfied for all 4 messages:
1. messages.ts PopupMessage union (4 variants)
2. messages.ts POPUP_ONLY_TYPES (4 entries)
3. popup-only.ts switch (4 case arms)

Cache lifecycle / use-after-free guard:
- clearOrgCache() added to org-handlers.ts; exports.
- Called in popup-only.ts 'lock' handler (before session.clearCurrent).
- Called in index.ts inactivity-timer onExpired (before clearCurrent).

Plan doc fixes:
- Line ~410: org_list_items return type corrected to OrgManifestEntry[].
- Line ~47: org_unwrap_key signature updated (no device_private_key_base64
  param — reads DEVICE_STATE internally).

9 new tests in org-handlers.test.ts (TDD: all watched fail before passing).
Full SW suite: 167 tests (17 files) green. build:all type-checks clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
2026-06-26 22:45:09 -04:00

455 lines
24 KiB
Markdown

# Org Foundation (SW + WASM) Implementation Plan
> **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:** Give the extension service worker the data layer to switch into an org vault, unwrap the org master key into a Zeroizing WASM handle, and serve a grant-filtered org manifest — no UI.
**Architecture:** Org reuses the existing key-agnostic WASM session registry (`relicario-wasm/src/session.rs`). Org ITEMS share the personal `.enc` format and reuse `item_decrypt`/`item_encrypt` on the org handle directly. The org MANIFEST is a dedicated `OrgManifest` type (NOT the personal `Manifest`) — it uses `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2, mirroring `manifest_decrypt`/`manifest_encrypt` in pattern but wrapping core's dedicated org manifest AEAD). New WASM functions: `org_unwrap_key` (Task 1) + `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2). In the SW, a new multi-context session replaces the single-handle model, and a new `org-vault.ts` module mirrors `vault.ts` for org reads. Plans 2 (read UI) and 3 (write) consume the SW message contract this plan produces — they never touch WASM.
**Tech Stack:** Rust (relicario-core/wasm), wasm-bindgen, TypeScript (extension service worker), vitest + happy-dom.
## Global Constraints
- Release target: v0.9.0.
- Org master key NEVER written to `localStorage`/`IndexedDB`/any persistent store — it lives only in a Zeroizing WASM session (`relicario-core` `Drop` zeroizes on `.free()`).
- Master key never crosses the WASM boundary; JS holds only the opaque `SessionHandle` (`u32`).
- Every new SW message needs all three: `PopupMessage` union entry + `POPUP_ONLY_TYPES` entry + handler arm (`extension/src/shared/messages.ts`) — a message in the union but not the set is silently rejected.
- Org crypto bypasses Argon2id (X25519 key-wrap), so the fast-Argon2id test-params convention does not apply to org tests; standard params apply only where shared fixtures touch the personal path.
- Capitalize "Relicario" in prose.
---
## File Structure
- `crates/relicario-wasm/src/lib.rs` — add `#[wasm_bindgen] org_unwrap_key` (Task 1) + `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2). Org items reuse `item_decrypt`/`item_encrypt` on the returned handle. Org manifest uses the dedicated `org_manifest_decrypt`/`org_manifest_encrypt` (NOT `manifest_decrypt`/`manifest_encrypt` — the org manifest is a different type `OrgManifest`).
- ~~`crates/relicario-core/src/manifest.rs`~~ — NOT modified. The org manifest is a dedicated `OrgManifest` type, not the personal `Manifest`. Core already ships its AEAD wrappers. No change to `ManifestEntry` was needed or made.
- `extension/src/wasm.d.ts` — declare `org_unwrap_key`.
- `extension/src/service-worker/session.ts` — replace single-handle model with a context map (personal + orgs); zero ALL on lock/expiry.
- `extension/src/service-worker/org-config.ts` *(new)*`orgConfigs` read/write over `chrome.storage.local`.
- `extension/src/service-worker/org-vault.ts` *(new)* — org read ops: load `members.json`/`collections.json`, match this device's member, unwrap key, fetch+decrypt+grant-filter the org manifest, get one item.
- `extension/src/service-worker/router/org-handlers.ts` *(new)* — handler arms for the org messages (keeps `popup-only.ts` from bloating).
- `extension/src/service-worker/router/popup-only.ts` — dispatch the new org message types into `org-handlers.ts`.
- `extension/src/shared/messages.ts` — org message request/response shapes + `POPUP_ONLY_TYPES` entries.
- `extension/src/shared/types.ts``OrgConfig`, `OrgConfigSummary`, `Collection`, `OrgMember`, manifest `collection?`.
- Tests: `crates/relicario-wasm` inline test for `org_unwrap_key`; `extension/src/service-worker/__tests__/org-session.test.ts`, `org-config.test.ts`, `org-vault.test.ts`.
---
### Task 1: WASM `org_unwrap_key`
**Files:**
- Modify: `crates/relicario-wasm/src/lib.rs` (add after the personal `unlock`, ~`:49-65`)
- Modify: `extension/src/wasm.d.ts`
- Test: `crates/relicario-wasm/src/lib.rs` (`#[cfg(test)]` module) or `crates/relicario-core/src/org.rs` test if wasm-bindgen blocks a unit test
**Interfaces:**
- Consumes: `relicario_core::org::unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8;32]>) -> Result<Zeroizing<[u8;32]>>` (`crates/relicario-core/src/org.rs:299`); `session::insert(master_key, image_secret) -> u32` (`crates/relicario-wasm/src/session.rs`).
- Produces: `org_unwrap_key(keys_blob: &[u8]) -> Result<SessionHandle, JsError>` (reads `DEVICE_STATE` internally — no private-key param crosses JS). The returned handle is an ordinary `SessionHandle` — callers use the existing `item_decrypt`/`item_encrypt`/`manifest_decrypt`/`manifest_encrypt` with it.
- [ ] **Step 1: Confirm the device-key form.** Read how `device_private_key` is produced — `crates/relicario-wasm/src/lib.rs` `register_device`/`generate_device_keypair` and `crates/relicario-core/src/device.rs`. Determine whether `private_key_base64` is the raw 32-byte ed25519 seed or an OpenSSH blob, and write `org_unwrap_key` to decode it to the 32-byte seed `Zeroizing<[u8;32]>` that `unwrap_org_key` expects. Note the finding in a code comment.
- [ ] **Step 2: Write the failing test**
```rust
#[cfg(test)]
mod org_tests {
use super::*;
use relicario_core::org::wrap_org_key;
use zeroize::Zeroizing;
#[test]
fn org_unwrap_key_yields_a_session_that_decrypts_org_blobs() {
// Generate a device keypair, wrap a known org key to it, unwrap via the wasm path,
// then encrypt+decrypt an item through the returned handle and assert round-trip.
let org_key = Zeroizing::new([7u8; 32]);
let (pub_openssh, priv_b64) = test_device_keypair(); // helper mirrors generate_device_keypair output
let wrapped = wrap_org_key(&org_key, &pub_openssh).unwrap();
let handle = org_unwrap_key(&wrapped, &priv_b64).unwrap();
let ct = item_encrypt(&handle, r#"{"id":"a1","core":{"type":"SecureNote","body":"x"}}"#).unwrap();
let pt = item_decrypt(&handle, &ct).unwrap(); // JsValue → assert it deserializes
assert!(format!("{pt:?}").contains("SecureNote"));
}
}
```
- [ ] **Step 3: Run test to verify it fails**
Run: `cargo test -p relicario-wasm org_unwrap_key`
Expected: FAIL — `cannot find function org_unwrap_key`.
- [ ] **Step 4: Implement `org_unwrap_key`**
```rust
/// Unwrap a member's ECIES-wrapped org master key into a session handle.
/// The org key is held in the same Zeroizing WASM session registry as the
/// personal master key; org items share the personal `.enc` AEAD format, so
/// the returned handle works with item_decrypt/manifest_decrypt unchanged.
#[wasm_bindgen]
pub fn org_unwrap_key(
keys_blob: &[u8],
device_private_key_base64: &str,
) -> Result<SessionHandle, JsError> {
let seed = decode_device_seed(device_private_key_base64) // per Step 1 finding
.map_err(|e| JsError::new(&format!("bad device key: {e}")))?;
let org_key = relicario_core::org::unwrap_org_key(keys_blob, &seed)
.map_err(|e| JsError::new(&format!("org unwrap failed: {e}")))?;
// image_secret slot unused for org; fill with zeroized placeholder.
let handle = session::insert(org_key, Zeroizing::new([0u8; 32]));
Ok(SessionHandle(handle))
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `cargo test -p relicario-wasm org_unwrap_key`
Expected: PASS.
- [ ] **Step 6: Declare in `wasm.d.ts` + build**
Add to `extension/src/wasm.d.ts`: `export function org_unwrap_key(keys_blob: Uint8Array, device_private_key_base64: string): SessionHandle;`
Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` then the project's wasm-pack step (see root `CLAUDE.md`).
Expected: builds clean.
- [ ] **Step 7: Commit**
```bash
git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts
git commit -m "feat(wasm): org_unwrap_key — ECIES unwrap into a session handle"
```
---
### Task 2: Org manifest over WASM + faithful OrgManifest/OrgManifestEntry TS mirror
> **Re-scoped after investigation.** The original Task 2 ("add collection to the personal
> ManifestEntry") was based on a wrong assumption and was rejected. Investigation confirmed
> that the org manifest is a DEDICATED type (`OrgManifest`/`OrgManifestEntry`), NOT the
> personal `Manifest`. Core already ships `encrypt_org_manifest`/`decrypt_org_manifest`
> AEAD wrappers (`crates/relicario-core/src/vault.rs:56,62`, re-exported at crate root).
> Adding `collection` to the personal `ManifestEntry` would be wrong and was not done.
> The PM ratified this re-scope (Option A). This task exposes the existing core wrappers
> over WASM and mirrors the org manifest types into TS.
**Files:**
- Modify: `crates/relicario-wasm/src/lib.rs` — add `org_manifest_decrypt` + `org_manifest_encrypt` after `org_unwrap_key`; add round-trip test in `org_tests`.
- Modify: `extension/src/wasm.d.ts` — declare both new functions.
- Modify: `extension/src/shared/types.ts` — add `OrgManifestEntry` + `OrgManifest` interfaces (faithful/lean; no personal-only fields).
**Interfaces:**
- Produces: `org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>` — mirrors `manifest_decrypt`; org items remain personal Items.
- Produces: `org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError>` — mirrors `manifest_encrypt`.
- Produces: `OrgManifestEntry` TS interface: `{id, type, title, tags[], modified, trashed_at?, collection}`. `collection` is REQUIRED. No personal-only fields (no attachment_summaries/icon_hint/favorite/group).
- Produces: `OrgManifest` TS interface: `{schema_version, entries: OrgManifestEntry[]}`.
- Core import path confirmed: `relicario_core::decrypt_org_manifest` / `relicario_core::encrypt_org_manifest` / `relicario_core::OrgManifest` — all re-exported at crate root (`lib.rs:81-83`, `lib.rs:99`).
- [x] **Step 1 (TDD RED):** Added test `org_manifest_round_trip_via_handle` in `org_tests` before implementing the fns. Confirmed compile error: `cannot find function 'org_manifest_encrypt' in this scope`.
- [x] **Step 2 (Implement):** Added `org_manifest_decrypt` + `org_manifest_encrypt` in `lib.rs` right after `org_unwrap_key`, mirroring the existing `manifest_decrypt`/`manifest_encrypt` pattern exactly. Same `need_key(handle)?` guard, same `session::with`, same `js_value_for(&out)`. Swapped type + core fn.
- [x] **Step 3 (TDD GREEN):** `cargo test -p relicario-wasm org_manifest` → 1 passed.
- [x] **Step 4 (TS types):** Added `OrgManifestEntry` + `OrgManifest` to `extension/src/shared/types.ts` (faithful/lean). Added `org_manifest_decrypt`/`org_manifest_encrypt` declarations to `extension/src/wasm.d.ts`.
- [x] **Step 5 (gates):** All five gates green (see commit).
```bash
git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts extension/src/shared/types.ts
git commit -m "feat(wasm,ext): org_manifest_decrypt/encrypt over WASM + faithful OrgManifest TS mirror"
```
---
### Task 3: Multi-context SW session
**Files:**
- Modify: `extension/src/service-worker/session.ts`
- Modify: `extension/src/service-worker/index.ts` (timer-expiry zero-all), `router/popup-only.ts` (the `lock` handler)
- Test: `extension/src/service-worker/__tests__/org-session.test.ts`
**Interfaces:**
- Produces: `setPersonal(h)`, `getPersonal()`, `setOrg(orgId, h)`, `getOrg(orgId)`, `setContext('personal'|orgId)`, `currentContext()`, `requireCurrentHandle()` (throws `vault_locked`), `clearAll()` (frees every handle). Keeps `getCurrent()`/`requireCurrent()`/`clearCurrent()` as thin wrappers over the personal handle so existing personal callers compile unchanged.
- [ ] **Step 1: Write the failing test**
```ts
import * as session from '../session';
test('clearAll frees personal and every org handle', () => {
const free = vi.fn();
const mk = (id: number) => ({ value: id, free } as unknown as SessionHandle);
session.setPersonal(mk(1));
session.setOrg('org-a', mk(2));
session.setOrg('org-b', mk(3));
session.clearAll();
expect(free).toHaveBeenCalledTimes(3);
expect(session.getPersonal()).toBeNull();
expect(session.getOrg('org-a')).toBeNull();
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-session.test.ts`
Expected: FAIL — `setPersonal`/`setOrg`/`clearAll` not exported.
- [ ] **Step 3: Implement the context model**
```ts
import type { SessionHandle } from '../../wasm/relicario_wasm';
let personal: SessionHandle | null = null;
const orgs = new Map<string, SessionHandle>();
let context: 'personal' | string = 'personal';
export function setPersonal(h: SessionHandle): void { personal = h; }
export function getPersonal(): SessionHandle | null { return personal; }
export function setOrg(orgId: string, h: SessionHandle): void { orgs.set(orgId, h); }
export function getOrg(orgId: string): SessionHandle | null { return orgs.get(orgId) ?? null; }
export function setContext(c: 'personal' | string): void { context = c; }
export function currentContext(): 'personal' | string { return context; }
export function requireCurrentHandle(): SessionHandle {
const h = context === 'personal' ? personal : orgs.get(context) ?? null;
if (!h) throw new Error('vault_locked');
return h;
}
export function clearAll(): void {
if (personal) { personal.free(); personal = null; }
for (const [, h] of orgs) h.free();
orgs.clear();
context = 'personal';
}
// Back-compat wrappers so existing personal-vault callers compile unchanged:
export function setCurrent(h: SessionHandle): void { setPersonal(h); }
export function getCurrent(): SessionHandle | null { return getPersonal(); }
export function requireCurrent(): SessionHandle { if (!personal) throw new Error('vault_locked'); return personal; }
export function clearCurrent(): void { clearAll(); }
```
- [ ] **Step 4: Run to verify it passes**
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-session.test.ts`
Expected: PASS.
- [ ] **Step 5: Point lock + timer at `clearAll`**
In `router/popup-only.ts` (the `lock` handler) and `index.ts` (`onExpired`), confirm they call `session.clearCurrent()` — now aliased to `clearAll()` — so a lock or timeout zeroes every org handle too. Run the full SW suite: `cd extension && npx vitest run src/service-worker/`. Expected: green (no personal regressions).
- [ ] **Step 6: Type-check + commit**
Run: `cd extension && npm run build:all` (NOT `npx tsc` — it can't resolve the generated wasm module).
```bash
git add extension/src/service-worker/session.ts extension/src/service-worker/index.ts extension/src/service-worker/router/popup-only.ts
git commit -m "feat(ext/sw): multi-context session (personal + orgs), clearAll zeroes all"
```
---
### Task 4: Org config storage + `org_list_configs`
**Files:**
- Create: `extension/src/service-worker/org-config.ts`
- Modify: `extension/src/shared/messages.ts`, `extension/src/shared/types.ts`, `extension/src/service-worker/router/org-handlers.ts` (new), `router/popup-only.ts`
- Test: `extension/src/service-worker/__tests__/org-config.test.ts`
**Interfaces:**
- Produces: `type OrgConfig = { orgId: string; displayName: string; hostType: 'gitea'|'github'; hostUrl: string; repoPath: string; apiToken: string; memberId: string }`; `type OrgConfigSummary = { orgId: string; displayName: string }`; `loadOrgConfigs(): Promise<OrgConfig[]>`; SW message `org_list_configs → { ok, data: OrgConfigSummary[] }`.
- [ ] **Step 1: Write the failing test**
```ts
test('org_list_configs returns id+displayName only (no tokens)', async () => {
chrome.storage.local.get = vi.fn().mockResolvedValue({ orgConfigs: [
{ orgId: 'o1', displayName: 'Acme', hostType: 'gitea', hostUrl: 'h', repoPath: 'r', apiToken: 'SECRET', memberId: 'm1' },
]});
const resp = await handleOrgListConfigs();
expect(resp).toEqual({ ok: true, data: [{ orgId: 'o1', displayName: 'Acme' }] });
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-config.test.ts`
Expected: FAIL — `handleOrgListConfigs` undefined.
- [ ] **Step 3: Implement `org-config.ts` + handler**
```ts
// org-config.ts
export type OrgConfig = { orgId: string; displayName: string; hostType: 'gitea'|'github'; hostUrl: string; repoPath: string; apiToken: string; memberId: string };
export async function loadOrgConfigs(): Promise<OrgConfig[]> {
const { orgConfigs } = await chrome.storage.local.get('orgConfigs');
return (orgConfigs as OrgConfig[] | undefined) ?? [];
}
```
```ts
// org-handlers.ts
import { loadOrgConfigs } from '../org-config';
export async function handleOrgListConfigs() {
const cfgs = await loadOrgConfigs();
return { ok: true as const, data: cfgs.map(c => ({ orgId: c.orgId, displayName: c.displayName })) };
}
```
- [ ] **Step 4: Wire the message (all three places)**
Add `org_list_configs` to the `PopupMessage` union and `POPUP_ONLY_TYPES` in `shared/messages.ts`, and a dispatch arm in `router/popup-only.ts``handleOrgListConfigs()`.
- [ ] **Step 5: Run to verify it passes**
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-config.test.ts`
Expected: PASS.
- [ ] **Step 6: Type-check + commit**
Run: `cd extension && npm run build:all`
```bash
git add extension/src/service-worker/org-config.ts extension/src/service-worker/router/org-handlers.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts extension/src/shared/types.ts
git commit -m "feat(ext/sw): org config storage + org_list_configs message"
```
---
### Task 5: Org read core — load grants, unwrap, fetch + grant-filter manifest
**Files:**
- Create: `extension/src/service-worker/org-vault.ts`
- Modify: `extension/src/shared/types.ts` (`Collection`, `OrgMember`)
- Test: `extension/src/service-worker/__tests__/org-vault.test.ts`
**Interfaces:**
- Consumes: `createGitHost` (`service-worker/git-host.ts`); `org_unwrap_key` (Task 1); device key from `DEVICE_STATE` via `w.get_device_info()`; `wasm.org_manifest_decrypt_filtered` (Task 5a).
- Produces: `openOrg(cfg: OrgConfig): Promise<OrgHandleState>` where `OrgHandleState = { handle: SessionHandle; grants: string[]; offline: boolean }`; `listOrgItems(state): ManifestEntry[]` (filtered to `grants`); `getOrgItem(state, id): Promise<Item>`; `listOrgCollections(state): Collection[]`.
- [ ] **Step 1: Write the failing test** (mock the GitHost + wasm boundary as `router.test.ts` does)
```ts
test('listOrgItems hides entries for ungranted collections', () => {
const manifest = { items: {
a: { id: 'a', title: 'x', collection: 'prod-infra', modified: 1 },
b: { id: 'b', title: 'y', collection: 'secret-ops', modified: 1 },
}};
const state = { handle: {} as any, grants: ['prod-infra'], offline: false };
expect(listOrgItems(state, manifest).map(e => e.id)).toEqual(['a']);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-vault.test.ts`
Expected: FAIL — `listOrgItems` undefined.
- [ ] **Step 3: Implement `org-vault.ts`** (open flow + filters)
```ts
import { createGitHost } from './git-host';
import { fingerprint } from '../shared/ssh-fingerprint';
import type { OrgConfig } from './org-config';
export type OrgHandleState = { handle: SessionHandle; grants: string[]; offline: boolean };
export async function openOrg(cfg: OrgConfig, wasm: WasmModule): Promise<OrgHandleState> {
const host = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken);
const members = JSON.parse(new TextDecoder().decode(await host.readFile('members.json')));
const { device_private_key } = await chrome.storage.local.get('device_private_key');
const me = matchMember(members, await deviceFingerprint()); // by ed25519 fingerprint
if (!me) throw new Error('not_an_org_member');
const wrapped = await host.readFile(`keys/${me.member_id}.enc`);
const handle = wasm.org_unwrap_key(wrapped, device_private_key);
return { handle, grants: me.collections, offline: false };
}
export function listOrgItems(state: OrgHandleState, manifest: Manifest): ManifestEntry[] {
return Object.values(manifest.items)
.filter(e => e.collection && state.grants.includes(e.collection));
}
```
(Fetch-and-decrypt the manifest with `wasm.manifest_decrypt(state.handle, ct)`, mirroring `vault.ts fetchAndDecryptManifest`. `getOrgItem` reads `items/<collection>/<id>.enc` and `wasm.item_decrypt`.)
- [ ] **Step 4: Run to verify it passes**
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-vault.test.ts`
Expected: PASS.
- [ ] **Step 5: Add the "key never persisted" assertion test**
```ts
test('opening an org never writes the org key to storage', async () => {
const setSpy = vi.spyOn(chrome.storage.local, 'set');
await openOrg(fakeCfg, fakeWasm);
for (const call of setSpy.mock.calls) {
expect(JSON.stringify(call)).not.toContain('orgMasterKey');
}
});
```
Run it; expected PASS (we never call `storage.local.set` with the key).
- [ ] **Step 6: Commit**
```bash
git add extension/src/service-worker/org-vault.ts extension/src/shared/types.ts extension/src/service-worker/__tests__/org-vault.test.ts
git commit -m "feat(ext/sw): org-vault — unwrap, fetch, grant-filter manifest"
```
---
### Task 6: `org_switch` (with offline detection) + read messages
**Files:**
- Modify: `extension/src/service-worker/router/org-handlers.ts`, `router/popup-only.ts`, `shared/messages.ts`
- Test: `extension/src/service-worker/__tests__/org-vault.test.ts`
**Interfaces:**
- Produces SW messages: `org_switch {context}``{ ok, data: { context, offline } }`; `org_list_items``{ ok, data: OrgManifestEntry[] }`; `org_get_item {id}``{ ok, data: Item }`; `org_list_collections``{ ok, data: Collection[] }`. On a git network error during switch, set `offline: true` and serve the last-cached manifest read-only.
- [ ] **Step 1: Write the failing test**
```ts
test('org_switch flags offline when the git fetch throws a network error', async () => {
const resp = await handleOrgSwitch({ context: 'o1' }, { ...stateWithNetworkError });
expect(resp).toEqual({ ok: true, data: { context: 'o1', offline: true } });
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-vault.test.ts`
Expected: FAIL — `handleOrgSwitch` undefined.
- [ ] **Step 3: Implement the four handlers** (switch sets `session.setContext`, caches the org `OrgHandleState`; on network error reuse the cached manifest and return `offline: true`; the three read handlers project from the cached state via `listOrgItems`/`getOrgItem`/`listOrgCollections`). Wire all four messages in `shared/messages.ts` (union + `POPUP_ONLY_TYPES`) and `popup-only.ts`.
- [ ] **Step 4: Run to verify it passes**
Run: `cd extension && npx vitest run src/service-worker/`
Expected: PASS (all org + personal SW tests green).
- [ ] **Step 5: Type-check + commit**
Run: `cd extension && npm run build:all`
```bash
git add extension/src/service-worker/router/org-handlers.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts
git commit -m "feat(ext/sw): org_switch + org read messages (grant-filtered, offline-aware)"
```
---
## Hand-off contract (consumed by Plan 2 read UI and Plan 3 write)
Plans 2 and 3 are UI-only and talk to the SW exclusively through these messages (sent via the `shared/state.ts` `sendMessage` wrapper from `popup.html`/`vault.html`):
- `org_list_configs``{ ok, data: OrgConfigSummary[] }` where `OrgConfigSummary = { orgId, displayName }`
- `org_switch { context: 'personal' | <orgId> }``{ ok, data: { context, offline: boolean } }`
- `org_list_items``{ ok, data: OrgManifestEntry[] }` (already grant-filtered; `collection` required — entries are the lean org shape, NOT personal ManifestEntry)
- `org_get_item { id }``{ ok, data: Item }` (org items are personal `Item`s encrypted with the org key; `item_decrypt` is unchanged)
- `org_list_collections``{ ok, data: Collection[] }` where `Collection = { slug, display_name }`
The SW holds the org context after `org_switch`; subsequent `org_list_items`/`org_get_item` operate on the current context until the next `org_switch` (including back to `'personal'`). Plan 3 adds `org_add_item`/`org_update_item`/`org_delete_item` against this same context model.