feat(ext/sw): org-vault read core (openOrg, grant-filtered list, collection-scoped get, offline)
- Add extension/src/service-worker/org-vault.ts: openOrg (members.json fingerprint match → org_unwrap_key → org_manifest_decrypt_filtered), listOrgItems, getOrgItem (collection-scoped path: items/<collection>/<id>.enc), listOrgCollections (grants filter). - Add OrgMember, OrgMembers, CollectionDef, OrgCollections, Collection types to shared/types.ts (mirrors relicario-core org.rs shapes). - Add 8-test suite covering: fingerprint match, not_an_org_member, listOrgItems passthrough, collection-scoped item path, listOrgCollections grant filter, storage.set never called, offline fallback (TypeError → cached manifest), offline with no cache → rethrow. - Fix plan doc line ~326: wasm.manifest_decrypt → wasm.org_manifest_decrypt_filtered. Gate results: 158/158 SW tests pass; npm run build:all clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
373
extension/src/service-worker/__tests__/org-vault.test.ts
Normal file
373
extension/src/service-worker/__tests__/org-vault.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GitHost } from '../git-host';
|
||||
import * as gitHostMod from '../git-host';
|
||||
import * as sshFingerprintMod from '../../shared/ssh-fingerprint';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
// Mock createGitHost so we can inject a fake GitHost per test.
|
||||
vi.mock('../git-host', async () => {
|
||||
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
|
||||
return {
|
||||
...actual,
|
||||
createGitHost: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock sshFingerprint so fingerprint matching is fully controllable.
|
||||
vi.mock('../../shared/ssh-fingerprint', () => ({
|
||||
sshFingerprint: vi.fn(),
|
||||
}));
|
||||
|
||||
// --- Test data ---
|
||||
|
||||
const MY_PK = 'ssh-ed25519 AAAAB3NzaC1yZDI1NTE5AAAAIdevice testdevice';
|
||||
const OTHER_PK = 'ssh-ed25519 AAAAB3NzaC1yZDI1NTE5AAAAIother otherdevice';
|
||||
const MY_FP = 'SHA256:myDeviceFingerprint';
|
||||
const OTHER_FP = 'SHA256:otherDeviceFingerprint';
|
||||
|
||||
const MEMBER_ID = 'aabbccdd11223344';
|
||||
const OTHER_MEMBER_ID = 'deadbeefcafe0001';
|
||||
|
||||
const MEMBERS_JSON = {
|
||||
schema_version: 1,
|
||||
members: [
|
||||
{
|
||||
member_id: OTHER_MEMBER_ID,
|
||||
display_name: 'Other User',
|
||||
role: 'member',
|
||||
ed25519_pubkey: OTHER_PK,
|
||||
collections: ['other-col'],
|
||||
added_at: 1000,
|
||||
added_by: OTHER_MEMBER_ID,
|
||||
},
|
||||
{
|
||||
member_id: MEMBER_ID,
|
||||
display_name: 'Test User',
|
||||
role: 'member',
|
||||
ed25519_pubkey: MY_PK,
|
||||
collections: ['prod-infra', 'staging'],
|
||||
added_at: 1001,
|
||||
added_by: MEMBER_ID,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const FILTERED_MANIFEST = {
|
||||
schema_version: 1,
|
||||
entries: [
|
||||
{ id: 'item001', type: 'login', title: 'My Login', tags: [], modified: 1000, collection: 'prod-infra' },
|
||||
{ id: 'item002', type: 'secure_note', title: 'A Note', tags: [], modified: 1001, collection: 'staging' },
|
||||
],
|
||||
};
|
||||
|
||||
const ORG_CFG = {
|
||||
orgId: 'org-test',
|
||||
displayName: 'Test Org',
|
||||
hostType: 'gitea' as const,
|
||||
hostUrl: 'https://git.example',
|
||||
repoPath: 'org/vault',
|
||||
apiToken: 'tok-abc',
|
||||
memberId: MEMBER_ID,
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function enc(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s);
|
||||
}
|
||||
|
||||
/** Build a fake GitHost whose readFile returns different payloads per path. */
|
||||
function makeOrgHost(
|
||||
files: Record<string, Uint8Array | (() => Promise<Uint8Array>)>,
|
||||
extraMethods: Partial<GitHost> = {},
|
||||
): GitHost {
|
||||
return {
|
||||
readFile: vi.fn().mockImplementation(async (path: string) => {
|
||||
const v = files[path];
|
||||
if (v === undefined) throw new Error(`404: ${path}`);
|
||||
if (typeof v === 'function') return v();
|
||||
return v;
|
||||
}),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
writeFileCreateOnly: vi.fn().mockResolvedValue(undefined),
|
||||
deleteFile: vi.fn().mockResolvedValue(undefined),
|
||||
listDir: vi.fn().mockResolvedValue([]),
|
||||
lastCommit: vi.fn().mockResolvedValue(null),
|
||||
putBlob: vi.fn().mockResolvedValue(''),
|
||||
getBlob: vi.fn().mockResolvedValue(new Uint8Array()),
|
||||
deleteBlob: vi.fn().mockResolvedValue(undefined),
|
||||
lastSyncAt: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
...extraMethods,
|
||||
};
|
||||
}
|
||||
|
||||
const WRAPPED_KEY_BYTES = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
|
||||
const MANIFEST_ENC_BYTES = new Uint8Array([0x01, 0x02, 0x03]);
|
||||
|
||||
function makeOrgHostDefault(): GitHost {
|
||||
return makeOrgHost({
|
||||
'members.json': enc(JSON.stringify(MEMBERS_JSON)),
|
||||
[`keys/${MEMBER_ID}.enc`]: WRAPPED_KEY_BYTES,
|
||||
'manifest.enc': MANIFEST_ENC_BYTES,
|
||||
'collections.json': enc(JSON.stringify({
|
||||
schema_version: 1,
|
||||
collections: [
|
||||
{ slug: 'prod-infra', display_name: 'Production Infrastructure', created_by: MEMBER_ID, created_at: 100 },
|
||||
{ slug: 'staging', display_name: 'Staging', created_by: MEMBER_ID, created_at: 101 },
|
||||
{ slug: 'secret-ops', display_name: 'Secret Ops', created_by: OTHER_MEMBER_ID, created_at: 102 },
|
||||
],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function makeFakeHandle() {
|
||||
return { value: 42, free: vi.fn() };
|
||||
}
|
||||
|
||||
/** Build a minimal wasm mock for org tests. */
|
||||
function makeOrgWasm(overrides: Record<string, unknown> = {}) {
|
||||
const fakeHandle = makeFakeHandle();
|
||||
return {
|
||||
_handle: fakeHandle,
|
||||
get_device_info: vi.fn(() => ({
|
||||
name: 'Test Device',
|
||||
signing_public_key: MY_PK,
|
||||
deploy_public_key: 'ssh-ed25519 AAAAB3Nzdeploy',
|
||||
})),
|
||||
org_unwrap_key: vi.fn(() => fakeHandle),
|
||||
org_manifest_decrypt_filtered: vi.fn(() => FILTERED_MANIFEST),
|
||||
item_decrypt: vi.fn(() => ({
|
||||
id: 'item001',
|
||||
title: 'My Login',
|
||||
type: 'login',
|
||||
tags: [],
|
||||
favorite: false,
|
||||
created: 100,
|
||||
modified: 1000,
|
||||
core: { type: 'login', username: 'user', password: 'pass' },
|
||||
sections: [],
|
||||
attachments: [],
|
||||
field_history: {},
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Setup the sshFingerprint mock to return fingerprints by pubkey. */
|
||||
function mockFingerprints(map: Record<string, string | null>) {
|
||||
vi.mocked(sshFingerprintMod.sshFingerprint).mockImplementation(async (key: string) => {
|
||||
return key in map ? map[key] : null;
|
||||
});
|
||||
}
|
||||
|
||||
// Import under test (after mocks are declared).
|
||||
import * as orgVault from '../org-vault';
|
||||
|
||||
// --- Chrome storage mock ---
|
||||
function mockChromeStorage() {
|
||||
const store: Record<string, unknown> = {};
|
||||
const setSpy = vi.fn().mockImplementation((kv: Record<string, unknown>) => {
|
||||
Object.assign(store, kv);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const getSpy = vi.fn().mockResolvedValue({});
|
||||
(global as { chrome: unknown }).chrome = {
|
||||
storage: { local: { get: getSpy, set: setSpy } },
|
||||
} as never;
|
||||
return { setSpy, getSpy, store };
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('org-vault', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default fingerprint mapping: MY_PK → MY_FP, OTHER_PK → OTHER_FP
|
||||
mockFingerprints({ [MY_PK]: MY_FP, [OTHER_PK]: OTHER_FP });
|
||||
});
|
||||
|
||||
// Test 1: openOrg — happy path: fingerprint match → grants, unwrap, filtered manifest
|
||||
it('openOrg: matches device by ed25519 fingerprint → grants; calls org_unwrap_key + org_manifest_decrypt_filtered', async () => {
|
||||
const host = makeOrgHostDefault();
|
||||
vi.mocked(gitHostMod.createGitHost).mockReturnValue(host);
|
||||
const w = makeOrgWasm();
|
||||
|
||||
const state = await orgVault.openOrg(ORG_CFG, w);
|
||||
|
||||
// Fingerprint called for both: device key + each member pubkey
|
||||
expect(sshFingerprintMod.sshFingerprint).toHaveBeenCalledWith(MY_PK);
|
||||
|
||||
// Correct member matched: MEMBER_ID (has MY_PK)
|
||||
expect(state.grants).toEqual(['prod-infra', 'staging']);
|
||||
|
||||
// org_unwrap_key called with the wrapped key bytes
|
||||
expect(w.org_unwrap_key).toHaveBeenCalledWith(WRAPPED_KEY_BYTES);
|
||||
|
||||
// org_manifest_decrypt_filtered called with handle, enc, grants
|
||||
expect(w.org_manifest_decrypt_filtered).toHaveBeenCalledWith(
|
||||
w._handle,
|
||||
MANIFEST_ENC_BYTES,
|
||||
['prod-infra', 'staging'],
|
||||
);
|
||||
|
||||
// Returned state
|
||||
expect(state.orgId).toBe('org-test');
|
||||
expect(state.offline).toBe(false);
|
||||
expect(state.manifest).toBe(FILTERED_MANIFEST);
|
||||
expect(state.handle).toBe(w._handle);
|
||||
});
|
||||
|
||||
// Test 2: openOrg — device fingerprint absent → throws not_an_org_member
|
||||
it('openOrg: device not in members.json → throws not_an_org_member', async () => {
|
||||
// No member matches MY_PK
|
||||
const membersNoMatch = {
|
||||
schema_version: 1,
|
||||
members: [
|
||||
{
|
||||
member_id: OTHER_MEMBER_ID,
|
||||
display_name: 'Other',
|
||||
role: 'member',
|
||||
ed25519_pubkey: OTHER_PK,
|
||||
collections: ['other-col'],
|
||||
added_at: 1000,
|
||||
added_by: OTHER_MEMBER_ID,
|
||||
},
|
||||
],
|
||||
};
|
||||
const host = makeOrgHost({
|
||||
'members.json': enc(JSON.stringify(membersNoMatch)),
|
||||
[`keys/${OTHER_MEMBER_ID}.enc`]: new Uint8Array([0x00]),
|
||||
'manifest.enc': MANIFEST_ENC_BYTES,
|
||||
});
|
||||
vi.mocked(gitHostMod.createGitHost).mockReturnValue(host);
|
||||
const w = makeOrgWasm();
|
||||
|
||||
await expect(orgVault.openOrg(ORG_CFG, w)).rejects.toThrow('not_an_org_member');
|
||||
// Should never reach org_unwrap_key
|
||||
expect(w.org_unwrap_key).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test 3: listOrgItems — returns cached filtered entries directly (no re-filter)
|
||||
it('listOrgItems: returns state.manifest.entries unchanged', () => {
|
||||
const state: orgVault.OrgHandleState = {
|
||||
orgId: 'org-test',
|
||||
host: makeOrgHostDefault(),
|
||||
handle: makeFakeHandle() as never,
|
||||
grants: ['prod-infra'],
|
||||
manifest: FILTERED_MANIFEST as never,
|
||||
offline: false,
|
||||
};
|
||||
|
||||
const items = orgVault.listOrgItems(state);
|
||||
|
||||
// Returns ALL entries from the manifest — filtering was done in core
|
||||
expect(items).toBe(FILTERED_MANIFEST.entries);
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Test 4: getOrgItem — reads items/<collection>/<id>.enc (collection-scoped path)
|
||||
it('getOrgItem: reads items/<collection>/<id>.enc and returns decrypted item', async () => {
|
||||
const ITEM_ENC = new Uint8Array([0xaa, 0xbb]);
|
||||
const host = makeOrgHost({
|
||||
'items/prod-infra/item001.enc': ITEM_ENC,
|
||||
});
|
||||
const w = makeOrgWasm();
|
||||
const state: orgVault.OrgHandleState = {
|
||||
orgId: 'org-test',
|
||||
host,
|
||||
handle: w._handle as never,
|
||||
grants: ['prod-infra'],
|
||||
manifest: FILTERED_MANIFEST as never,
|
||||
offline: false,
|
||||
};
|
||||
|
||||
const item = await orgVault.getOrgItem(state, 'item001', w);
|
||||
|
||||
// Path must be collection-scoped
|
||||
expect(host.readFile).toHaveBeenCalledWith('items/prod-infra/item001.enc');
|
||||
|
||||
// item_decrypt called with the correct handle and ciphertext
|
||||
expect(w.item_decrypt).toHaveBeenCalledWith(w._handle, ITEM_ENC);
|
||||
|
||||
// Returns the decrypted item
|
||||
expect(item.id).toBe('item001');
|
||||
});
|
||||
|
||||
// Test 5: listOrgCollections — returns ONLY granted collections (ungranted excluded)
|
||||
it('listOrgCollections: filters to grants, maps to { slug, display_name }', async () => {
|
||||
const host = makeOrgHostDefault();
|
||||
const state: orgVault.OrgHandleState = {
|
||||
orgId: 'org-test',
|
||||
host,
|
||||
handle: makeFakeHandle() as never,
|
||||
grants: ['prod-infra', 'staging'],
|
||||
manifest: FILTERED_MANIFEST as never,
|
||||
offline: false,
|
||||
};
|
||||
|
||||
const collections = await orgVault.listOrgCollections(state);
|
||||
|
||||
// secret-ops must be excluded (not in grants)
|
||||
expect(collections).toHaveLength(2);
|
||||
expect(collections.map((c) => c.slug)).toContain('prod-infra');
|
||||
expect(collections.map((c) => c.slug)).toContain('staging');
|
||||
expect(collections.map((c) => c.slug)).not.toContain('secret-ops');
|
||||
|
||||
// Shape: { slug, display_name } only
|
||||
expect(collections[0]).toHaveProperty('slug');
|
||||
expect(collections[0]).toHaveProperty('display_name');
|
||||
expect(Object.keys(collections[0])).toEqual(expect.arrayContaining(['slug', 'display_name']));
|
||||
});
|
||||
|
||||
// Test 6: openOrg NEVER writes the org key to storage
|
||||
it('openOrg: never writes org key or handle to chrome.storage.local', async () => {
|
||||
const { setSpy } = mockChromeStorage();
|
||||
const host = makeOrgHostDefault();
|
||||
vi.mocked(gitHostMod.createGitHost).mockReturnValue(host);
|
||||
const w = makeOrgWasm();
|
||||
|
||||
await orgVault.openOrg(ORG_CFG, w);
|
||||
|
||||
// chrome.storage.local.set must never be called at all by openOrg
|
||||
expect(setSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test 7a: offline fallback — network error + cachedManifest → offline:true, no throw
|
||||
it('openOrg: manifest.enc network error + cachedManifest → offline:true, returns cached manifest', async () => {
|
||||
const networkError = new TypeError('Failed to fetch');
|
||||
const host = makeOrgHost({
|
||||
'members.json': enc(JSON.stringify(MEMBERS_JSON)),
|
||||
[`keys/${MEMBER_ID}.enc`]: WRAPPED_KEY_BYTES,
|
||||
// manifest.enc throws network error
|
||||
'manifest.enc': () => Promise.reject(networkError),
|
||||
});
|
||||
vi.mocked(gitHostMod.createGitHost).mockReturnValue(host);
|
||||
const w = makeOrgWasm();
|
||||
const cached = { schema_version: 1, entries: [{ id: 'cached-item', type: 'login' as const, title: 'Cached', tags: [], modified: 999, collection: 'prod-infra' }] };
|
||||
|
||||
const state = await orgVault.openOrg(ORG_CFG, w, cached);
|
||||
|
||||
expect(state.offline).toBe(true);
|
||||
expect(state.manifest).toBe(cached);
|
||||
// org_manifest_decrypt_filtered must not be called (we used the cache)
|
||||
expect(w.org_manifest_decrypt_filtered).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test 7b: offline fallback — network error WITHOUT cachedManifest → rethrows
|
||||
it('openOrg: manifest.enc network error without cachedManifest → rethrows', async () => {
|
||||
const networkError = new TypeError('Failed to fetch');
|
||||
const host = makeOrgHost({
|
||||
'members.json': enc(JSON.stringify(MEMBERS_JSON)),
|
||||
[`keys/${MEMBER_ID}.enc`]: WRAPPED_KEY_BYTES,
|
||||
'manifest.enc': () => Promise.reject(networkError),
|
||||
});
|
||||
vi.mocked(gitHostMod.createGitHost).mockReturnValue(host);
|
||||
const w = makeOrgWasm();
|
||||
|
||||
// No cachedManifest → must rethrow
|
||||
await expect(orgVault.openOrg(ORG_CFG, w)).rejects.toThrow('Failed to fetch');
|
||||
});
|
||||
});
|
||||
147
extension/src/service-worker/org-vault.ts
Normal file
147
extension/src/service-worker/org-vault.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/// Org vault read core — mirrors vault.ts but for an org vault.
|
||||
/// Pure functions over a GitHost + the WASM org bridge.
|
||||
/// NO SW message handlers (Task 6 wires those), NO UI.
|
||||
///
|
||||
/// Security invariants:
|
||||
/// - The org master key lives ONLY inside the WASM session (org_unwrap_key).
|
||||
/// This module NEVER writes the key or any SessionHandle to chrome.storage.local,
|
||||
/// IndexedDB, or any other persistent store.
|
||||
/// - Grant filtering is done in core (org_manifest_decrypt_filtered); listOrgItems
|
||||
/// returns the already-filtered manifest.entries without any TS-side re-filter.
|
||||
|
||||
import type { SessionHandle } from '../../wasm/relicario_wasm';
|
||||
import type { GitHost } from './git-host';
|
||||
import { createGitHost } from './git-host';
|
||||
import { sshFingerprint } from '../shared/ssh-fingerprint';
|
||||
import type {
|
||||
OrgConfig,
|
||||
OrgMembers,
|
||||
OrgMember,
|
||||
OrgManifest,
|
||||
OrgManifestEntry,
|
||||
OrgCollections,
|
||||
Collection,
|
||||
Item,
|
||||
} from '../shared/types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type WasmModule = any;
|
||||
|
||||
export interface OrgHandleState {
|
||||
orgId: string;
|
||||
host: GitHost;
|
||||
handle: SessionHandle; // from org_unwrap_key; NOT persisted; Task 6 calls session.setOrg
|
||||
grants: string[]; // member.collections
|
||||
manifest: OrgManifest; // cached, grant-filtered
|
||||
offline: boolean;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// 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;
|
||||
// Some environments produce an Error with a recognisable message instead.
|
||||
if (e instanceof Error) {
|
||||
const msg = e.message.toLowerCase();
|
||||
return (
|
||||
msg.includes('failed to fetch') ||
|
||||
msg.includes('networkerror') ||
|
||||
msg.includes('network error')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Open an org vault:
|
||||
/// 1. Read public members.json
|
||||
/// 2. Match THIS device by ed25519 fingerprint
|
||||
/// 3. Derive grants from the member's collections
|
||||
/// 4. Read + unwrap the member's encrypted org key (keys/<member_id>.enc)
|
||||
/// 5. Fetch + grant-filter the manifest (offline fallback if cachedManifest supplied)
|
||||
///
|
||||
/// The returned handle is NOT persisted here. Task 6 hands it to session.setOrg.
|
||||
export async function openOrg(
|
||||
cfg: OrgConfig,
|
||||
w: WasmModule,
|
||||
cachedManifest?: OrgManifest,
|
||||
): Promise<OrgHandleState> {
|
||||
// 1. Build the GitHost for the org repo.
|
||||
const host = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken);
|
||||
|
||||
// 2. Read public members.json.
|
||||
const membersRaw = await host.readFile('members.json');
|
||||
const members = JSON.parse(new TextDecoder().decode(membersRaw)) as OrgMembers;
|
||||
|
||||
// 3. Get this device's ed25519 signing key and compute its fingerprint.
|
||||
const info = w.get_device_info() as { name: string; signing_public_key: string; deploy_public_key: string } | null;
|
||||
if (!info) throw new Error('device_key_unavailable');
|
||||
const myFp = await sshFingerprint(info.signing_public_key);
|
||||
|
||||
// 4. Find this device's member entry — await in a for-loop (not .find, which can't await).
|
||||
let me: OrgMember | undefined;
|
||||
for (const m of members.members) {
|
||||
if (await sshFingerprint(m.ed25519_pubkey) === myFp) {
|
||||
me = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!me) throw new Error('not_an_org_member');
|
||||
|
||||
// 5. Member's grants.
|
||||
const grants = me.collections;
|
||||
|
||||
// 6. Read this member's wrapped org key.
|
||||
const wrapped = await host.readFile(`keys/${me.member_id}.enc`);
|
||||
|
||||
// 7. Unwrap the org key inside WASM (reads DEVICE_STATE internally — no key param crosses JS).
|
||||
const handle = w.org_unwrap_key(wrapped) as SessionHandle;
|
||||
|
||||
// 8. Fetch + grant-filter the manifest; fall back to the cached copy on network failure.
|
||||
let manifest: OrgManifest;
|
||||
let offline = false;
|
||||
try {
|
||||
const enc = await host.readFile('manifest.enc');
|
||||
manifest = w.org_manifest_decrypt_filtered(handle, enc, grants) as OrgManifest;
|
||||
} catch (e) {
|
||||
if (cachedManifest && isNetworkError(e)) {
|
||||
manifest = cachedManifest;
|
||||
offline = true;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return { orgId: cfg.orgId, host, handle, grants, manifest, offline };
|
||||
}
|
||||
|
||||
/// Return the cached, already-grant-filtered manifest entries.
|
||||
/// Grant filtering happened in core (org_manifest_decrypt_filtered) — no TS-side filter.
|
||||
export function listOrgItems(state: OrgHandleState): OrgManifestEntry[] {
|
||||
return state.manifest.entries;
|
||||
}
|
||||
|
||||
/// Fetch and decrypt a single org item.
|
||||
/// Org items are collection-scoped: path = items/<collection>/<id>.enc
|
||||
/// (differs from personal flat items/<id>.enc).
|
||||
export async function getOrgItem(
|
||||
state: OrgHandleState,
|
||||
id: string,
|
||||
w: WasmModule,
|
||||
): Promise<Item> {
|
||||
const entry = state.manifest.entries.find((e) => e.id === id);
|
||||
if (!entry) throw new Error('item_not_found');
|
||||
const ct = await state.host.readFile(`items/${entry.collection}/${id}.enc`);
|
||||
return w.item_decrypt(state.handle, ct) as Item;
|
||||
}
|
||||
|
||||
/// Return the list of collections granted to this member.
|
||||
/// Reads public collections.json, filters to state.grants, maps to { slug, display_name }.
|
||||
export async function listOrgCollections(state: OrgHandleState): Promise<Collection[]> {
|
||||
const raw = await state.host.readFile('collections.json');
|
||||
const orgCollections = JSON.parse(new TextDecoder().decode(raw)) as OrgCollections;
|
||||
return orgCollections.collections
|
||||
.filter((c) => state.grants.includes(c.slug))
|
||||
.map((c) => ({ slug: c.slug, display_name: c.display_name }));
|
||||
}
|
||||
@@ -225,6 +225,45 @@ export interface OrgConfigSummary {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
// --- Org members + collections (mirror Rust org.rs shapes) ---
|
||||
|
||||
export type OrgRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
export interface OrgMember {
|
||||
member_id: string;
|
||||
display_name: string;
|
||||
role: OrgRole;
|
||||
/** SSH public key string (openssh format: "ssh-ed25519 AAAA...") */
|
||||
ed25519_pubkey: string;
|
||||
/** Collection slugs this member can access. */
|
||||
collections: string[];
|
||||
added_at: number;
|
||||
added_by: string;
|
||||
}
|
||||
|
||||
export interface OrgMembers {
|
||||
schema_version: number;
|
||||
members: OrgMember[];
|
||||
}
|
||||
|
||||
export interface CollectionDef {
|
||||
slug: string;
|
||||
display_name: string;
|
||||
created_by: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface OrgCollections {
|
||||
schema_version: number;
|
||||
collections: CollectionDef[];
|
||||
}
|
||||
|
||||
/** Public shape returned by listOrgCollections — slug + display_name only. */
|
||||
export interface Collection {
|
||||
slug: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
// --- Org manifest ---
|
||||
// Org manifest — a dedicated, leaner shape than the personal ManifestEntry.
|
||||
// Faithful to relicario-core OrgManifestEntry; collection is REQUIRED.
|
||||
|
||||
Reference in New Issue
Block a user