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:
@@ -323,7 +323,7 @@ git commit -m "feat(ext/sw): org config storage + org_list_configs message"
|
|||||||
- Test: `extension/src/service-worker/__tests__/org-vault.test.ts`
|
- Test: `extension/src/service-worker/__tests__/org-vault.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**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).
|
- Consumes: `createGitHost` (`service-worker/git-host.ts`); `org_unwrap_key` (Task 1); device key from `DEVICE_STATE` via `w.get_device_info()`; `wasm.org_manifest_decrypt_filtered` (Task 5a).
|
||||||
- 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[]`.
|
- 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)
|
- [ ] **Step 1: Write the failing test** (mock the GitHost + wasm boundary as `router.test.ts` does)
|
||||||
|
|||||||
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;
|
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 ---
|
||||||
// Org manifest — a dedicated, leaner shape than the personal ManifestEntry.
|
// Org manifest — a dedicated, leaner shape than the personal ManifestEntry.
|
||||||
// Faithful to relicario-core OrgManifestEntry; collection is REQUIRED.
|
// Faithful to relicario-core OrgManifestEntry; collection is REQUIRED.
|
||||||
|
|||||||
Reference in New Issue
Block a user