From f9b8f3821d927e5b4c3998d6d440addf9ec1093f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 22:45:09 -0400 Subject: [PATCH] feat(ext/sw): org_switch + org read messages (grant-filtered, offline-aware, cache cleared on lock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- .../2026-06-20-v0.9.0-org-a-foundation.md | 4 +- .../__tests__/org-handlers.test.ts | 225 ++++++++++++++++++ extension/src/service-worker/index.ts | 2 + extension/src/service-worker/org-vault.ts | 2 +- .../src/service-worker/router/org-handlers.ts | 78 +++++- .../src/service-worker/router/popup-only.ts | 22 +- extension/src/shared/messages.ts | 25 +- 7 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 extension/src/service-worker/__tests__/org-handlers.test.ts 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 0b47a60..94c273e 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 @@ -44,7 +44,7 @@ **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. +- Produces: `org_unwrap_key(keys_blob: &[u8]) -> Result` (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. @@ -407,7 +407,7 @@ git commit -m "feat(ext/sw): org-vault — unwrap, fetch, grant-filter manifest" - 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. +- 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** diff --git a/extension/src/service-worker/__tests__/org-handlers.test.ts b/extension/src/service-worker/__tests__/org-handlers.test.ts new file mode 100644 index 0000000..b9cbd6a --- /dev/null +++ b/extension/src/service-worker/__tests__/org-handlers.test.ts @@ -0,0 +1,225 @@ +/// Tests for org-handlers.ts (Task 6): org_switch + 3 read handlers + cache lifecycle. +/// +/// Tests at the handler-function level, not via popup-only.handle(). Thrown +/// errors (e.g. not_in_org_context) surface in production via index.ts's outer +/// .catch; here we assert them directly with expect(() => ...).toThrow(). + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as session from '../session'; + +// --- Mocks (hoisted) --- + +vi.mock('../org-vault', () => ({ + openOrg: vi.fn(), + listOrgItems: vi.fn(), + getOrgItem: vi.fn(), + listOrgCollections: vi.fn(), + isNetworkError: vi.fn(), +})); + +vi.mock('../org-config', () => ({ + loadOrgConfigs: vi.fn(), +})); + +import * as orgVaultMod from '../org-vault'; +import * as orgConfigMod from '../org-config'; +import { + handleOrgSwitch, + handleOrgListItems, + handleOrgGetItem, + handleOrgListCollections, + clearOrgCache, +} from '../router/org-handlers'; +import type { OrgHandleState } from '../org-vault'; +import type { OrgManifestEntry, Collection, Item } from '../../shared/types'; + +// --- Test data --- + +const ORG_ID = 'org-abc123'; + +const ORG_CFG = { + orgId: ORG_ID, + displayName: 'Test Org', + hostType: 'gitea' as const, + hostUrl: 'https://git.example', + repoPath: 'org/vault', + apiToken: 'tok-abc', + memberId: 'member001', +}; + +const FILTERED_ENTRIES: OrgManifestEntry[] = [ + { id: 'item001', type: 'login', title: 'Login A', tags: [], modified: 1000, collection: 'col-a' }, + { id: 'item002', type: 'secure_note', title: 'Note B', tags: [], modified: 1001, collection: 'col-b' }, +]; + +const ORG_MANIFEST = { schema_version: 1, entries: FILTERED_ENTRIES }; + +const GRANTED_COLLECTIONS: Collection[] = [ + { slug: 'col-a', display_name: 'Collection A' }, + { slug: 'col-b', display_name: 'Collection B' }, +]; + +const DECRYPTED_ITEM: Item = { + id: 'item001', title: 'Login A', type: 'login', tags: [], favorite: false, + created: 100, modified: 1000, + core: { type: 'login', username: 'user', password: 'pass' }, + sections: [], attachments: [], field_history: {}, +}; + +const FAKE_WASM = { label: 'fake-wasm-module' }; + +function makeFakeHandle() { + return { value: 42, free: vi.fn() }; +} + +function makeFakeOrgState(overrides: Partial = {}): OrgHandleState { + return { + orgId: ORG_ID, + host: {} as never, + handle: makeFakeHandle() as never, + grants: ['col-a', 'col-b'], + manifest: ORG_MANIFEST as never, + offline: false, + ...overrides, + }; +} + +// --- Suite --- + +describe('org-handlers', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset real session module state (calls .free() on any stored handles). + session.clearAll(); + // Reset the module-level org cache in org-handlers. + clearOrgCache(); + // Default: one org configured, isNetworkError returns false. + vi.mocked(orgConfigMod.loadOrgConfigs).mockResolvedValue([ORG_CFG]); + vi.mocked(orgVaultMod.isNetworkError).mockReturnValue(false); + }); + + // Test 1: org_switch to an orgId — openOrg called, session wired, offline:false + it('org_switch to an org: calls openOrg, wires setOrg + setContext, returns { context, offline:false }', async () => { + const fakeState = makeFakeOrgState({ offline: false }); + vi.mocked(orgVaultMod.openOrg).mockResolvedValue(fakeState); + + const result = await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM); + + expect(result).toEqual({ ok: true, data: { context: ORG_ID, offline: false } }); + // openOrg called with the config + wasm module; no prior manifest (first switch) + expect(orgVaultMod.openOrg).toHaveBeenCalledWith(ORG_CFG, FAKE_WASM, undefined); + // Session context set to the org + expect(session.currentContext()).toBe(ORG_ID); + // Handle from org state registered in session + expect(session.getOrg(ORG_ID)).toBe(fakeState.handle); + }); + + // Test 2: org_switch to 'personal' — no openOrg call, context reset to 'personal' + it("org_switch to 'personal': sets personal context, returns { context:'personal', offline:false }, openOrg not called", async () => { + const result = await handleOrgSwitch({ context: 'personal' }, FAKE_WASM); + + expect(result).toEqual({ ok: true, data: { context: 'personal', offline: false } }); + expect(orgVaultMod.openOrg).not.toHaveBeenCalled(); + expect(session.currentContext()).toBe('personal'); + }); + + // Test 3: offline fallback — openOrg throws network error + prior cached state exists → offline:true + it('org_switch: openOrg throws network error + prior cached state → returns offline:true (reuses cache, no error)', async () => { + // Populate cache with a successful first switch. + const fakeState = makeFakeOrgState(); + vi.mocked(orgVaultMod.openOrg).mockResolvedValueOnce(fakeState); + await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM); + + // Second switch: network error + isNetworkError returns true. + const networkErr = new TypeError('Failed to fetch'); + vi.mocked(orgVaultMod.openOrg).mockRejectedValueOnce(networkErr); + vi.mocked(orgVaultMod.isNetworkError).mockReturnValue(true); + + const result = await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM); + + expect(result).toEqual({ ok: true, data: { context: ORG_ID, offline: true } }); + // openOrg was called with the prior cached manifest as offline fallback + expect(orgVaultMod.openOrg).toHaveBeenLastCalledWith(ORG_CFG, FAKE_WASM, fakeState.manifest); + // Context still set to the org + expect(session.currentContext()).toBe(ORG_ID); + }); + + // Test 4: org_switch to unconfigured orgId → { ok:false, error:'org_not_configured' } + it('org_switch to unconfigured orgId: returns { ok:false, error:org_not_configured }', async () => { + vi.mocked(orgConfigMod.loadOrgConfigs).mockResolvedValue([]); // no configs + + const result = await handleOrgSwitch({ context: 'org-does-not-exist' }, FAKE_WASM); + + expect(result).toEqual({ ok: false, error: 'org_not_configured' }); + expect(orgVaultMod.openOrg).not.toHaveBeenCalled(); + }); + + // Test 5: org_list_items after a switch → returns cached filtered OrgManifestEntry[] + it('org_list_items after org_switch: returns cached filtered OrgManifestEntry[] (not personal ManifestEntry[])', async () => { + const fakeState = makeFakeOrgState(); + vi.mocked(orgVaultMod.openOrg).mockResolvedValue(fakeState); + vi.mocked(orgVaultMod.listOrgItems).mockReturnValue(FILTERED_ENTRIES); + await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM); + + const result = handleOrgListItems(); + + expect(result).toEqual({ ok: true, data: FILTERED_ENTRIES }); + // listOrgItems called with the cached OrgHandleState + expect(orgVaultMod.listOrgItems).toHaveBeenCalledWith(fakeState); + // The data entries have collection fields (OrgManifestEntry, not ManifestEntry) + expect((result.data as OrgManifestEntry[])[0]).toHaveProperty('collection', 'col-a'); + }); + + // Test 6: org_get_item → decrypted Item + it('org_get_item: calls getOrgItem with cached state + id + wasm, returns decrypted Item', async () => { + const fakeState = makeFakeOrgState(); + vi.mocked(orgVaultMod.openOrg).mockResolvedValue(fakeState); + vi.mocked(orgVaultMod.getOrgItem).mockResolvedValue(DECRYPTED_ITEM); + await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM); + + const result = await handleOrgGetItem({ id: 'item001' }, FAKE_WASM); + + expect(result).toEqual({ ok: true, data: DECRYPTED_ITEM }); + expect(orgVaultMod.getOrgItem).toHaveBeenCalledWith(fakeState, 'item001', FAKE_WASM); + }); + + // Test 7: org_list_collections → granted Collection[] + it('org_list_collections: calls listOrgCollections with cached state, returns granted Collection[]', async () => { + const fakeState = makeFakeOrgState(); + vi.mocked(orgVaultMod.openOrg).mockResolvedValue(fakeState); + vi.mocked(orgVaultMod.listOrgCollections).mockResolvedValue(GRANTED_COLLECTIONS); + await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM); + + const result = await handleOrgListCollections(); + + expect(result).toEqual({ ok: true, data: GRANTED_COLLECTIONS }); + expect(orgVaultMod.listOrgCollections).toHaveBeenCalledWith(fakeState); + }); + + // Test 8: org_list_items with 'personal' context (no org switch) → throws not_in_org_context + it("org_list_items with no org switch (context is 'personal'): throws not_in_org_context", () => { + // Default context is 'personal' (set by session.clearAll() in beforeEach). + expect(session.currentContext()).toBe('personal'); + + expect(() => handleOrgListItems()).toThrow('not_in_org_context'); + }); + + // Test 9: clearOrgCache — empties the map; org read throws not_in_org_context after clear + it('clearOrgCache: empties the org state cache; org read after clear throws not_in_org_context', async () => { + // Populate cache. + const fakeState = makeFakeOrgState(); + vi.mocked(orgVaultMod.openOrg).mockResolvedValue(fakeState); + vi.mocked(orgVaultMod.listOrgItems).mockReturnValue(FILTERED_ENTRIES); + await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM); + + // Confirm we can read (cache is populated, context is ORG_ID). + expect(() => handleOrgListItems()).not.toThrow(); + + // Clear (simulates lock handler calling clearOrgCache). + clearOrgCache(); + + // Context is still ORG_ID but cache is empty → requireCurrentOrgState throws. + expect(session.currentContext()).toBe(ORG_ID); + expect(() => handleOrgListItems()).toThrow('not_in_org_context'); + }); +}); diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index 295b920..4d8de08 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -8,6 +8,7 @@ import { encodeBinary, decodeBinary } from '../shared/message-binary'; import * as vault from './vault'; import { clearCurrent } from './session'; import * as sessionTimer from './session-timer'; +import { clearOrgCache } from './router/org-handlers'; import { READ_ONLY_CONTENT_CALLABLE } from './session-timer'; // @ts-ignore TS2307 — resolved by webpack alias / copy @@ -52,6 +53,7 @@ const state: RouterState = { sessionTimer.onExpired(() => { // eslint-disable-next-line no-console console.log('[relicario sw] session expired — locking vault'); + clearOrgCache(); // prevent use-after-free: clear org cache BEFORE freeing handles clearCurrent(); state.manifest = null; // Plan C Phase 5: don't leak the cached git-host client across a lock. diff --git a/extension/src/service-worker/org-vault.ts b/extension/src/service-worker/org-vault.ts index c080c5b..4cba003 100644 --- a/extension/src/service-worker/org-vault.ts +++ b/extension/src/service-worker/org-vault.ts @@ -38,7 +38,7 @@ export interface OrgHandleState { /// Detect a network-level failure (fetch() rejects with TypeError; no connectivity). /// Does NOT classify HTTP non-2xx errors (those are application-level, not network). -function isNetworkError(e: unknown): boolean { +export function isNetworkError(e: unknown): boolean { // fetch() rejects with TypeError when the request cannot be made at all // (DNS failure, no connectivity, CORS preflight failure, etc.). if (e instanceof TypeError) return true; diff --git a/extension/src/service-worker/router/org-handlers.ts b/extension/src/service-worker/router/org-handlers.ts index 42d0e66..2a0f5e4 100644 --- a/extension/src/service-worker/router/org-handlers.ts +++ b/extension/src/service-worker/router/org-handlers.ts @@ -1,11 +1,85 @@ /// Org message handler arms — kept separate so popup-only.ts doesn't bloat. /// -/// Security invariant: handlers here must NEVER return full OrgConfig objects -/// to the popup. Always project to OrgConfigSummary (id + displayName only). +/// Security invariants: +/// - Handlers here must NEVER return full OrgConfig objects to the popup. +/// Always project to OrgConfigSummary (id + displayName only). +/// - clearOrgCache() MUST be called on every lock / inactivity-timeout path +/// (alongside session.clearAll). The orgStates Map holds OrgHandleState +/// objects that reference SessionHandles; session.clearAll() frees those +/// handles in WASM. Reusing a cached state after a lock would be a +/// use-after-free of a freed WASM handle. +import { openOrg, listOrgItems, getOrgItem, listOrgCollections, isNetworkError } from '../org-vault'; +import type { OrgHandleState } from '../org-vault'; import { loadOrgConfigs } from '../org-config'; +import * as session from '../session'; import type { OrgConfigSummary } from '../../shared/types'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type WasmModule = any; + +// Cached open-org state per orgId (host + grants + filtered manifest). The +// SessionHandle is also held by session.setOrg; this Map holds the rest. +const orgStates = new Map(); + +// MUST be called on lock / inactivity-timeout (alongside session.clearCurrent/clearAll): +// the cached states reference SessionHandles that clearAll() frees — reusing one +// after a lock would be a use-after-free. Clearing the cache prevents that. +export function clearOrgCache(): void { orgStates.clear(); } + +function requireCurrentOrgState(): OrgHandleState { + const ctx = session.currentContext(); + const state = ctx === 'personal' ? undefined : orgStates.get(ctx); + if (!state) throw new Error('not_in_org_context'); + return state; +} + +/** Handle org_switch: open the org vault and cache its state, or restore personal context. */ +export async function handleOrgSwitch( + { context }: { context: string }, + w: WasmModule, +): Promise<{ ok: true; data: { context: string; offline: boolean } } | { ok: false; error: string }> { + if (context === 'personal') { + session.setContext('personal'); + return { ok: true, data: { context: 'personal', offline: false } }; + } + + const cfgs = await loadOrgConfigs(); + const cfg = cfgs.find((c) => c.orgId === context); + if (!cfg) return { ok: false, error: 'org_not_configured' }; + + const prev = orgStates.get(context); + try { + const state = await openOrg(cfg, w, prev?.manifest); + orgStates.set(context, state); + session.setOrg(context, state.handle); // frees a displaced prior handle (Task 3) + session.setContext(context); + return { ok: true, data: { context, offline: state.offline } }; + } catch (e) { + // Full-outage fallback: a network error with a prior cached state → reuse it read-only. + if (prev && isNetworkError(e)) { + session.setContext(context); + return { ok: true, data: { context, offline: true } }; + } + return { ok: false, error: e instanceof Error ? e.message : 'org_switch_failed' }; + } +} + +/** Handle org_list_items: returns cached, grant-filtered OrgManifestEntry[]. */ +export function handleOrgListItems() { + return { ok: true as const, data: listOrgItems(requireCurrentOrgState()) }; +} + +/** Handle org_get_item: fetch and decrypt a single org item. */ +export async function handleOrgGetItem({ id }: { id: string }, w: WasmModule) { + return { ok: true as const, data: await getOrgItem(requireCurrentOrgState(), id, w) }; +} + +/** Handle org_list_collections: returns granted Collection[] only. */ +export async function handleOrgListCollections() { + return { ok: true as const, data: await listOrgCollections(requireCurrentOrgState()) }; +} + /** Handle org_list_configs: returns only { orgId, displayName } per org. */ export async function handleOrgListConfigs(): Promise<{ ok: true; data: OrgConfigSummary[] }> { const cfgs = await loadOrgConfigs(); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 417b450..c7e3917 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -14,7 +14,14 @@ import * as session from '../session'; import * as devices from '../devices'; import * as sessionTimer from '../session-timer'; import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage'; -import { handleOrgListConfigs } from './org-handlers'; +import { + handleOrgListConfigs, + handleOrgSwitch, + handleOrgListItems, + handleOrgGetItem, + handleOrgListCollections, + clearOrgCache, +} from './org-handlers'; // --- Shared ambient state owned by the SW module --- // @@ -73,6 +80,7 @@ export async function handle( } case 'lock': + clearOrgCache(); // prevent use-after-free: clear org cache BEFORE freeing handles session.clearCurrent(); state.manifest = null; // Don't leak the cached git-host (incl. lastSyncAt) across a lock — @@ -663,6 +671,18 @@ export async function handle( case 'org_list_configs': return handleOrgListConfigs(); + case 'org_switch': + return handleOrgSwitch(msg, state.wasm); + + case 'org_list_items': + return handleOrgListItems(); + + case 'org_get_item': + return handleOrgGetItem(msg, state.wasm); + + case 'org_list_collections': + return handleOrgListCollections(); + default: return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` }; } diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 21d492a..23a51ed 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -1,7 +1,7 @@ import type { Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState, DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef, Device, - FieldHistoryView, OrgConfigSummary, + FieldHistoryView, OrgConfigSummary, OrgManifestEntry, Collection, } from './types'; // --- Session timeout config --- @@ -69,7 +69,11 @@ export type PopupMessage = | { type: 'attach_vault'; config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string } | { type: 'get_vault_status' } - | { type: 'org_list_configs' }; + | { type: 'org_list_configs' } + | { type: 'org_switch'; context: string } + | { type: 'org_list_items' } + | { type: 'org_get_item'; id: string } + | { type: 'org_list_collections' }; // --- Messages a content script may send --- @@ -184,6 +188,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'generate_recovery_qr', 'unwrap_recovery_qr', 'create_vault', 'attach_vault', 'get_vault_status', 'org_list_configs', + 'org_switch', 'org_list_items', 'org_get_item', 'org_list_collections', ] as PopupMessage['type'][]); export interface ExportBackupResponse extends Extract { @@ -227,6 +232,22 @@ export interface OrgListConfigsResponse extends Extract data: OrgConfigSummary[]; } +export interface OrgSwitchResponse extends Extract { + data: { context: string; offline: boolean }; +} + +export interface OrgListItemsResponse extends Extract { + data: OrgManifestEntry[]; +} + +export interface OrgGetItemResponse extends Extract { + data: Item; +} + +export interface OrgListCollectionsResponse extends Extract { + data: Collection[]; +} + export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([ 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', 'capture_save_login',