From f5336e352fd9a94b0fffb5db5e4284c3f8d429f6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 23:40:44 -0400 Subject: [PATCH] feat(ext/sw): org config storage + org_list_configs message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- .../__tests__/org-config.test.ts | 47 +++++++++++++++++++ extension/src/service-worker/org-config.ts | 17 +++++++ .../src/service-worker/router/org-handlers.ts | 16 +++++++ .../src/service-worker/router/popup-only.ts | 4 ++ extension/src/shared/messages.ts | 10 +++- extension/src/shared/types.ts | 19 ++++++++ 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 extension/src/service-worker/__tests__/org-config.test.ts create mode 100644 extension/src/service-worker/org-config.ts create mode 100644 extension/src/service-worker/router/org-handlers.ts diff --git a/extension/src/service-worker/__tests__/org-config.test.ts b/extension/src/service-worker/__tests__/org-config.test.ts new file mode 100644 index 0000000..f9a87af --- /dev/null +++ b/extension/src/service-worker/__tests__/org-config.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { handleOrgListConfigs } from '../router/org-handlers'; + +function mockChromeStorage(data: Record) { + (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); + } + }); +}); diff --git a/extension/src/service-worker/org-config.ts b/extension/src/service-worker/org-config.ts new file mode 100644 index 0000000..457cffc --- /dev/null +++ b/extension/src/service-worker/org-config.ts @@ -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 { + 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 { + await chrome.storage.local.set({ orgConfigs: configs }); +} diff --git a/extension/src/service-worker/router/org-handlers.ts b/extension/src/service-worker/router/org-handlers.ts new file mode 100644 index 0000000..42d0e66 --- /dev/null +++ b/extension/src/service-worker/router/org-handlers.ts @@ -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 })), + }; +} diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index bbc6036..a18deee 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -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}` }; } diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 3c59bc4..21d492a 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, + 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 = 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 { @@ -221,6 +223,10 @@ export interface GetVaultStatusResponse extends Extract pendingItems: number }; } +export interface OrgListConfigsResponse extends Extract { + data: OrgConfigSummary[]; +} + export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([ 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', 'capture_save_login', diff --git a/extension/src/shared/types.ts b/extension/src/shared/types.ts index 5a23673..84fe3b6 100644 --- a/extension/src/shared/types.ts +++ b/extension/src/shared/types.ts @@ -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.