feat(core,wasm): filter_by_collections single-source + org_manifest_decrypt_filtered

Add OrgManifest::filter_by_collections(&[String]) as the single source of
grant-filter logic. Refactor filter_for_member to delegate to it (no
duplicated loop). Add org_manifest_decrypt_filtered WASM binding that
decrypts + filters in core so ungranted entries never cross to JS (phase-1
SOFT/UX filter; per-collection crypto isolation is phase-2). Declare the
binding in extension/src/wasm.d.ts. Vec<String> compiled cleanly for
wasm32-unknown-unknown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
adlee-was-taken
2026-06-26 22:19:55 -04:00
parent 0d56b3333b
commit 288956c089
3 changed files with 80 additions and 5 deletions

View File

@@ -206,18 +206,27 @@ impl OrgManifest {
Self { schema_version: 1, entries: Vec::new() }
}
/// Return only entries whose collection is in `member.collections`.
pub fn filter_for_member(&self, member: &OrgMember) -> Self {
let granted: std::collections::HashSet<&str> =
member.collections.iter().map(|s| s.as_str()).collect();
/// Keep only entries whose collection slug is in `granted`.
///
/// NOTE: phase-1 grant filtering is a SOFT/UX filter — the org member holds
/// the single org key and can decrypt everything; per-collection crypto
/// isolation is phase-2. This hides ungranted entries from the UI; it is not
/// a cryptographic boundary.
pub fn filter_by_collections(&self, granted: &[String]) -> Self {
let set: std::collections::HashSet<&str> = granted.iter().map(|s| s.as_str()).collect();
Self {
schema_version: self.schema_version,
entries: self.entries.iter()
.filter(|e| granted.contains(e.collection.as_str()))
.filter(|e| set.contains(e.collection.as_str()))
.cloned()
.collect(),
}
}
/// Return only entries whose collection is in `member.collections`.
pub fn filter_for_member(&self, member: &OrgMember) -> Self {
self.filter_by_collections(&member.collections) // delegate — single source
}
}
impl Default for OrgManifest {
@@ -418,6 +427,44 @@ mod tests {
assert_eq!(filtered.entries[0].collection, "prod");
}
#[test]
fn filter_by_collections_keeps_only_granted_entries() {
let mut manifest = OrgManifest::new();
for (title, coll) in [("A", "prod"), ("B", "dev"), ("C", "secret")] {
manifest.entries.push(OrgManifestEntry {
id: ItemId::new(),
r#type: crate::item_types::ItemType::SecureNote,
title: title.into(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: coll.into(),
});
}
let filtered = manifest.filter_by_collections(&["prod".into(), "dev".into()]);
assert_eq!(filtered.entries.len(), 2);
assert!(filtered.entries.iter().all(|e| e.collection != "secret"));
assert_eq!(filtered.schema_version, manifest.schema_version);
}
#[test]
fn filter_by_collections_empty_granted_returns_empty() {
let mut manifest = OrgManifest::new();
manifest.entries.push(OrgManifestEntry {
id: ItemId::new(),
r#type: crate::item_types::ItemType::SecureNote,
title: "X".into(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: "prod".into(),
});
let filtered = manifest.filter_by_collections(&[]);
assert_eq!(filtered.entries.len(), 0);
}
#[test]
fn generate_org_key_is_32_bytes() {
let key = generate_org_key();