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:
47
extension/src/service-worker/__tests__/org-config.test.ts
Normal file
47
extension/src/service-worker/__tests__/org-config.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
17
extension/src/service-worker/org-config.ts
Normal file
17
extension/src/service-worker/org-config.ts
Normal 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 });
|
||||
}
|
||||
16
extension/src/service-worker/router/org-handlers.ts
Normal file
16
extension/src/service-worker/router/org-handlers.ts
Normal 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 })),
|
||||
};
|
||||
}
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user