# 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`) and the existing `item_decrypt`/`manifest_decrypt` AEAD (org items share the personal `.enc` format, org key used directly). The only new WASM function is `org_unwrap_key`. 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`. (Reuses `session::insert`; reuses existing `manifest_decrypt`/`item_decrypt`/`item_encrypt`/`manifest_encrypt` on the returned handle.) - `crates/relicario-core/src/manifest.rs` — ensure a `ManifestEntry` carries an optional `collection: Option` so the org manifest round-trips through the existing manifest (de)serialization. (Verify first; only add if absent.) - `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>` (`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], device_private_key_base64: &str) -> Result`. 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 { 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 `collection` field round-trips **Files:** - Modify: `crates/relicario-core/src/manifest.rs` - Modify: `extension/src/shared/types.ts` - Test: `crates/relicario-core/tests/format_v2.rs` (or the manifest test module) **Interfaces:** - Produces: `ManifestEntry.collection: Option` (serde `skip_serializing_if = "Option::is_none"`), mirrored in TS as `collection?: string`. - [ ] **Step 1: Check current state.** Grep `crates/relicario-core/src/manifest.rs` for `collection`. If the org manifest already round-trips (org CLI works, so it likely uses a dedicated type or already has the field), this task is a no-op verification — confirm with a test and skip to commit. If `ManifestEntry` lacks `collection`, proceed. - [ ] **Step 2: Write the failing test** ```rust #[test] fn manifest_entry_round_trips_collection_slug() { let json = r#"{"id":"a1","title":"db","collection":"prod-infra","modified":1}"#; let entry: ManifestEntry = serde_json::from_str(json).unwrap(); assert_eq!(entry.collection.as_deref(), Some("prod-infra")); let back = serde_json::to_string(&entry).unwrap(); assert!(back.contains("prod-infra")); } ``` - [ ] **Step 3: Run to verify it fails** Run: `cargo test -p relicario-core manifest_entry_round_trips_collection_slug` Expected: FAIL (unknown field or missing accessor) — or PASS immediately if the field already exists (then this task is verification-only). - [ ] **Step 4: Add the field if absent** ```rust #[serde(skip_serializing_if = "Option::is_none", default)] pub collection: Option, ``` - [ ] **Step 5: Run to verify it passes** Run: `cargo test -p relicario-core manifest` Expected: PASS, no other manifest test regressed. - [ ] **Step 6: Mirror in TS + commit** Add `collection?: string;` to the `ManifestEntry` interface in `extension/src/shared/types.ts`. ```bash git add crates/relicario-core/src/manifest.rs extension/src/shared/types.ts git commit -m "feat(core): ManifestEntry carries optional collection slug" ``` --- ### 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(); 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`; 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 { 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 `chrome.storage.local.device_private_key`; `wasm.manifest_decrypt` (existing). - Produces: `openOrg(cfg: OrgConfig): Promise` where `OrgHandleState = { handle: SessionHandle; grants: string[]; offline: boolean }`; `listOrgItems(state): ManifestEntry[]` (filtered to `grants`); `getOrgItem(state, id): Promise`; `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 { 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//.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: ManifestEntry[] }`; `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' | }` → `{ ok, data: { context, offline: boolean } }` - `org_list_items` → `{ ok, data: ManifestEntry[] }` (already grant-filtered; entries carry `collection`) - `org_get_item { id }` → `{ ok, data: Item }` - `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.