Files
relicario/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md
adlee-was-taken 74cee8ac67 docs(plans): v0.9.0 implementation plans — 5 streams across 2 specs
Full-TDD per-stream plans for the v0.9.0 multi-agent train:
- org-a-foundation (A0+A1): WASM org_unwrap_key + multi-context SW session +
  org config + grant-filtered manifest read.
- org-b-read-ui (A2): org switcher + grant-filtered browse/read + offline banner.
- org-c-write (A3): GO/NO-GO signing spike first, then commitSigned + org write
  handlers + UI. Spike-gated; NO-GO ships read-only.
- keyfile-core-cli (B1+B2): core armor + unlock_with_secret + params hint +
  WASM bindings + CLI init/unlock --key-file.
- keyfile-ext-positioning (B3+B4): setup container choice + unlock + the
  README/DESIGN/CRYPTO/FORMATS positioning pivot.

Cross-plan contracts pinned and self-reviewed for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQbgrP6KQW5pibjbPEoTSs
2026-06-21 09:35:44 -04:00

22 KiB

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<String> 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.tsOrgConfig, 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], device_private_key_base64: &str) -> Result<SessionHandle, JsError>. 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

#[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
/// 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
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<String> (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

#[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
#[serde(skip_serializing_if = "Option::is_none", default)]
pub collection: Option<String>,
  • 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.

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

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

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

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
// 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) ?? [];
}
// 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.tshandleOrgListConfigs().

  • 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

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

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

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

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