feat(ext/sw): org config storage + org_list_configs message

Introduces OrgConfig/OrgConfigSummary types, chrome.storage.local backing
via org-config.ts, and the first org SW message (org_list_configs) wired in
all three required places: PopupMessage union, POPUP_ONLY_TYPES set, and a
case arm in popup-only.ts. Handler projects to OrgConfigSummary — apiToken,
hostUrl, repoPath, memberId never leave the SW. 3 new TDD tests (RED→GREEN).

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-25 23:40:44 -04:00
parent 2ecd98208d
commit f5336e352f
6 changed files with 111 additions and 2 deletions

View File

@@ -0,0 +1,47 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { handleOrgListConfigs } from '../router/org-handlers';
function mockChromeStorage(data: Record<string, unknown>) {
(global as { chrome: unknown }).chrome = {
storage: {
local: {
get: vi.fn().mockResolvedValue(data),
},
},
} as never;
}
describe('org-config / org_list_configs', () => {
beforeEach(() => {
mockChromeStorage({});
});
it('org_list_configs returns id+displayName only (no tokens)', async () => {
chrome.storage.local.get = vi.fn().mockResolvedValue({ orgConfigs: [
{ orgId: 'o1', displayName: 'Acme', hostType: 'gitea', hostUrl: 'h', repoPath: 'r', apiToken: 'SECRET', memberId: 'm1' },
]});
const resp = await handleOrgListConfigs();
expect(resp).toEqual({ ok: true, data: [{ orgId: 'o1', displayName: 'Acme' }] });
});
it('returns empty array when no orgConfigs stored', async () => {
chrome.storage.local.get = vi.fn().mockResolvedValue({});
const resp = await handleOrgListConfigs();
expect(resp).toEqual({ ok: true, data: [] });
});
it('does not expose apiToken in any entry', async () => {
chrome.storage.local.get = vi.fn().mockResolvedValue({ orgConfigs: [
{ orgId: 'o2', displayName: 'Corp', hostType: 'github', hostUrl: 'https://github.com', repoPath: 'corp/vault', apiToken: 'ghp_SUPERSECRET', memberId: 'm2' },
]});
const resp = await handleOrgListConfigs();
expect(resp.ok).toBe(true);
if (resp.ok) {
const entry = resp.data[0];
expect('apiToken' in entry).toBe(false);
expect('hostUrl' in entry).toBe(false);
expect('repoPath' in entry).toBe(false);
expect('memberId' in entry).toBe(false);
}
});
});

View File

@@ -0,0 +1,17 @@
/// Org config storage — device-local chrome.storage.local backing for
/// configured org vaults. The full OrgConfig (including apiToken) lives only
/// in the SW; only OrgConfigSummary (id + displayName) is ever returned to the
/// popup. See shared/types.ts for the type definitions.
import type { OrgConfig } from '../shared/types';
/** Load all org configs from chrome.storage.local. Returns [] if none stored. */
export async function loadOrgConfigs(): Promise<OrgConfig[]> {
const { orgConfigs } = await chrome.storage.local.get('orgConfigs');
return (orgConfigs as OrgConfig[] | undefined) ?? [];
}
/** Persist the full org config list to chrome.storage.local. */
export async function saveOrgConfigs(configs: OrgConfig[]): Promise<void> {
await chrome.storage.local.set({ orgConfigs: configs });
}

View File

@@ -0,0 +1,16 @@
/// 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).
import { loadOrgConfigs } from '../org-config';
import type { OrgConfigSummary } from '../../shared/types';
/** Handle org_list_configs: returns only { orgId, displayName } per org. */
export async function handleOrgListConfigs(): Promise<{ ok: true; data: OrgConfigSummary[] }> {
const cfgs = await loadOrgConfigs();
return {
ok: true as const,
data: cfgs.map(c => ({ orgId: c.orgId, displayName: c.displayName })),
};
}

View File

@@ -14,6 +14,7 @@ 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';
// --- Shared ambient state owned by the SW module ---
//
@@ -642,6 +643,9 @@ export async function handle(
case 'get_vault_status':
return vault.handleGetVaultStatus(state);
case 'org_list_configs':
return handleOrgListConfigs();
default:
return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` };
}

View File

@@ -1,7 +1,7 @@
import type {
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef, Device,
FieldHistoryView,
FieldHistoryView, OrgConfigSummary,
} from './types';
// --- Session timeout config ---
@@ -68,7 +68,8 @@ export type PopupMessage =
carrierImageBytes: ArrayBuffer; deviceName: string }
| { type: 'attach_vault'; config: VaultConfig; passphrase: string;
referenceImageBytes: ArrayBuffer; deviceName: string }
| { type: 'get_vault_status' };
| { type: 'get_vault_status' }
| { type: 'org_list_configs' };
// --- Messages a content script may send ---
@@ -182,6 +183,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'preview_totp_from_secret',
'generate_recovery_qr', 'unwrap_recovery_qr',
'create_vault', 'attach_vault', 'get_vault_status',
'org_list_configs',
] as PopupMessage['type'][]);
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
@@ -221,6 +223,10 @@ export interface GetVaultStatusResponse extends Extract<Response, { ok: true }>
pendingItems: number };
}
export interface OrgListConfigsResponse extends Extract<Response, { ok: true }> {
data: OrgConfigSummary[];
}
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
'capture_save_login',

View File

@@ -206,6 +206,25 @@ export interface ManifestEntry {
attachment_summaries: AttachmentSummary[];
}
// --- Org config (device-local storage) ---
/** Full org config stored in the SW only — never sent to the popup. */
export interface OrgConfig {
orgId: string;
displayName: string;
hostType: 'gitea' | 'github';
hostUrl: string;
repoPath: string;
apiToken: string;
memberId: string;
}
/** Safe subset returned by org_list_configs — no tokens, no remote details. */
export interface OrgConfigSummary {
orgId: string;
displayName: string;
}
// --- Org manifest ---
// Org manifest — a dedicated, leaner shape than the personal ManifestEntry.
// Faithful to relicario-core OrgManifestEntry; collection is REQUIRED.