diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 9e2b438..279acf0 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -602,12 +602,73 @@ pub fn org_unwrap_key( Ok(SessionHandle(handle)) } +/// Decrypt an org manifest blob (manifest.enc in the org repo) with an org +/// session handle. The org manifest is a dedicated OrgManifest type (NOT the +/// personal Manifest); core's decrypt_org_manifest deserializes it. +#[wasm_bindgen] +pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + js_value_for(&out) +} + +/// Encrypt an OrgManifest (JSON) with an org session handle, for the write track +/// (Dev-C never touches WASM — this is exposed here so they don't have to). +#[wasm_bindgen] +pub fn org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result, JsError> { + need_key(handle)?; + let m: relicario_core::OrgManifest = serde_json::from_str(manifest_json) + .map_err(|e| JsError::new(&format!("org manifest json: {e}")))?; + session::with(handle.0, |k| relicario_core::encrypt_org_manifest(&m, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + #[cfg(test)] mod org_tests { use super::*; use relicario_core::org::wrap_org_key; use zeroize::Zeroizing; + #[test] + fn org_manifest_round_trip_via_handle() { + use relicario_core::{OrgManifest, OrgManifestEntry, ItemType, ItemId, decrypt_org_manifest}; + session::clear(); + let key_bytes = [0xABu8; 32]; + let h = session::insert(Zeroizing::new(key_bytes), Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + let key = Zeroizing::new(key_bytes); + + let mut manifest = OrgManifest::new(); + manifest.entries.push(OrgManifestEntry { + id: ItemId::new(), + r#type: ItemType::SecureNote, + title: "Org Test Item".into(), + tags: vec!["tag1".into()], + modified: 1_234_567_890, + trashed_at: None, + collection: "prod".into(), + }); + + let manifest_json = serde_json::to_string(&manifest).unwrap(); + let encrypted = org_manifest_encrypt(&handle, &manifest_json).unwrap(); + assert!(!encrypted.is_empty()); + + // Decrypt via core directly (avoids js-sys/serde_wasm_bindgen on native). + let parsed: OrgManifest = decrypt_org_manifest(&encrypted, &key).unwrap(); + assert_eq!(parsed.entries.len(), 1); + assert_eq!(parsed.entries[0].collection, "prod"); + assert_eq!(parsed.entries[0].title, "Org Test Item"); + assert_eq!(parsed.entries[0].tags, vec!["tag1".to_string()]); + assert_eq!(parsed.schema_version, 1); + + // Random nonces: two encryptions of the same plaintext must differ. + let encrypted2 = org_manifest_encrypt(&handle, &manifest_json).unwrap(); + assert_ne!(encrypted, encrypted2, "nonces must differ"); + } + /// Returns (public_openssh, private_openssh) using the real core keypair generator. /// /// Device private keys are produced by `relicario_core::device::generate_keypair()` diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md index 094a813..ae0528d 100644 --- a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md @@ -4,7 +4,7 @@ **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. +**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. @@ -21,8 +21,8 @@ ## 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.) +- `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`. @@ -120,55 +120,42 @@ git commit -m "feat(wasm): org_unwrap_key — ECIES unwrap into a session handle --- -### Task 2: Org manifest `collection` field round-trips +### 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-core/src/manifest.rs` -- Modify: `extension/src/shared/types.ts` -- Test: `crates/relicario-core/tests/format_v2.rs` (or the manifest test module) +- 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: `ManifestEntry.collection: Option` (serde `skip_serializing_if = "Option::is_none"`), mirrored in TS as `collection?: string`. +- Produces: `org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result` — mirrors `manifest_decrypt`; org items remain personal Items. +- Produces: `org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result, 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`). -- [ ] **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. +- [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`. -- [ ] **Step 2: Write the failing test** +- [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. -```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")); -} -``` +- [x] **Step 3 (TDD GREEN):** `cargo test -p relicario-wasm org_manifest` → 1 passed. -- [ ] **Step 3: Run to verify it fails** +- [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`. -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`. +- [x] **Step 5 (gates):** All five gates green (see commit). ```bash -git add crates/relicario-core/src/manifest.rs extension/src/shared/types.ts -git commit -m "feat(core): ManifestEntry carries optional collection slug" +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" ``` --- @@ -460,8 +447,8 @@ Plans 2 and 3 are UI-only and talk to the SW exclusively through these messages - `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_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. diff --git a/extension/src/shared/types.ts b/extension/src/shared/types.ts index 565afcf..5a23673 100644 --- a/extension/src/shared/types.ts +++ b/extension/src/shared/types.ts @@ -206,6 +206,25 @@ export interface ManifestEntry { attachment_summaries: AttachmentSummary[]; } +// --- Org manifest --- +// Org manifest — a dedicated, leaner shape than the personal ManifestEntry. +// Faithful to relicario-core OrgManifestEntry; collection is REQUIRED. + +export interface OrgManifestEntry { + id: ItemId; + type: ItemType; // Rust r#type → JSON key "type" + title: string; + tags: string[]; + modified: number; + trashed_at?: number; + collection: string; +} + +export interface OrgManifest { + schema_version: number; + entries: OrgManifestEntry[]; +} + // --- Vault settings --- // Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md // β₂ tightens retention + generator_defaults; γ owns attachment_caps. diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 47feb4f..89dc5d6 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -88,6 +88,8 @@ declare module 'relicario-wasm' { export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array; export function org_unwrap_key(keys_blob: Uint8Array, device_private_openssh: string): SessionHandle; + export function org_manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; + export function org_manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array; export default function init(module_or_path?: unknown): Promise; export function initSync(args: { module: WebAssembly.Module }): void;