diff --git a/.gitignore b/.gitignore index 5038125f4..868c0643d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,11 @@ apps/we-electron/electron/seed-port-map.json apps/we-electron/electron/seed-extra-resources.json apps/we-electron/electron/seed-runtime.json apps/we-electron/electron/seed-servers.js + +# E2E test artifacts — screenshots, traces, reports. The directory structure +# persists (.gitkeep), but generated images and HTML reports do not. +e2e/screenshots/*.png +e2e/test-results/ +e2e/playwright-report/ + +test-results/ \ No newline at end of file diff --git a/e2e/helpers/ad4m-rpc.ts b/e2e/helpers/ad4m-rpc.ts new file mode 100644 index 000000000..2f56079b8 --- /dev/null +++ b/e2e/helpers/ad4m-rpc.ts @@ -0,0 +1,136 @@ +/** + * Minimal AD4M WebSocket RPC client for e2e test setup. + * + * Uses Node's built-in WebSocket (Node ≥ 21). No external dependencies. + * Handles: user creation, login, agent status — enough to bootstrap auth + * without ad4m-connect's interactive UI flow. + */ + +interface RpcResponse { + id: string; + result?: unknown; + error?: { message: string; code?: number }; +} + +export interface Ad4mRpcConfig { + /** WebSocket URL (default: ws://127.0.0.1:12000/api/v1/ws) */ + wsUrl?: string; + /** Admin credential / token (default: test123) */ + token?: string; + /** Timeout per RPC call in ms (default: 15000) */ + timeout?: number; +} + +const defaults = { + wsUrl: 'ws://127.0.0.1:12000/api/v1/ws', + token: 'test123', + timeout: 15_000, +}; + +/** + * Make a single RPC call and close the connection. + * + * The AD4M WS-RPC protocol sends JSON: + * → { id, type: "method.name", params: {...} } + * ← { id, result | error } + */ +export async function rpcCall( + method: string, + params: Record = {}, + config: Ad4mRpcConfig = {}, +): Promise { + const wsUrl = config.wsUrl ?? defaults.wsUrl; + const token = config.token ?? defaults.token; + const timeout = config.timeout ?? defaults.timeout; + + const fullUrl = wsUrl.includes('?') ? `${wsUrl}&token=${token}` : `${wsUrl}?token=${token}`; + + return new Promise((resolve, reject) => { + const ws = new WebSocket(fullUrl); + const reqId = crypto.randomUUID(); + let settled = false; + + const timer = setTimeout(() => { + if (!settled) { + settled = true; + ws.close(); + reject(new Error(`RPC timeout: ${method} after ${timeout}ms`)); + } + }, timeout); + + ws.addEventListener('open', () => { + ws.send(JSON.stringify({ id: reqId, type: method, params })); + }); + + ws.addEventListener('message', (event) => { + if (settled) return; + try { + const resp: RpcResponse = JSON.parse(String(event.data)); + if (resp.id !== reqId) return; // ignore subscription messages + settled = true; + clearTimeout(timer); + ws.close(); + if (resp.error) { + reject(new Error(`RPC error [${method}]: ${resp.error.message}`)); + } else { + resolve(resp.result); + } + } catch (_e) { + // Not JSON or wrong shape — keep waiting + } + }); + + ws.addEventListener('error', (_event) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(new Error(`WebSocket error connecting to ${wsUrl}`)); + } + }); + + ws.addEventListener('close', () => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(new Error(`WebSocket closed before response for ${method}`)); + } + }); + }); +} + +/** + * Ensure a test user exists and return a JWT for that user. + * + * Idempotent: swallows "already exists" from user.create. + */ +export async function ensureUserAndLogin( + email = 'dev@test.com', + password = 'test123', + config: Ad4mRpcConfig = {}, +): Promise { + // Create user (ignore errors — may already exist) + try { + await rpcCall('user.create', { email, password }, config); + } catch { + // Already exists — fine + } + + // Login + const jwt = await rpcCall('user.login', { email, password }, config); + if (typeof jwt !== 'string' || jwt.length < 10) { + throw new Error(`Login failed: expected JWT string, got ${JSON.stringify(jwt)}`); + } + return jwt.replace(/^"|"$/g, ''); +} + +/** + * Get agent status — confirms the executor runs and an agent exists. + */ +export async function agentStatus(config: Ad4mRpcConfig = {}): Promise<{ + did: string; + isInitialized: boolean; + isUnlocked: boolean; +}> { + const result = await rpcCall('agent.status', {}, config); + return result as { did: string; isInitialized: boolean; isUnlocked: boolean }; +} diff --git a/e2e/identity-module.spec.ts b/e2e/identity-module.spec.ts new file mode 100644 index 000000000..1571cb315 --- /dev/null +++ b/e2e/identity-module.spec.ts @@ -0,0 +1,1014 @@ +/** + * E2E test: WE identity module on the account settings page. + * + * Validates the full stack: executor with identity RPC handlers → ad4m-connect auth → + * WE app boot → Settings account page → identity section rendering. + * + * Screenshots persist to e2e/screenshots/ (gitignored). + * + * Prerequisites: + * - AD4M executor on port 12000 (with --enable-multi-user true, --admin-credential test123) + * - WE served on port 3000 + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { expect, type Page, test } from '@playwright/test'; + +import { type Ad4mRpcConfig, agentStatus, ensureUserAndLogin } from './helpers/ad4m-rpc'; + +// ─── Config from env ───────────────────────────────────────────────────────── + +const AD4M_PORT = Number(process.env.AD4M_PORT ?? 12000); +const AD4M_ADMIN_CREDENTIAL = process.env.AD4M_ADMIN_CREDENTIAL ?? 'test123'; +const WE_URL = process.env.WE_URL ?? 'http://localhost:3000'; +const AD4M_CONNECT_VERSION = process.env.AD4M_CONNECT_VERSION ?? '0.13.0-test-interpretation-2'; +const TEST_EMAIL = process.env.AD4M_TEST_EMAIL ?? 'e2e@test.com'; +const TEST_PASSWORD = process.env.AD4M_TEST_PASSWORD ?? 'test123'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCREENSHOT_DIR = path.join(__dirname, 'screenshots'); + +const rpcConfig: Ad4mRpcConfig = { + wsUrl: `ws://127.0.0.1:${AD4M_PORT}/api/v1/ws`, + token: AD4M_ADMIN_CREDENTIAL, +}; + +// ─── Test data fixtures ───────────────────────────────────────────────────── + +const MOCK_DID = 'did:key:z6MkhAsbW5vwZ5Fdvkk6KPqsk9WWWib3r1oEQzcmjX4q7KnA'; + +const FIXTURES = { + identity: { + did: MOCK_DID, + name: `${MOCK_DID.substring(0, 24)}…`, + agentType: 'human', + recoveryThreshold: 2, + }, + + /** Single device — the local machine that booted. */ + singleDevice: [ + { + id: 'key-local-001', + label: 'Arcadia Desktop', + icon: 'desktop', + type: 'device', + scopeSummary: 'sign, KEL ops, delegate', + active: true, + keyId: 'key-local-001', + signingKey: 'z6Mkr8B4fGqJ7DcVFR3xNp4T5vQwVL9xS2nKjYb7h4eRqVFa', + delegatedAt: 'Sequence #0', + scopes: ['sign', 'KEL ops', 'delegate'], + encryptionKey: 'z6LSkR8B4fGqJ7DcVFR3xNp4T5vQwVL9xS2nKjYb7h4eRqVFz', + }, + ], + + /** Multi-device roster: desktop, laptop, mobile, remote server, assistant. */ + multiDevice: [ + { + id: 'key-desktop-001', + label: 'Arcadia Desktop', + icon: 'desktop', + type: 'device', + scopeSummary: 'sign, KEL ops, delegate', + active: true, + keyId: 'key-desktop-001', + signingKey: 'z6Mkr8B4fGqJ7DcVFR3xNp4T5vQwVL9xS2nKjYb7h4eRqVFa', + delegatedAt: 'Sequence #0', + scopes: ['sign', 'KEL ops', 'delegate'], + encryptionKey: 'z6LSkR8B4fGqJ7DcVFR3xNp4T5vQwVL9xS2nKjYb7h4eRqVFz', + }, + { + id: 'key-laptop-002', + label: 'MacBook Pro', + icon: 'desktop', + type: 'device', + scopeSummary: 'sign, KEL ops', + active: true, + keyId: 'key-laptop-002', + signingKey: 'z6MkvT3c8L2Kq9RwXnYp5A1jD7mE4fGhNs6uBk0wJ8xZtWaR', + delegatedAt: 'Sequence #2', + scopes: ['sign', 'KEL ops'], + encryptionKey: null, + }, + { + id: 'key-mobile-003', + label: 'Galaxy S22', + icon: 'device-mobile', + type: 'device', + scopeSummary: 'sign', + active: true, + keyId: 'key-mobile-003', + signingKey: 'z6MkpQ7Rj4Ws8Lb5Xc2Yd6Nh3Tg0Vf9Ua1Ke4Mj7Pi0So3Rn6', + delegatedAt: 'Sequence #3', + scopes: ['sign'], + encryptionKey: null, + }, + { + id: 'key-remote-004', + label: 'Field Server', + icon: 'cloud', + type: 'device', + scopeSummary: 'sign, KEL ops', + active: true, + keyId: 'key-remote-004', + signingKey: 'z6MktH9wK3Lb5Fc8Qd2Rj6Yn4Xp0Vg7Ua1Me3Nk5Si8To0Wq2', + delegatedAt: 'Sequence #5', + scopes: ['sign', 'KEL ops'], + encryptionKey: 'z6LStH9wK3Lb5Fc8Qd2Rj6Yn4Xp0Vg7Ua1Me3Nk5Si8To0Wq3', + }, + { + id: 'key-assistant-005', + label: 'Hex (AI Assistant)', + icon: 'robot', + type: 'assistant', + scopeSummary: 'sign', + active: true, + keyId: 'key-assistant-005', + signingKey: 'z6MkdF2wL8Hb3Gc9Re4Sj7Tn5Xq1Vg0Ub6Mf8Nk2Pi4So7Wn3', + delegatedAt: 'Sequence #6', + scopes: ['sign'], + encryptionKey: null, + }, + ], + + /** Multi-device with a revoked key. */ + multiDeviceWithRevoked: [ + { + id: 'key-desktop-001', + label: 'Arcadia Desktop', + icon: 'desktop', + type: 'device', + scopeSummary: 'sign, KEL ops, delegate', + active: true, + keyId: 'key-desktop-001', + signingKey: 'z6Mkr8B4fGqJ7DcVFR3xNp4T5vQwVL9xS2nKjYb7h4eRqVFa', + delegatedAt: 'Sequence #0', + scopes: ['sign', 'KEL ops', 'delegate'], + encryptionKey: 'z6LSkR8B4fGqJ7DcVFR3xNp4T5vQwVL9xS2nKjYb7h4eRqVFz', + }, + { + id: 'key-old-laptop', + label: 'Old ThinkPad (compromised)', + icon: 'desktop', + type: 'device', + scopeSummary: 'No permissions', + active: false, + keyId: 'key-old-laptop', + signingKey: 'z6MkxW4tN7Jb2Lc5Qf8Re3Sd6Yn9Tp0Vg1Ua4Mh7Nk0Pi3So6', + delegatedAt: 'Sequence #1', + scopes: [], + encryptionKey: null, + }, + { + id: 'key-mobile-003', + label: 'Galaxy S22', + icon: 'device-mobile', + type: 'device', + scopeSummary: 'sign', + active: true, + keyId: 'key-mobile-003', + signingKey: 'z6MkpQ7Rj4Ws8Lb5Xc2Yd6Nh3Tg0Vf9Ua1Ke4Mj7Pi0So3Rn6', + delegatedAt: 'Sequence #3', + scopes: ['sign'], + encryptionKey: null, + }, + ], + + /** Guardians — all consented. */ + guardiansAllConsented: [ + { name: 'Alice Nakamoto', did: 'did:key:z6MkfA3...bC9q', consented: true }, + { name: 'Bob Chen', did: 'did:key:z6MkgD7...eF2r', consented: true }, + { name: 'Carol Torres', did: 'did:key:z6MkhG1...jK5t', consented: true }, + ], + + /** Guardians — mixed consent (one pending). */ + guardiansMixedConsent: [ + { name: 'Alice Nakamoto', did: 'did:key:z6MkfA3...bC9q', consented: true }, + { name: 'Bob Chen', did: 'did:key:z6MkgD7...eF2r', consented: false }, + { name: 'Carol Torres', did: 'did:key:z6MkhG1...jK5t', consented: true }, + ], + + /** KEL events — full lifecycle. */ + kelEvents: [ + { seqLabel: '#0', type: 'inception', summary: 'Identity created — initial key established' }, + { seqLabel: '#1', type: 'delegate', summary: 'Delegated key-old-laptop (ThinkPad)' }, + { seqLabel: '#2', type: 'delegate', summary: 'Delegated key-laptop-002 (MacBook Pro)' }, + { seqLabel: '#3', type: 'delegate', summary: 'Delegated key-mobile-003 (Galaxy S22)' }, + { seqLabel: '#4', type: 'rotate', summary: 'Revoked key-old-laptop — device compromised' }, + { seqLabel: '#5', type: 'delegate', summary: 'Delegated key-remote-004 (Field Server)' }, + { seqLabel: '#6', type: 'delegate', summary: 'Delegated key-assistant-005 (Hex)' }, + ], + + /** Active recovery state. */ + recoveryActive: { + method: 'guardian', + statusLabel: '1 of 2 guardians approved — waiting for 1 more', + approvals: 1, + threshold: 2, + requestedAt: '2026-09-02T14:30:00Z', + }, + + /** Incoming recovery requests (as a guardian). */ + incomingRecoveryRequests: [ + { id: 'req-001', requesterName: 'Dave Miller', requesterDid: 'did:key:z6MkjL4...mN8v' }, + { id: 'req-002', requesterName: 'Eve Park', requesterDid: 'did:key:z6MkkM5...nP9w' }, + ], +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Inject AD4M credentials into localStorage, bypassing the ad4m-connect interactive auth flow. + * + * Same technique as ad4m-flux-browser-auth.sh — set the version-prefixed keys that ad4m-connect + * reads on init, then reload so it finds them and skips straight to "connected". + */ +async function injectAd4mCredentials(page: Page, jwt: string) { + const executorUrl = `http://127.0.0.1:${AD4M_PORT}`; + + // Clear any stale ad4m keys first + await page.evaluate(() => { + for (let i = localStorage.length - 1; i >= 0; i--) { + const k = localStorage.key(i); + if (k && k.includes('ad4m')) localStorage.removeItem(k); + } + }); + + // Inject credentials for the known version + await page.evaluate( + ({ version, token, url, port }) => { + localStorage.setItem(`${version}/ad4m-token`, token); + localStorage.setItem(`${version}/ad4m-url`, url); + localStorage.setItem(`${version}/ad4m-port`, String(port)); + localStorage.setItem( + `${version}/ad4m-last-host`, + JSON.stringify({ + id: `e2e-${Date.now()}`, + url, + name: '127.0.0.1', + location: 'E2E Test', + }), + ); + }, + { version: AD4M_CONNECT_VERSION, token: jwt, url: executorUrl, port: AD4M_PORT }, + ); +} + +/** + * Detect additional ad4m-connect version prefixes the runtime may have written, + * and re-inject credentials for those too. + * + * ad4m-connect sometimes writes keys under a runtime-detected version that differs from the + * package.json version (bundle caching, esbuild inlining). This catches that mismatch. + */ +async function reinjectForRuntimeVersions(page: Page, jwt: string) { + const executorUrl = `http://127.0.0.1:${AD4M_PORT}`; + + const runtimeVersions: string[] = await page.evaluate((knownVersion) => { + const versions: Record = {}; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.includes('/ad4m-')) { + const ver = k.split('/ad4m-')[0]; + versions[ver] = (versions[ver] || 0) + 1; + } + } + return Object.keys(versions).filter((v) => v !== knownVersion); + }, AD4M_CONNECT_VERSION); + + if (runtimeVersions.length === 0) return false; + + for (const version of runtimeVersions) { + await page.evaluate( + ({ version, token, url, port }) => { + localStorage.setItem(`${version}/ad4m-token`, token); + localStorage.setItem(`${version}/ad4m-url`, url); + localStorage.setItem(`${version}/ad4m-port`, String(port)); + localStorage.setItem( + `${version}/ad4m-last-host`, + JSON.stringify({ + id: `e2e-${Date.now()}`, + url, + name: '127.0.0.1', + location: 'E2E Test', + }), + ); + }, + { version, token: jwt, url: executorUrl, port: AD4M_PORT }, + ); + } + return true; +} + +/** + * Wait for the WE app to finish booting — past the boot screen into the main template. + * + * The app renders a boot screen (BootScreen.schema.ts) until the session initialises, + * then transitions to the template layout with the chrome rail. + */ +async function waitForAppBoot(page: Page, timeoutMs = 30_000) { + // Wait for the page to settle — the app may reload itself during auth + await page.waitForLoadState('networkidle', { timeout: timeoutMs }); + + // Wait for the we-app or main content area to appear + // The app renders custom elements; wait for the chrome rail or any module launcher + await page.waitForFunction( + () => { + // Check for the ad4m-connect element being in authenticated state + const ac = document.querySelector('ad4m-connect') as HTMLElement & { + authState?: string; + connectionState?: string; + }; + if (ac) { + // If ad4m-connect exists and shows authenticated + connected, the app can proceed + if (ac.authState === 'authenticated' && ac.connectionState === 'connected') return true; + // Some builds use a 'core' sub-object + const core = (ac as unknown as { core?: { authState: string; connectionState: string } }).core; + if (core?.authState === 'authenticated' && core?.connectionState === 'connected') return true; + } + + // Alternatively, check whether the main app content loaded (no ad4m-connect visible) + // WE hides ad4m-connect once authenticated and renders the template + const body = document.body.textContent || ''; + // The boot screen shows "Create account" or "Unlock" — if neither appears, + // and we have content, the app booted past that + if (body.length > 100 && !body.includes('Enter the security code') && !body.includes('connection-options')) { + return true; + } + + return false; + }, + { timeout: timeoutMs }, + ); +} + +/** + * Complete the auth flow: inject credentials, reload, handle version mismatch, wait for boot. + */ +async function authenticateAndBoot(page: Page, jwt: string) { + // Navigate to WE — triggers ad4m-connect UI + await page.goto(WE_URL, { waitUntil: 'domcontentloaded' }); + + // Inject credentials into localStorage + await injectAd4mCredentials(page, jwt); + + // Reload so ad4m-connect picks up the injected credentials + await page.reload({ waitUntil: 'domcontentloaded' }); + + // Brief pause for ad4m-connect to read localStorage and attempt connection + await page.waitForTimeout(3000); + + // Check for runtime version mismatch and re-inject if needed + const reinjected = await reinjectForRuntimeVersions(page, jwt); + if (reinjected) { + console.log('Re-injected credentials for runtime version mismatch'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); + } + + // Wait for the app to boot past the auth screen + await waitForAppBoot(page); + + // Dismiss the "What should we call you?" name prompt if it appears + const notNowButton = page.getByRole('button', { name: 'Not now' }); + if (await notNowButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await notNowButton.click(); + await page.waitForTimeout(1500); + } +} + +/** + * Open the Settings page by clicking the gear icon in the sidebar. + * + * The sidebar's Settings rail item renders as a we-button with a we-icon[name="gear"]. + * Clicking it opens the Settings shell view (an overlay/route) with the Account page as default. + */ +async function openSettings(page: Page) { + // The sidebar may need expansion first — dispatch mouseenter on its container. + await page.evaluate(() => { + const candidates = document.querySelectorAll('div'); + for (const el of candidates) { + const style = window.getComputedStyle(el); + if (style.position === 'fixed' && style.left === '0px' && style.top === '0px' && parseInt(style.width) <= 100) { + el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: false })); + break; + } + } + }); + await page.waitForTimeout(800); + + // Click the Settings (gear) icon in the sidebar + const gearButton = page.locator('we-icon[name="gear"]').first(); + await gearButton.waitFor({ state: 'visible', timeout: 5000 }); + await gearButton.click(); + + // Wait for the Settings page to render — it shows the "Settings" heading + await page.waitForFunction( + () => { + const body = document.body.textContent || ''; + return body.includes('Settings') && body.includes('Account'); + }, + { timeout: 10_000 }, + ); + + // Let the page finish rendering + await page.waitForTimeout(1500); +} + +/** + * Wait for the identity store to become available on window.__identityStore. + * + * The store gets exposed by wireIdentityModule after auth — even if identity RPC calls fail + * (fallback data gets set and the hook still runs). + */ +async function waitForIdentityStore(page: Page, timeoutMs = 15_000): Promise { + try { + await page.waitForFunction( + () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__identityStore; + return store && typeof store.setRoster === 'function'; + }, + { timeout: timeoutMs }, + ); + return true; + } catch { + return false; + } +} + +/** + * Inject identity data into the store via page.evaluate. + * + * Solid signals update the DOM reactively — setting data triggers immediate re-render. + */ +async function injectIdentityData( + page: Page, + data: { + identity?: Record; + roster?: Record[]; + guardians?: Record[]; + kelEvents?: Record[]; + recoveryState?: Record | null; + backupConfirmed?: boolean; + incomingRecoveryRequests?: Record[]; + enrolmentOffer?: Record | null; + }, +) { + await page.evaluate((d) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__identityStore; + if (!store) throw new Error('__identityStore not available'); + + if (d.identity !== undefined) store.setIdentity(d.identity); + if (d.roster !== undefined) store.setRoster(d.roster); + if (d.guardians !== undefined) store.setGuardians(d.guardians); + if (d.kelEvents !== undefined) store.setKelEvents(d.kelEvents); + if (d.recoveryState !== undefined) store.setRecoveryState(d.recoveryState); + if (d.backupConfirmed !== undefined) store.setBackupConfirmed(d.backupConfirmed); + if (d.incomingRecoveryRequests !== undefined) store.setIncomingRecoveryRequests(d.incomingRecoveryRequests); + if (d.enrolmentOffer !== undefined) store.setEnrolmentOffer(d.enrolmentOffer); + }, data); + + // Let Solid's reactivity flush DOM updates + await page.waitForTimeout(500); +} + +/** Click a tab in the identity section by its label text. */ +async function clickIdentityTab(page: Page, tabName: string) { + const tab = page.locator('we-tab').filter({ hasText: tabName }); + if (await tab.isVisible({ timeout: 3000 }).catch(() => false)) { + await tab.click(); + await page.waitForTimeout(500); + } +} + +/** Take a screenshot with a descriptive filename. */ +async function screenshot(page: Page, name: string) { + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, `${name}.png`), + fullPage: true, + }); + console.log(`Screenshot: ${name}.png`); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +test.describe('Identity Module', () => { + let jwt: string; + + test.beforeAll(async () => { + // Verify the executor runs and the agent exists + const status = await agentStatus(rpcConfig); + expect(status.isInitialized).toBe(true); + expect(status.isUnlocked).toBe(true); + console.log(`Executor agent: ${status.did}`); + + // Get a JWT for the test user + jwt = await ensureUserAndLogin(TEST_EMAIL, TEST_PASSWORD, rpcConfig); + console.log(`JWT obtained (${jwt.length} chars)`); + }); + + test('renders the identity section on the account settings page', async ({ page }) => { + test.setTimeout(90_000); + + // ── Step 1: Auth + boot ── + await authenticateAndBoot(page, jwt); + + await screenshot(page, '01-we-app-booted'); + + // ── Step 2: Open Settings ── + await openSettings(page); + + await screenshot(page, '02-settings-account-page'); + + // ── Step 3: Verify the identity section rendered ── + const identityVisible = await page.evaluate(() => { + const body = document.body.textContent || ''; + const hasIdentityHeading = body.includes('Identity'); + const hasTabsOrLoading = + body.includes('Devices') || + body.includes('Guardians') || + body.includes('Recovery') || + body.includes('Loading identity'); + return hasIdentityHeading && hasTabsOrLoading; + }); + + expect(identityVisible).toBe(true); + }); + + test('devices tab — single device', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + roster: FIXTURES.singleDevice, + backupConfirmed: false, + }); + + await clickIdentityTab(page, 'Devices'); + await screenshot(page, '10-devices-single'); + }); + + test('devices tab — multiple devices and assistant', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + roster: FIXTURES.multiDevice, + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Devices'); + await screenshot(page, '11-devices-multi-with-assistant'); + }); + + test('devices tab — with revoked key', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + roster: FIXTURES.multiDeviceWithRevoked, + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Devices'); + await screenshot(page, '12-devices-with-revoked'); + }); + + test('device detail view — active device', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + roster: FIXTURES.multiDevice, + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Devices'); + + // Click the first device (Arcadia Desktop) to open detail view + const firstDevice = page.locator('we-icon[name="desktop"]').first(); + if (await firstDevice.isVisible({ timeout: 3000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(800); + } + + await screenshot(page, '13-device-detail-active'); + }); + + test('device detail view — revoked device', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + roster: FIXTURES.multiDeviceWithRevoked, + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Devices'); + + // Select the revoked device programmatically — the second entry + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__identityStore.selectDevice('key-old-laptop'); + }); + await page.waitForTimeout(800); + + await screenshot(page, '14-device-detail-revoked'); + }); + + test('devices tab — QR enrollment view', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + roster: FIXTURES.multiDevice, + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Devices'); + + // Generate a real QR code data URL via the qrcode library loaded in the page + // Since the app bundles qrcode, we simulate what startEnrolment does by setting + // the enrollment offer directly — including a synthetic QR data URL. + const qrDataUrl = await page.evaluate(async () => { + // Build a minimal 1×1 white PNG data URL as a stand-in — the real app uses the + // qrcode library, but for test purposes we generate one inline via canvas. + const canvas = document.createElement('canvas'); + canvas.width = 280; + canvas.height = 280; + const ctx = canvas.getContext('2d')!; + // Draw a simple pattern that looks like a QR code (checkerboard) + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, 280, 280); + ctx.fillStyle = '#000000'; + const cellSize = 10; + for (let y = 0; y < 28; y++) { + for (let x = 0; x < 28; x++) { + // Finder patterns at corners + random-ish data pattern + const isFinder = (x < 7 && y < 7) || (x >= 21 && y < 7) || (x < 7 && y >= 21); + const isData = (x + y) % 3 === 0 || (x * y) % 5 < 2; + if (isFinder || isData) { + ctx.fillRect(x * cellSize, y * cellSize, cellSize, cellSize); + } + } + } + return canvas.toDataURL('image/png'); + }); + + await injectIdentityData(page, { + enrolmentOffer: { + qrDataUrl, + label: 'Device 1725350000000', + publicKey: 'z6MknewDevicePublicKey1234567890abcdefghijklmnop', + challenge: 'a1b2c3d4e5f6', + }, + }); + + // Verify the QR view replaced the device list + const qrVisible = await page.evaluate(() => { + const body = document.body.textContent || ''; + return body.includes('Scan to enroll') && body.includes('Cancel'); + }); + expect(qrVisible).toBe(true); + + // Verify the img element rendered with the data URL + const imgSrc = await page.locator('img[alt="Enrollment QR code"]').getAttribute('src'); + expect(imgSrc).toBeTruthy(); + expect(imgSrc!.startsWith('data:image/png')).toBe(true); + + await screenshot(page, '15-devices-qr-enrollment'); + + // Dismiss the enrollment and verify the device list returns + await injectIdentityData(page, { enrolmentOffer: null }); + await page.waitForTimeout(500); + + const listVisible = await page.evaluate(() => { + const body = document.body.textContent || ''; + return body.includes('Arcadia Desktop') && !body.includes('Scan to enroll'); + }); + expect(listVisible).toBe(true); + + await screenshot(page, '16-devices-after-qr-dismiss'); + }); + + test('guardians tab — all consented with threshold', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + guardians: FIXTURES.guardiansAllConsented, + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Guardians'); + await screenshot(page, '20-guardians-all-consented'); + }); + + test('guardians tab — mixed consent with pending warning', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + guardians: FIXTURES.guardiansMixedConsent, + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Guardians'); + await screenshot(page, '21-guardians-pending-warning'); + }); + + test('guardians tab — empty state', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + guardians: [], + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Guardians'); + await screenshot(page, '22-guardians-empty'); + }); + + test('recovery tab — methods only (no active recovery)', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + guardians: FIXTURES.guardiansAllConsented, + recoveryState: null, + incomingRecoveryRequests: [], + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Recovery'); + await screenshot(page, '30-recovery-methods-only'); + }); + + test('recovery tab — active recovery in progress', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + guardians: FIXTURES.guardiansAllConsented, + recoveryState: FIXTURES.recoveryActive, + incomingRecoveryRequests: [], + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Recovery'); + await screenshot(page, '31-recovery-in-progress'); + }); + + test('recovery tab — incoming requests as guardian', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + guardians: FIXTURES.guardiansAllConsented, + recoveryState: null, + incomingRecoveryRequests: FIXTURES.incomingRecoveryRequests, + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Recovery'); + await screenshot(page, '32-recovery-incoming-requests'); + }); + + test('log tab — populated event log', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + kelEvents: FIXTURES.kelEvents, + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Log'); + await screenshot(page, '40-log-populated'); + }); + + test('log tab — empty state', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + await injectIdentityData(page, { + identity: FIXTURES.identity, + kelEvents: [], + backupConfirmed: true, + }); + + await clickIdentityTab(page, 'Log'); + await screenshot(page, '41-log-empty'); + }); + + test('backup states — nag banner vs secured badge', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + // First: not backed up — nag banner visible + await injectIdentityData(page, { + identity: FIXTURES.identity, + roster: FIXTURES.multiDevice, + backupConfirmed: false, + }); + await screenshot(page, '50-backup-nag-banner'); + + // Then: backed up — "Backup secured" badge, no nag + await injectIdentityData(page, { + backupConfirmed: true, + }); + await screenshot(page, '51-backup-secured'); + }); + + test('full scenario — multi-executor production identity', async ({ page }) => { + test.setTimeout(90_000); + await authenticateAndBoot(page, jwt); + await openSettings(page); + + const storeReady = await waitForIdentityStore(page); + expect(storeReady).toBe(true); + + // Inject a fully populated identity: multi-device, guardians, KEL, backup confirmed + await injectIdentityData(page, { + identity: FIXTURES.identity, + roster: FIXTURES.multiDevice, + guardians: FIXTURES.guardiansAllConsented, + kelEvents: FIXTURES.kelEvents, + recoveryState: null, + incomingRecoveryRequests: [], + backupConfirmed: true, + }); + + // Devices tab (default) + await clickIdentityTab(page, 'Devices'); + await screenshot(page, '60-full-devices'); + + // Guardians tab + await clickIdentityTab(page, 'Guardians'); + await screenshot(page, '61-full-guardians'); + + // Recovery tab + await clickIdentityTab(page, 'Recovery'); + await screenshot(page, '62-full-recovery'); + + // Log tab + await clickIdentityTab(page, 'Log'); + await screenshot(page, '63-full-log'); + + // Device detail — click first device + await clickIdentityTab(page, 'Devices'); + await page.waitForTimeout(300); + + const deviceRow = page.locator('we-icon[name="desktop"]').first(); + if (await deviceRow.isVisible({ timeout: 3000 }).catch(() => false)) { + await deviceRow.click(); + await page.waitForTimeout(800); + } + await screenshot(page, '64-full-device-detail'); + }); + + test('identity RPC handlers respond', async ({ page }) => { + // Navigate and auth + await page.goto(WE_URL, { waitUntil: 'domcontentloaded' }); + await injectAd4mCredentials(page, jwt); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + + const reinjected = await reinjectForRuntimeVersions(page, jwt); + if (reinjected) { + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + } + + await waitForAppBoot(page); + + // Call identity.resolve through the browser's WebSocket connection to the executor. + // This validates the identity RPC handlers registered in identity_ws.rs respond. + // The handler returns an error for unknown DIDs ("identifier not found") — that + // proves routing works. "Unknown type" would mean the handler never registered. + // + // NOTE: This test requires the identity-branch executor (feat/agent-identity-*). + // The Docker production executor lacks these handlers and returns "Unknown type". + // Skip gracefully when running against Docker prod (port 13000). + const resolveResponse = await page.evaluate( + async ({ port }) => { + return new Promise<{ result?: unknown; error?: { code: number; message: string } }>((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/api/v1/ws?token=test123`); + const id = crypto.randomUUID(); + const timer = setTimeout(() => { + ws.close(); + reject(new Error('RPC timeout')); + }, 10_000); + + ws.onopen = () => { + ws.send(JSON.stringify({ id, type: 'identity.resolve', params: { id: 'did:key:test' } })); + }; + ws.onmessage = (e) => { + const msg = JSON.parse(e.data); + if (msg.id !== id) return; + clearTimeout(timer); + ws.close(); + resolve({ result: msg.result, error: msg.error }); + }; + ws.onerror = () => { + clearTimeout(timer); + reject(new Error('WebSocket error')); + }; + }); + }, + { port: AD4M_PORT }, + ); + + // The handler responded (either result or domain-level error). + // "Unknown type" means the identity_ws handlers never registered — skip if so. + if (resolveResponse.error?.message?.includes('Unknown type')) { + console.log('Skipping — executor lacks identity RPC handlers (production build)'); + test.skip(); + return; + } + + if (resolveResponse.error) { + // Domain error (e.g. "identifier not found") — routing works + console.log('identity.resolve returned domain error:', resolveResponse.error.message); + } else { + console.log('identity.resolve returned result:', JSON.stringify(resolveResponse.result)); + } + }); +}); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000..88fa7e672 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * E2E tests for the WE application against a live AD4M executor. + * + * Prerequisites: + * 1. AD4M executor running (port 12000, admin credential "test123") + * 2. WE served (port 3000 — `pnpm run serve` or `vite preview`) + * + * Both are started by the ad4m-devtools launch scripts: + * ad4m-flux-launch.sh --ad4m ../ad4m --flux . --no-flux + * pnpm run serve (or vite preview --port 3000) + * + * Override with env vars: + * AD4M_PORT=12000 AD4M_ADMIN_CREDENTIAL=test123 WE_URL=http://localhost:3000 + */ +export default defineConfig({ + testDir: '.', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + timeout: 60_000, + + reporter: 'html', + + use: { + baseURL: process.env.WE_URL ?? 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'off', // managed explicitly in tests + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/e2e/screenshots/.gitkeep b/e2e/screenshots/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/package.json b/package.json index 4003a6334..4b7ab94ed 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,13 @@ "format": "prettier --write .", "clean:deep": "pnpm clean && rm -rf apps/we-tauri/src-tauri/target apps/we-electron/dist-electron", "lint:css": "stylelint 'packages/**/src/**/*.css' 'apps/*/src/**/*.css' 'packages/**/src/**/*.scss'", - "test:coverage": "pnpm -r --no-bail run test -- --coverage" + "test:coverage": "pnpm -r --no-bail run test -- --coverage", + "test:e2e": "npx playwright test --config e2e/playwright.config.ts" }, "packageManager": "pnpm@10.18.3", "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", "@typescript-eslint/eslint-plugin": "^8.68.0", "@typescript-eslint/parser": "^8.68.0", "eslint": "^10.9.1", diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 087f1a16a..43fc7e2d1 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -49,14 +49,15 @@ "@we/design-utils": "workspace:*", "@we/drag": "workspace:*", "@we/editor": "workspace:*", + "@we/entities": "workspace:*", "@we/globe-widget": "workspace:*", "@we/graph-expanders": "workspace:*", "@we/graph-protocol": "workspace:*", "@we/graph-solid": "workspace:*", - "@we/entities": "workspace:*", "@we/module-call": "workspace:*", "@we/module-globe": "workspace:*", "@we/module-graph": "workspace:*", + "@we/module-identity": "workspace:*", "@we/module-notes": "workspace:*", "@we/module-pocket": "workspace:*", "@we/module-shared": "workspace:*", @@ -73,6 +74,7 @@ "@we/tokens": "workspace:*", "@we/widgets": "workspace:*", "lib0": "^0.2.117", + "qrcode": "^1.5.4", "solid-js": "^1.9.15", "three": "^0.185.1", "y-protocols": "^1.0.7", @@ -81,6 +83,7 @@ "devDependencies": { "@solidjs/testing-library": "^0.8.10", "@types/node": "^26.3.0", + "@types/qrcode": "^1.5.6", "@types/three": "^0.185.4", "@vitest/coverage-v8": "^4.1.11", "@we/backend-inmemory": "workspace:*", diff --git a/packages/app-shell/src/frameworks/solid/providers/BootController.tsx b/packages/app-shell/src/frameworks/solid/providers/BootController.tsx index 2a6b45189..b656897eb 100644 --- a/packages/app-shell/src/frameworks/solid/providers/BootController.tsx +++ b/packages/app-shell/src/frameworks/solid/providers/BootController.tsx @@ -9,6 +9,7 @@ * Renders nothing. */ import { consumeGuestBootTarget } from '@shared/guestLink'; +import { wireIdentityModule } from '@shared/identity'; import { useDatasetStore } from '../stores/DatasetStore'; import { useProfileStore } from '../stores/ProfileStore'; @@ -57,6 +58,16 @@ export function BootController() { const ownDid = session.me()?.did; if (ownDid) profileStore.fetchProfile(ownDid); + // Wire the identity module — connect store signals to the executor's identity RPC handlers. + // Non-blocking: the identity section degrades to a loading spinner until data arrives. + if (ownDid && session.port()) { + const serverUrl = session.serverUrl() ?? `http://localhost:${session.port()}`; + const wsUrl = serverUrl.replace(/^http/, 'ws') + '/api/v1/ws'; + wireIdentityModule({ wsUrl, token: session.token() ?? '' }, ownDid).catch((err) => + console.warn('BootController: identity wiring failed', err), + ); + } + /* Somebody arrived on a guest invite link. diff --git a/packages/app-shell/src/shared/identity/identityRpc.ts b/packages/app-shell/src/shared/identity/identityRpc.ts new file mode 100644 index 000000000..601feb5ed --- /dev/null +++ b/packages/app-shell/src/shared/identity/identityRpc.ts @@ -0,0 +1,48 @@ +/** + * Lightweight WS-RPC client for AD4M identity handlers. + * + * Identity handlers use custom message types (`identity.resolve`, `identity.roster`, etc.) + * outside the GraphQL schema. This helper speaks the executor's WebSocket protocol. + */ + +export interface IdentityRpcConfig { + wsUrl: string; // e.g. 'ws://localhost:12000/api/v1/ws' + token: string; +} + +/** + * Send a single identity RPC call and return the result. + * + * Opens a dedicated WebSocket per call — acceptable because identity data loads once at boot + * and actions (export, revoke) fire rarely. + */ +export async function identityRpc( + config: IdentityRpcConfig, + method: string, + params: Record = {}, +): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${config.wsUrl}?token=${config.token}`); + const id = crypto.randomUUID(); + const timer = setTimeout(() => { + ws.close(); + reject(new Error(`Identity RPC timeout: ${method}`)); + }, 15_000); + + ws.onopen = () => { + ws.send(JSON.stringify({ id, type: method, params })); + }; + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string); + if (msg.id !== id) return; + clearTimeout(timer); + ws.close(); + if (msg.error) reject(new Error(msg.error.message ?? `RPC error: ${method}`)); + else resolve(msg.result as T); + }; + ws.onerror = () => { + clearTimeout(timer); + reject(new Error(`WebSocket error during ${method}`)); + }; + }); +} diff --git a/packages/app-shell/src/shared/identity/index.ts b/packages/app-shell/src/shared/identity/index.ts new file mode 100644 index 000000000..d016d9fb7 --- /dev/null +++ b/packages/app-shell/src/shared/identity/index.ts @@ -0,0 +1,2 @@ +export { identityRpc, type IdentityRpcConfig } from './identityRpc'; +export { wireIdentityModule } from './wireIdentityModule'; diff --git a/packages/app-shell/src/shared/identity/wireIdentityModule.ts b/packages/app-shell/src/shared/identity/wireIdentityModule.ts new file mode 100644 index 000000000..dedd79f1e --- /dev/null +++ b/packages/app-shell/src/shared/identity/wireIdentityModule.ts @@ -0,0 +1,310 @@ +/** + * Host-side wiring for the identity module. + * + * Fetches identity data from the executor's identity RPC handlers, transforms the responses + * into the shapes the Settings UI expects, and pushes them into the module's reactive store. + * Also wires the stub actions (export, revoke, backup, enrolment) to real RPC calls. + * + * Called from BootController after auth, when the connection details and agent DID are available. + * The identity module's store already exists at that point — it was created synchronously during + * module registration in PlatformProvider. + */ +import QRCode from 'qrcode'; + +import { moduleStores } from '../registries/moduleRegistry'; +import { identityRpc, type IdentityRpcConfig } from './identityRpc'; + +// ─── Response types (executor shapes) ────────────────────────────────────── + +interface ResolvedIdentity { + did: string; + validity: string; + keyState?: { + headSeq: number; + agentType: string; + validKeys: Array<{ + id: string; + signingKey: string; + encryptionKey: string | null; + scope: { sign: boolean; kelOps: boolean; delegate: boolean }; + }>; + }; +} + +interface RosterEntry { + key: { + id: string; + signingKey: string; + encryptionKey: string | null; + scope: { sign: boolean; kelOps: boolean; delegate: boolean }; + }; + label: string | null; + lane: string | null; + enrolledAtSeq: number; + active: boolean; + revokedAtSeq: number | null; +} + +interface KelEvent { + seq: number; + type: string; + summary: string; + signedBy: string; + raw: string; +} + +// ─── Transformers ────────────────────────────────────────────────────────── + +/** Map the Rust Lane enum's debug string to a UI type and icon. */ +function laneToUi(lane: string | null): { type: string; icon: string } { + switch (lane) { + case 'LocalDevice': + return { type: 'device', icon: 'desktop' }; + case 'MobileDevice': + return { type: 'device', icon: 'device-mobile' }; + case 'RemoteDevice': + return { type: 'device', icon: 'cloud' }; + case 'Assistant': + return { type: 'assistant', icon: 'robot' }; + default: + return { type: 'device', icon: 'desktop' }; + } +} + +/** Format a scope object into a human-readable summary. */ +function formatScope(scope: { sign?: boolean; kelOps?: boolean; delegate?: boolean }): string { + const parts: string[] = []; + if (scope.sign) parts.push('sign'); + if (scope.kelOps) parts.push('KEL ops'); + if (scope.delegate) parts.push('delegate'); + return parts.length ? parts.join(', ') : 'No permissions'; +} + +/** Convert scope flags to an array of labels for tag display. */ +function scopeToArray(scope: { sign?: boolean; kelOps?: boolean; delegate?: boolean }): string[] { + const out: string[] = []; + if (scope.sign) out.push('sign'); + if (scope.kelOps) out.push('KEL ops'); + if (scope.delegate) out.push('delegate'); + return out; +} + +/** Transform a raw roster entry from the executor into the shape the UI reads. */ +function transformRosterEntry(raw: RosterEntry): Record { + const lane = laneToUi(raw.lane); + const scope = raw.key?.scope ?? { sign: false, kelOps: false, delegate: false }; + + return { + id: raw.key?.id ?? '', + label: raw.label ?? raw.key?.id?.substring(0, 16) ?? 'Unknown device', + icon: lane.icon, + type: lane.type, + scopeSummary: formatScope(scope), + active: raw.active ?? false, + keyId: raw.key?.id ?? '', + signingKey: raw.key?.signingKey ?? '', + delegatedAt: `Sequence #${raw.enrolledAtSeq ?? '?'}`, + scopes: scopeToArray(scope), + encryptionKey: raw.key?.encryptionKey ?? null, + }; +} + +/** Transform a raw KEL event into the shape the UI reads. */ +function transformKelEvent(raw: KelEvent): Record { + return { + seqLabel: `#${raw.seq ?? '?'}`, + type: raw.type ?? 'unknown', + summary: raw.summary ?? '', + }; +} + +// ─── Wiring ──────────────────────────────────────────────────────────────── + +/** + * Connect the identity module's store to the executor's identity RPC handlers. + * + * Fetches all identity data, transforms it, and pushes it into the module's signals. + * Replaces the stub actions with real RPC-backed implementations. + * + * Returns a cleanup function (currently a no-op — kept for future subscription teardown). + */ +export async function wireIdentityModule(config: IdentityRpcConfig, agentDid: string): Promise<() => void> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (moduleStores as Record).identity; + if (!store) { + console.warn('wireIdentityModule: identity module not registered, skipping'); + return () => {}; + } + + // ── Fetch and populate data ── + // Each call is independent — one failing should not block the others. + + // Identity (resolve) + try { + const resolved = await identityRpc(config, 'identity.resolve', { id: agentDid }); + store.setIdentity({ + did: resolved.did ?? agentDid, + name: resolved.did ? `${resolved.did.substring(0, 24)}…` : agentDid, + agentType: resolved.keyState?.agentType ?? 'human', + }); + } catch (err) { + console.warn('wireIdentityModule: identity.resolve failed, setting fallback', err); + store.setIdentity({ + did: agentDid, + name: `${agentDid.substring(0, 24)}…`, + agentType: 'human', + }); + } + + // Roster + try { + const roster = await identityRpc(config, 'identity.roster', { did: agentDid }); + store.setRoster((roster ?? []).map(transformRosterEntry)); + } catch (err) { + console.warn('wireIdentityModule: identity.roster failed', err); + store.setRoster([]); + } + + // KEL events + try { + const events = await identityRpc(config, 'identity.kelEvents', { did: agentDid }); + store.setKelEvents((events ?? []).map(transformKelEvent)); + } catch (err) { + console.warn('wireIdentityModule: identity.kelEvents failed', err); + store.setKelEvents([]); + } + + // Guardians (currently returns [] from executor) + try { + const guardians = await identityRpc(config, 'identity.guardians', { did: agentDid }); + store.setGuardians(guardians ?? []); + } catch (err) { + console.warn('wireIdentityModule: identity.guardians failed', err); + store.setGuardians([]); + } + + // Recovery state (currently returns null from executor) + try { + const state = await identityRpc(config, 'identity.recoveryState', { did: agentDid }); + store.setRecoveryState(state ?? null); + } catch (err) { + console.warn('wireIdentityModule: identity.recoveryState failed', err); + store.setRecoveryState(null); + } + + // ── Wire actions ── + + /** Helper: refetch the roster and update the store. */ + async function refreshRoster(): Promise { + try { + const roster = await identityRpc(config, 'identity.roster', { did: agentDid }); + store.setRoster((roster ?? []).map(transformRosterEntry)); + } catch { + /* keep current state on refresh failure */ + } + } + + store.exportKel = async () => { + try { + const kel = await identityRpc(config, 'identity.exportKel', { did: agentDid }); + const text = typeof kel === 'string' ? kel : JSON.stringify(kel, null, 2); + const blob = new Blob([text], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `kel-export-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + console.error('wireIdentityModule: exportKel failed', err); + } + }; + + store.revokeKey = async (keyId: unknown) => { + try { + await identityRpc(config, 'identity.revokeKey', { did: agentDid, keyId: keyId as string }); + await refreshRoster(); + } catch (err) { + console.error('wireIdentityModule: revokeKey failed', err); + } + }; + + store.startBackup = async () => { + try { + const mnemonic = await identityRpc(config, 'identity.generateMnemonic'); + // For now, show the mnemonic in an alert. A proper backup ceremony UI comes later. + if (typeof mnemonic === 'string' && mnemonic.length > 0) { + window.alert(`Recovery phrase (write this down securely):\n\n${mnemonic}`); + await identityRpc(config, 'identity.confirmMnemonicBackup'); + store.setBackupConfirmed(true); + } + } catch (err) { + console.error('wireIdentityModule: startBackup failed', err); + } + }; + + store.startEnrolment = async () => { + try { + const offer = await identityRpc<{ publicKey: string; label: string; challenge: string }>( + config, + 'identity.createEnrolOffer', + { label: `Device ${Date.now()}` }, + ); + + // Encode the offer as a QR code data URL. The payload contains everything the + // scanning device needs to complete enrollment: executor URL and offer credentials. + const payload = JSON.stringify({ + type: 'adam-enrol', + executor: config.wsUrl.replace(/\?.*$/, ''), + offer: { publicKey: offer.publicKey, challenge: offer.challenge, label: offer.label }, + }); + const qrDataUrl = await QRCode.toDataURL(payload, { + width: 280, + margin: 2, + errorCorrectionLevel: 'M', + color: { dark: '#000000', light: '#ffffff' }, + }); + + store.setEnrolmentOffer({ + qrDataUrl, + label: offer.label, + publicKey: offer.publicKey, + challenge: offer.challenge, + }); + } catch (err) { + console.error('wireIdentityModule: startEnrolment failed', err); + } + }; + + // Actions that require wallet signing — not yet implemented in the executor. + store.startMnemonicRecovery = () => { + console.warn('Mnemonic recovery requires wallet signing — not yet available.'); + }; + store.startGuardianRecovery = () => { + console.warn('Guardian recovery requires wallet signing — not yet available.'); + }; + store.vetoRecovery = () => { + console.warn('Veto recovery requires wallet signing — not yet available.'); + }; + store.approveRecovery = (_requestId: unknown) => { + console.warn('Approve recovery requires wallet signing — not yet available.'); + }; + store.addGuardian = () => { + console.warn('Adding guardians requires wallet signing — not yet available.'); + }; + + // Expose the store for e2e tests — lets Playwright inject data via page.evaluate. + // The setters are Solid signals closured inside createStore; without this, no external + // code can reach them. Guarded to window so SSR builds skip it. + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__identityStore = store; + } + + console.info('wireIdentityModule: identity module wired successfully'); + return () => { + /* Future: tear down subscriptions */ + }; +} diff --git a/packages/app-shell/src/shared/registries/bundledModules.ts b/packages/app-shell/src/shared/registries/bundledModules.ts index f392f1028..ce51bd50e 100644 --- a/packages/app-shell/src/shared/registries/bundledModules.ts +++ b/packages/app-shell/src/shared/registries/bundledModules.ts @@ -12,6 +12,7 @@ import { callModule } from '@we/module-call'; import { createGlobeModule } from '@we/module-globe'; import { createGraphModule } from '@we/module-graph'; +import { identityModule } from '@we/module-identity'; import { notesModule } from '@we/module-notes'; import { pocketModule } from '@we/module-pocket'; import type { ModuleDefinition, ModuleStoreDeps } from '@we/module-shared'; @@ -51,6 +52,9 @@ export const bundledModules: Record = { // Hears the call without either module referencing the other — the host routes the stream from // whichever module declares `audioSource`. See `@we/module-transcribe`. transcribe: () => transcribeModule, + // Agent-scoped: your DID, enrolled devices, guardians, recovery, and KEL. No host components + // needed — the module uses schema fragments exclusively. + identity: () => identityModule, }; export interface ModuleActivation { diff --git a/packages/module-system/identity/package.json b/packages/module-system/identity/package.json new file mode 100644 index 000000000..bcadd16d9 --- /dev/null +++ b/packages/module-system/identity/package.json @@ -0,0 +1,36 @@ +{ + "name": "@we/module-identity", + "version": "0.1.0", + "description": "Identity module — agent-scoped DID, devices, guardians, recovery, KEL", + "license": "MIT", + "type": "module", + "scripts": { + "build:steps": "tsup", + "build": "we-build", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "peerDependencies": { + "@we/schema-shared": "workspace:*", + "@we/module-shared": "workspace:*" + }, + "devDependencies": { + "@we/cli": "workspace:*", + "@we/schema-shared": "workspace:*", + "@we/module-shared": "workspace:*", + "tsup": "^8.5.1", + "typescript": "^5.7.2" + } +} diff --git a/packages/module-system/identity/src/index.ts b/packages/module-system/identity/src/index.ts new file mode 100644 index 000000000..73ac70751 --- /dev/null +++ b/packages/module-system/identity/src/index.ts @@ -0,0 +1,183 @@ +/** + * The Identity module — your DID, devices, guardians, recovery, and event log. + * + * Agent-scoped: registered once per person, not tied to any space. + * + * ## Where UI lives + * + * The Settings account page (`IdentitySettings.schema.ts` in the shell template) renders the + * identity UI. It reads store signals via `$: 'modules.identity.'` — no cross-package + * import, just runtime store-path resolution. + * + * ## Where state lives + * + * - **Identity data** — signals on the store (`identity`, `roster`, `guardians`, `kelEvents`, + * `recoveryState`), populated by the host's wiring to the identity RPC client after auth. + * - **Tab / detail selection** — `$localState` in the Settings schema, local to the component. + * + * ## Data flow + * + * The store exposes data signals (starting empty/null) and setters. The host wiring + * (`wireIdentityModule` in the app-shell) fetches from the executor's identity RPC handlers, + * transforms the responses, and pushes them into the setters. Action stubs (`revokeKey`, + * `exportKel`, etc.) get replaced with real RPC-backed implementations by the same wiring. + */ +import { defineModule, type ModuleStoreDeps } from '@we/module-shared'; + +// ─── Module definition ─────────────────────────────────────────────────────── + +export const identityModule = defineModule({ + id: 'identity', + name: 'Identity', + description: 'Your DID, enrolled devices, guardians, recovery, and event log.', + icon: 'fingerprint', + + /** + * Agent-scoped — identity data belongs to the person, not a community. + * + * The store provides identity data to the Settings account page. No launcher, no dock — + * the UI lives in the shell's Settings template, referencing `modules.identity.*` signals. + */ + scope: 'agent', + + createStore: ({ signal }: ModuleStoreDeps) => { + type R = Record; + + // ── Identity data ── + // Populated by the host's wiring to the identity client when it connects. + // Each starts empty; the schema fragments handle the loading state via $if guards. + + /** The resolved identity — DID, display name, agent type. */ + const [identity, setIdentity] = signal(null); + /** All enrolled devices, executors, and assistants. */ + const [roster, setRoster] = signal([]); + /** Guardian entries with consent status. */ + const [guardians, setGuardians] = signal([]); + /** KEL event log. */ + const [kelEvents, setKelEvents] = signal([]); + /** Active recovery request state, or null when no recovery runs. */ + const [recoveryState, setRecoveryState] = signal(null); + /** Whether the mnemonic backup has been confirmed. */ + const [backupConfirmed, setBackupConfirmed] = signal(false); + /** Incoming recovery requests from people this agent guards. */ + const [incomingRecoveryRequests, setIncomingRecoveryRequests] = signal([]); + /** Active enrollment offer — holds { qrDataUrl, label, publicKey, challenge } or null. */ + const [enrolmentOffer, setEnrolmentOffer] = signal(null); + + /** The currently selected device for the detail view, or null. */ + const [selectedDeviceId, setSelectedDeviceId] = signal(null); + + return { + // ── Identity data (read by schema fragments) ── + identity, + roster, + guardians, + kelEvents, + recoveryState, + backupConfirmed, + incomingRecoveryRequests, + enrolmentOffer, + + // ── Derived values ── + /** Roster entries of type 'device' or 'executor'. */ + devices: () => roster().filter((e: R) => e.type !== 'assistant'), + /** Roster entries of type 'assistant'. */ + assistants: () => roster().filter((e: R) => e.type === 'assistant'), + /** Count labels for section headers. */ + deviceCount: () => `${roster().filter((e: R) => e.type !== 'assistant').length}`, + assistantCount: () => `${roster().filter((e: R) => e.type === 'assistant').length}`, + guardianCount: () => `${guardians().length}`, + + /** Whether any guardian has not yet consented. */ + pendingGuardians: () => guardians().some((g: R) => !g.consented), + + /** Threshold label like "2/3". */ + thresholdLabel: () => { + const gs = guardians(); + if (!gs.length) return ''; + const threshold = (identity() as Record | null)?.recoveryThreshold; + return `${threshold ?? '?'}/${gs.length}`; + }, + /** Threshold description like "2 of 3 guardians needed to recover". */ + thresholdDescription: () => { + const gs = guardians(); + const threshold = (identity() as Record | null)?.recoveryThreshold; + return `${threshold ?? '?'} of ${gs.length} guardians needed to recover`; + }, + /** Guardian recovery button label. */ + guardianRecoveryLabel: () => { + const gs = guardians(); + const threshold = (identity() as Record | null)?.recoveryThreshold; + return `Ask ${threshold ?? '?'} of your ${gs.length} guardians to approve recovery`; + }, + + /** The full detail of the currently selected device. */ + selectedDevice: () => { + const id = selectedDeviceId(); + if (!id) return null; + return roster().find((e: R) => e.id === id) ?? null; + }, + + // ── Device selection ── + selectedDeviceId, + selectDevice: (id: unknown) => setSelectedDeviceId(id as string), + clearSelection: () => setSelectedDeviceId(null), + + // ── Actions ── + /** Copy the DID to clipboard. */ + copyDid: () => { + const id = identity(); + const did = id ? (id as Record).did : null; + if (did && typeof navigator !== 'undefined' && navigator.clipboard) { + navigator.clipboard.writeText(did as string).catch(() => { + /* clipboard unavailable — silent */ + }); + } + }, + + // ── Actions — wired by the host's identity client integration ── + // The host replaces these stubs with real RPC-backed implementations after auth. + // Until then they degrade gracefully — a click does nothing visible. + revokeKey: (_keyId: unknown) => { + /* Wired by the host — revokes a key and refreshes the roster. */ + }, + exportKel: () => { + /* Wired by the host — downloads KEL as JSON file. */ + }, + startMnemonicRecovery: () => { + /* Wired by the host — opens the mnemonic recovery ceremony. */ + }, + startGuardianRecovery: () => { + /* Wired by the host — opens the guardian recovery ceremony. */ + }, + vetoRecovery: () => { + /* Wired by the host — vetoes the active recovery request. */ + }, + approveRecovery: (_requestId: unknown) => { + /* Wired by the host — approves an incoming recovery request. */ + }, + startBackup: () => { + /* Wired by the host — begins the mnemonic backup ceremony. */ + }, + startEnrolment: () => { + /* Wired by the host — creates an enrolment offer and generates a QR code. */ + }, + dismissEnrolment: () => { + setEnrolmentOffer(null); + }, + addGuardian: () => { + /* Wired by the host — begins the guardian addition flow. */ + }, + + // ── Data setters (called by the host's identity client wiring) ── + setIdentity, + setRoster, + setGuardians, + setKelEvents, + setRecoveryState, + setBackupConfirmed, + setIncomingRecoveryRequests, + setEnrolmentOffer, + }; + }, +}); diff --git a/packages/module-system/identity/tsconfig.json b/packages/module-system/identity/tsconfig.json new file mode 100644 index 000000000..c8c92cbd6 --- /dev/null +++ b/packages/module-system/identity/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/module-system/identity/tsup.config.ts b/packages/module-system/identity/tsup.config.ts new file mode 100644 index 000000000..3540b2015 --- /dev/null +++ b/packages/module-system/identity/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + target: 'es2022', + splitting: false, + treeshake: true, + external: ['@we/schema-shared', '@we/module-shared'], +}); diff --git a/packages/templates/shell/src/IdentitySettings.schema.ts b/packages/templates/shell/src/IdentitySettings.schema.ts new file mode 100644 index 000000000..b73db5b8e --- /dev/null +++ b/packages/templates/shell/src/IdentitySettings.schema.ts @@ -0,0 +1,960 @@ +/** + * Identity settings — devices, guardians, recovery, and event log. + * + * Rendered on the Account page of Settings. The identity module provides the data store + * (`modules.identity.*` signals); this file provides the layout. No import from the module + * package — store paths resolve at runtime because the module has `scope: 'agent'` and + * always registers. + * + * ## Why this duplicates the dock panel's layout + * + * The dock panel had its own `$localState` and its own container (`panel` in the module source). + * Settings embeds the same content differently: no panel header, no dock frame, no toggle — + * just sections in a scrolling page. Sharing schema fragments across packages would require a + * cross-package import that the dependency graph should not carry (templates do not depend on + * specific modules), so the layout lives here and the data lives in the module. + */ +import type { SchemaNode } from '@we/schema-shared'; + +// ─── Reusable fragment builders ────────────────────────────────────────────── + +/** One device or assistant in the roster list. */ +const rosterEntry: SchemaNode = { + type: 'Row', + props: { + bg: 'surface-raised', + r: '300', + p: '300', + gap: '300', + ay: 'center', + cursor: 'pointer', + onClick: { $action: 'modules.identity.selectDevice', args: [{ $: 'entry.id' }] }, + }, + children: [ + { type: 'we-icon', props: { name: { $: 'entry.icon' }, size: 'md' } }, + { + type: 'Column', + props: { gap: '50', flex: '1', overflow: 'hidden' }, + children: [ + { + type: 'we-text', + props: { variant: 'body-sm', fontWeight: '500' }, + children: [{ $: 'entry.label' }], + }, + { + type: 'we-text', + props: { variant: 'caption', color: 'text-muted' }, + children: [{ $: 'entry.scopeSummary' }], + }, + ], + }, + { + type: '$if', + props: { + condition: { $: 'entry.active' }, + then: { type: 'we-badge', props: { variant: 'success', size: 'xs' }, children: ['Active'] }, + else: { type: 'we-badge', props: { variant: 'danger', size: 'xs' }, children: ['Revoked'] }, + }, + }, + { type: 'we-icon', props: { name: 'caret-right', size: 'sm', color: 'text-dim' } }, + ], +}; + +/** A guardian entry — avatar, name, truncated DID, consent status. */ +const guardianEntry: SchemaNode = { + type: 'Row', + props: { bg: 'surface-raised', r: '300', p: '300', gap: '300', ay: 'center' }, + children: [ + { type: 'we-avatar', props: { name: { $: 'guardian.name' }, size: 'sm' } }, + { + type: 'Column', + props: { gap: '50', flex: '1', overflow: 'hidden' }, + children: [ + { + type: 'we-text', + props: { variant: 'body-sm', fontWeight: '500' }, + children: [{ $: 'guardian.name' }], + }, + { + type: 'we-text', + props: { variant: 'caption', color: 'text-muted', truncate: true }, + children: [{ $: 'guardian.did' }], + }, + ], + }, + { + type: '$if', + props: { + condition: { $: 'guardian.consented' }, + then: { type: 'we-tag', props: { variant: 'success' }, children: ['Consented'] }, + else: { type: 'we-tag', props: { variant: 'warning' }, children: ['Pending'] }, + }, + }, + ], +}; + +/** A single KEL event row. */ +const kelEventRow: SchemaNode = { + type: 'Row', + props: { gap: '300', py: '200', borderBottom: '1px solid var(--we-color-border)' }, + children: [ + { + type: 'we-text', + props: { + variant: 'caption', + color: 'text-dim', + fontFamily: 'mono', + width: '28px', + textAlign: 'right', + flex: '0 0 auto', + }, + children: [{ $: 'kelEvent.seqLabel' }], + }, + { + type: 'Column', + props: { gap: '50', flex: '1' }, + children: [ + { + type: 'we-text', + props: { variant: 'caption', fontWeight: '600', color: 'accent' }, + children: [{ $: 'kelEvent.type' }], + }, + { + type: 'we-text', + props: { variant: 'caption', color: 'text-muted' }, + children: [{ $: 'kelEvent.summary' }], + }, + ], + }, + ], +}; + +/** A labelled detail field. */ +function detailField(label: string, value: unknown, mono = false): SchemaNode { + return { + type: 'Column', + props: { gap: '100' }, + children: [ + { + type: 'we-text', + props: { + variant: 'caption', + color: 'text-muted', + textTransform: 'uppercase', + letterSpacing: '0.5px', + }, + children: [label], + }, + { + type: 'we-text', + props: { + variant: 'body-sm', + ...(mono ? { fontFamily: 'mono', wordBreak: 'break-all' } : {}), + }, + children: [value as string], + }, + ], + }; +} + +/** One bullet point in the revocation consequences list. */ +function revokeConsequence(text: string): SchemaNode { + return { + type: 'Row', + props: { gap: '200', pl: '200' }, + children: [ + { type: 'we-text', props: { variant: 'caption', color: 'text-dim' }, children: ['•'] }, + { type: 'we-text', props: { variant: 'caption', color: 'text-muted' }, children: [text] }, + ], + }; +} + +/** Section header with optional count badge. */ +const sectionHeader = (title: string, countExpr?: string): SchemaNode => ({ + type: 'Row', + props: { ay: 'center', pt: '400', pb: '200' }, + children: [ + { + type: 'we-text', + props: { + variant: 'caption', + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: '0.5px', + color: 'text-muted', + flex: '1', + }, + children: [title], + }, + ...(countExpr + ? [ + { + type: 'we-badge' as const, + props: { variant: 'neutral' as const, size: 'xs' as const }, + children: [{ $: countExpr }], + }, + ] + : []), + ], +}); + +/** A recovery method button. */ +function recoveryMethod(icon: string, title: string, description: string, action: string): SchemaNode { + return { + type: 'Row', + props: { + bg: 'surface', + r: '300', + p: '300', + gap: '300', + ay: 'center', + cursor: 'pointer', + border: '1px solid var(--we-color-border)', + onClick: { $action: action }, + }, + children: [ + { type: 'we-icon', props: { name: icon, size: 'md' } }, + { + type: 'Column', + props: { gap: '50', flex: '1' }, + children: [ + { type: 'we-text', props: { variant: 'body-sm', fontWeight: '500' }, children: [title] }, + { type: 'we-text', props: { variant: 'caption', color: 'text-muted' }, children: [description] }, + ], + }, + { type: 'we-icon', props: { name: 'caret-right', size: 'sm', color: 'text-dim' } }, + ], + }; +} + +// ─── Device detail ────────────────────────────────────────────────────────── + +const deviceDetail: SchemaNode = { + type: 'Column', + props: { gap: '400' }, + children: [ + // Back navigation + { + type: 'Row', + props: { + gap: '200', + ay: 'center', + cursor: 'pointer', + onClick: { $action: 'modules.identity.clearSelection' }, + }, + children: [ + { type: 'we-icon', props: { name: 'arrow-left', size: 'sm', color: 'text-muted' } }, + { type: 'we-text', props: { variant: 'body-sm', color: 'text-muted' }, children: ['Back'] }, + ], + }, + // Detail card + { + type: 'Column', + props: { bg: 'surface-raised', r: '400', p: '400', gap: '400' }, + children: [ + detailField('Label', { $: 'modules.identity.selectedDevice.label' }), + detailField('Key ID', { $: 'modules.identity.selectedDevice.keyId' }, true), + detailField('Signing key', { $: 'modules.identity.selectedDevice.signingKey' }, true), + detailField('Delegated at', { $: 'modules.identity.selectedDevice.delegatedAt' }), + { + type: 'Column', + props: { gap: '100' }, + children: [ + { + type: 'we-text', + props: { + variant: 'caption', + color: 'text-muted', + textTransform: 'uppercase', + letterSpacing: '0.5px', + }, + children: ['Scope'], + }, + { + type: 'Row', + props: { gap: '200', flexWrap: 'wrap' }, + children: [ + { + type: '$each', + props: { items: { $: 'modules.identity.selectedDevice.scopes' }, as: 'scope' }, + children: [{ type: 'we-tag', props: { variant: 'neutral' }, children: [{ $: 'scope' }] }], + }, + ], + }, + ], + }, + { + type: '$if', + props: { + condition: { $: 'modules.identity.selectedDevice.encryptionKey' }, + then: detailField('Encryption key', { $: 'modules.identity.selectedDevice.encryptionKey' }, true), + }, + }, + ], + }, + // Revocation section + { + type: '$if', + props: { + condition: { $: 'modules.identity.selectedDevice.active' }, + then: { + type: 'Column', + props: { + bg: 'surface-raised', + r: '400', + p: '400', + gap: '300', + border: '1px solid var(--we-color-danger-surface)', + }, + children: [ + { + type: 'we-text', + props: { variant: 'body-sm', fontWeight: '600', color: 'danger' }, + children: ['Revoke this key'], + }, + revokeConsequence('Everything this key signed until now stays valid.'), + revokeConsequence('This key can no longer sign anything new as you.'), + revokeConsequence('You cannot undo a revocation.'), + { + type: 'we-button', + props: { + variant: 'danger', + size: 'sm', + onClick: { + $action: 'modules.identity.revokeKey', + args: [{ $: 'modules.identity.selectedDevice.id' }], + }, + }, + children: ['Revoke key'], + }, + ], + }, + }, + }, + ], +}; + +// ─── QR enrollment view ───────────────────────────────────────────────────── + +/** Shown when an enrollment offer exists — QR code for the new device to scan. */ +const enrolmentQrView: SchemaNode = { + type: 'Column', + props: { gap: '400', ay: 'center', py: '300' }, + children: [ + { type: 'we-text', props: { variant: 'body-sm', fontWeight: '600' }, children: ['Scan to enroll'] }, + { + type: 'we-text', + props: { variant: 'caption', color: 'text-muted', textAlign: 'center' }, + children: ['Open your identity app on the new device and scan this code.'], + }, + // QR code image — data URL from the qrcode library + { + type: 'Column', + props: { + ay: 'center', + ax: 'center', + bg: 'white', + r: '400', + p: '300', + }, + children: [ + { + type: 'img', + props: { + src: { $: 'modules.identity.enrolmentOffer.qrDataUrl' }, + alt: 'Enrollment QR code', + width: '280', + height: '280', + }, + }, + ], + }, + // Offer details + { + type: 'Column', + props: { gap: '100', ay: 'center' }, + children: [ + { + type: 'we-text', + props: { variant: 'caption', color: 'text-muted' }, + children: [{ $: 'modules.identity.enrolmentOffer.label' }], + }, + { + type: 'we-text', + props: { variant: 'caption', color: 'text-dim', fontFamily: 'mono', fontSize: '10px' }, + children: [{ $: 'modules.identity.enrolmentOffer.publicKey' }], + }, + ], + }, + // Cancel button + { + type: 'we-button', + props: { + variant: 'ghost', + size: 'sm', + onClick: { $action: 'modules.identity.dismissEnrolment' }, + }, + children: ['Cancel'], + }, + ], +}; + +// ─── Tab content ──────────────────────────────────────────────────────────── + +/** Devices tab — QR enrollment view, device detail, or roster overview. */ +const devicesTab: SchemaNode = { + type: '$if', + props: { + condition: { $: 'modules.identity.enrolmentOffer' }, + then: enrolmentQrView, + else: { + type: '$if', + props: { + condition: { $: 'modules.identity.selectedDeviceId' }, + then: deviceDetail, + else: { + type: 'Column', + props: { gap: '300' }, + children: [ + // Devices + sectionHeader('Devices', 'modules.identity.deviceCount'), + { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: '$each', + props: { items: { $: 'modules.identity.devices' }, as: 'entry' }, + children: [rosterEntry], + }, + ], + }, + // Assistants + { + type: '$if', + props: { + condition: { $: 'modules.identity.assistants.length' }, + then: { + type: 'Column', + props: { gap: '200' }, + children: [ + sectionHeader('Assistants', 'modules.identity.assistantCount'), + { + type: '$each', + props: { items: { $: 'modules.identity.assistants' }, as: 'entry' }, + children: [rosterEntry], + }, + ], + }, + }, + }, + // Add button + { + type: 'we-button', + props: { + variant: 'ghost', + size: 'sm', + width: '100%', + onClick: { $action: 'modules.identity.startEnrolment' }, + }, + children: [{ type: 'we-icon', props: { name: 'plus' } }, ' Add device or assistant'], + }, + ], + }, + }, + }, + }, +}; + +/** Guardians tab — threshold, guardian list, add, warnings. */ +const guardiansTab: SchemaNode = { + type: 'Column', + props: { gap: '300' }, + children: [ + // Threshold display + { + type: '$if', + props: { + condition: { $: 'modules.identity.guardians.length' }, + then: { + type: 'Row', + props: { bg: 'surface-raised', r: '400', p: '400', gap: '300', ay: 'center' }, + children: [ + { + type: 'Column', + props: { + ay: 'center', + ax: 'center', + width: '44px', + height: '44px', + r: 'pill', + border: '3px solid var(--we-color-success)', + flex: '0 0 auto', + }, + children: [ + { + type: 'we-text', + props: { variant: 'body-sm', fontWeight: '700' }, + children: [{ $: 'modules.identity.thresholdLabel' }], + }, + ], + }, + { + type: 'Column', + props: { gap: '50', flex: '1' }, + children: [ + { + type: 'we-text', + props: { variant: 'body-sm', fontWeight: '500' }, + children: ['Recovery threshold'], + }, + { + type: 'we-text', + props: { variant: 'caption', color: 'text-muted' }, + children: [{ $: 'modules.identity.thresholdDescription' }], + }, + ], + }, + ], + }, + }, + }, + sectionHeader('Guardians'), + { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: '$each', + props: { items: { $: 'modules.identity.guardians' }, as: 'guardian' }, + children: [guardianEntry], + }, + ], + }, + { + type: 'we-button', + props: { + variant: 'ghost', + size: 'sm', + width: '100%', + onClick: { $action: 'modules.identity.addGuardian' }, + }, + children: [{ type: 'we-icon', props: { name: 'plus' } }, ' Add guardian'], + }, + { + type: '$if', + props: { + condition: { $: 'modules.identity.pendingGuardians' }, + then: { + type: 'Row', + props: { bg: 'warning-surface', r: '300', p: '300', gap: '200', ay: 'center' }, + children: [ + { type: 'we-icon', props: { name: 'warning', size: 'sm', color: 'warning-text' } }, + { + type: 'we-text', + props: { variant: 'caption', color: 'warning-text', flex: '1' }, + children: [ + 'One or more guardians have not accepted yet. The roster cannot arm until all guardians consent.', + ], + }, + ], + }, + }, + }, + { + type: '$if', + props: { + condition: { $: '!modules.identity.guardians.length' }, + then: { + type: 'Column', + props: { bg: 'surface-raised', r: '400', p: '400', gap: '200', ay: 'center' }, + children: [ + { type: 'we-icon', props: { name: 'shield', size: 'lg', color: 'text-dim' } }, + { + type: 'we-text', + props: { variant: 'body-sm', color: 'text-muted', textAlign: 'center' }, + children: [ + 'No guardians set up yet. Add guardians who can help recover your identity if you lose all devices.', + ], + }, + ], + }, + }, + }, + ], +}; + +/** Recovery tab — methods, active recovery, incoming requests. */ +const recoveryTab: SchemaNode = { + type: 'Column', + props: { gap: '300' }, + children: [ + { + type: 'Column', + props: { bg: 'surface-raised', r: '400', p: '400', gap: '300' }, + children: [ + { + type: 'we-text', + props: { variant: 'body-sm', fontWeight: '600' }, + children: ['Recovery methods'], + }, + { + type: 'we-text', + props: { variant: 'caption', color: 'text-muted' }, + children: ['Lost all your devices? Use one of these methods to regain access.'], + }, + recoveryMethod( + 'key', + 'Recovery phrase', + 'Enter your 12-word mnemonic on a new device', + 'modules.identity.startMnemonicRecovery', + ), + { + type: '$if', + props: { + condition: { $: 'modules.identity.guardians.length' }, + then: recoveryMethod( + 'shield-check', + 'Guardian recovery', + { $: 'modules.identity.guardianRecoveryLabel' } as unknown as string, + 'modules.identity.startGuardianRecovery', + ), + }, + }, + ], + }, + // Active recovery request + { + type: '$if', + props: { + condition: { $: 'modules.identity.recoveryState' }, + then: { + type: 'Column', + props: { + bg: 'surface-raised', + r: '400', + p: '400', + gap: '300', + border: '1px solid var(--we-color-accent)', + }, + children: [ + { + type: 'we-text', + props: { variant: 'body-sm', fontWeight: '600', color: 'accent' }, + children: ['Recovery in progress'], + }, + { + type: 'we-text', + props: { variant: 'caption', color: 'text-muted' }, + children: [{ $: 'modules.identity.recoveryState.statusLabel' }], + }, + { + type: 'we-progress-bar', + props: { + value: { $: 'modules.identity.recoveryState.approvals' }, + max: { $: 'modules.identity.recoveryState.threshold' }, + }, + }, + { + type: 'Row', + props: { gap: '200' }, + children: [ + { + type: 'we-button', + props: { + variant: 'danger', + size: 'sm', + onClick: { $action: 'modules.identity.vetoRecovery' }, + }, + children: ['Veto'], + }, + ], + }, + ], + }, + }, + }, + // As a guardian — incoming requests + { + type: 'Column', + props: { bg: 'surface-raised', r: '400', p: '400', gap: '200', border: '1px solid var(--we-color-border)' }, + children: [ + { + type: 'we-text', + props: { variant: 'body-sm', fontWeight: '600' }, + children: ['As a guardian'], + }, + { + type: '$if', + props: { + condition: { $: 'modules.identity.incomingRecoveryRequests.length' }, + then: { + type: '$each', + props: { + items: { $: 'modules.identity.incomingRecoveryRequests' }, + as: 'request', + }, + children: [ + { + type: 'Row', + props: { gap: '300', ay: 'center', p: '200' }, + children: [ + { type: 'we-avatar', props: { name: { $: 'request.requesterName' }, size: 'sm' } }, + { + type: 'Column', + props: { gap: '50', flex: '1' }, + children: [ + { type: 'we-text', props: { variant: 'body-sm' }, children: [{ $: 'request.requesterName' }] }, + { + type: 'we-text', + props: { variant: 'caption', color: 'text-muted' }, + children: ['Requesting recovery'], + }, + ], + }, + { + type: 'we-button', + props: { + variant: 'primary', + size: 'xs', + onClick: { + $action: 'modules.identity.approveRecovery', + args: [{ $: 'request.id' }], + }, + }, + children: ['Approve'], + }, + ], + }, + ], + }, + else: { + type: 'we-text', + props: { variant: 'caption', color: 'text-muted' }, + children: ['No pending recovery requests from anyone you guard.'], + }, + }, + }, + ], + }, + ], +}; + +/** Log tab — KEL events and export. */ +const logTab: SchemaNode = { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: '$each', + props: { items: { $: 'modules.identity.kelEvents' }, as: 'kelEvent' }, + children: [kelEventRow], + }, + { + type: '$if', + props: { + condition: { $: '!modules.identity.kelEvents.length' }, + then: { + type: 'Column', + props: { py: '400', ay: 'center' }, + children: [ + { type: 'we-icon', props: { name: 'scroll', size: 'lg', color: 'text-dim' } }, + { + type: 'we-text', + props: { variant: 'body-sm', color: 'text-muted', textAlign: 'center' }, + children: ['No events yet.'], + }, + ], + }, + }, + }, + { + type: '$if', + props: { + condition: { $: 'modules.identity.kelEvents.length' }, + then: { + type: 'we-button', + props: { + variant: 'ghost', + size: 'sm', + width: '100%', + onClick: { $action: 'modules.identity.exportKel' }, + }, + children: [{ type: 'we-icon', props: { name: 'download-simple' } }, ' Export event log (JSON)'], + }, + }, + }, + ], +}; + +// ─── The settings section ─────────────────────────────────────────────────── + +/** + * Identity section for the Account settings page. + * + * Gated on `modules.identity` — the module always registers (agent-scoped), but the guard + * degrades cleanly if it ever does not. Shows a backup nag, status badges, and tabbed content + * for devices, guardians, recovery, and event log. + */ +export const identitySection: SchemaNode = { + type: '$if', + props: { + condition: { $: 'modules.identity' }, + then: { + type: 'Column', + props: { gap: '300' }, + $localState: { + tab: { type: 'string', initial: 'devices' }, + }, + children: [ + // Section heading + { + type: 'Row', + props: { gap: '200', ay: 'center' }, + children: [ + { type: 'we-icon', props: { name: 'fingerprint', size: '20px' } }, + { type: 'we-text', props: { fontWeight: 'semibold' }, children: ['Identity'] }, + ], + }, + + // Loading state or content + { + type: '$if', + props: { + condition: { $: 'modules.identity.identity' }, + then: { + type: 'Column', + props: { gap: '300' }, + children: [ + // Backup nag + { + type: '$if', + props: { + condition: { $: '!modules.identity.backupConfirmed' }, + then: { + type: 'Row', + props: { bg: 'warning-surface', r: '300', p: '300', gap: '200', ay: 'center' }, + children: [ + { type: 'we-icon', props: { name: 'warning', color: 'warning-text' } }, + { + type: 'we-text', + props: { variant: 'caption', color: 'warning-text', flex: '1' }, + children: [ + 'Back up your recovery phrase — without it, losing all devices means losing this identity.', + ], + }, + { + type: 'we-button', + props: { + variant: 'ghost', + size: 'xs', + onClick: { $action: 'modules.identity.startBackup' }, + }, + children: ['Back up'], + }, + ], + }, + }, + }, + + // Status badges + { + type: 'Row', + props: { gap: '200' }, + children: [ + { + type: '$if', + props: { + condition: { $: 'modules.identity.backupConfirmed' }, + then: { + type: 'we-badge', + props: { variant: 'success', size: 'sm' }, + children: [ + { type: 'we-icon', props: { name: 'check-circle', size: 'xs' } }, + ' Backup secured', + ], + }, + else: { + type: 'we-badge', + props: { variant: 'warning', size: 'sm' }, + children: [{ type: 'we-icon', props: { name: 'warning', size: 'xs' } }, ' No backup'], + }, + }, + }, + { + type: '$if', + props: { + condition: { $: 'modules.identity.guardianCount' }, + then: { + type: 'we-badge', + props: { variant: 'success', size: 'sm' }, + children: [ + { type: 'we-icon', props: { name: 'shield-check', size: 'xs' } }, + ' ', + { $: 'modules.identity.guardianCount' }, + ' guardians', + ], + }, + }, + }, + ], + }, + + // Tabs + { + type: 'we-tabs', + props: { + selectedKey: { $: 'local.tab' }, + onChange: { $setLocal: 'tab', value: { $: 'event.detail.value' } }, + }, + children: [ + { type: 'we-tab', props: { key: 'devices' }, children: ['Devices'] }, + { type: 'we-tab', props: { key: 'guardians' }, children: ['Guardians'] }, + { type: 'we-tab', props: { key: 'recovery' }, children: ['Recovery'] }, + { type: 'we-tab', props: { key: 'log' }, children: ['Log'] }, + ], + }, + + // Tab content + { + type: 'Column', + children: [ + { + type: '$if', + props: { condition: { $: "local.tab == 'devices'" }, then: devicesTab }, + }, + { + type: '$if', + props: { condition: { $: "local.tab == 'guardians'" }, then: guardiansTab }, + }, + { + type: '$if', + props: { condition: { $: "local.tab == 'recovery'" }, then: recoveryTab }, + }, + { + type: '$if', + props: { condition: { $: "local.tab == 'log'" }, then: logTab }, + }, + ], + }, + ], + }, + else: { + // Loading state + type: 'Column', + props: { py: '500', ay: 'center', ax: 'center', gap: '300' }, + children: [ + { type: 'we-spinner', props: { size: 'md' } }, + { + type: 'we-text', + props: { variant: 'body-sm', color: 'text-muted' }, + children: ['Loading identity…'], + }, + ], + }, + }, + }, + ], + }, + }, +}; diff --git a/packages/templates/shell/src/Settings.schema.ts b/packages/templates/shell/src/Settings.schema.ts index 747a7a0a8..bc837b2d2 100644 --- a/packages/templates/shell/src/Settings.schema.ts +++ b/packages/templates/shell/src/Settings.schema.ts @@ -10,6 +10,7 @@ import { expr } from '@we/schema-shared'; import { accountSettings } from './AccountSettings.schema.ts'; import { aiSection } from './AiSettings.schema.ts'; import { hostSection } from './HostSettings.schema.ts'; +import { identitySection } from './IdentitySettings.schema.ts'; import { languagesLocalState, languagesSection } from './LanguageSettings.schema.ts'; import { backup, @@ -785,7 +786,7 @@ export const settingsTemplate: TemplateSchema = { // as its own `RenderSchema` call with a fresh context — so it is not a descendant of this node at // render time, whatever the schema tree looks like, and state declared here would never reach it. routes: [ - { path: '/', ...page([accountSection]) }, + { path: '/', ...page([accountSection, identitySection]) }, { path: '/appearance', ...page([templatesSection, themeScopeSection, themesSection]) }, { path: '/spaces', @@ -822,7 +823,7 @@ export const settingsTemplate: TemplateSchema = { // somebody navigates to it directly. { path: '/developer', ...page([developerSection]) }, // Anything else lands on Account rather than an empty frame. - { path: '*', ...page([accountSection]) }, + { path: '*', ...page([accountSection, identitySection]) }, ], children: [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76c63e257..43dedecd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.9.1(jiti@2.7.0)) + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 '@typescript-eslint/eslint-plugin': specifier: ^8.68.0 version: 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.9.1(jiti@2.7.0))(typescript@5.9.3) @@ -432,7 +435,7 @@ importers: version: 4.23.12 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@26.3.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)) packages/app-shell: dependencies: @@ -493,6 +496,9 @@ importers: '@we/module-graph': specifier: workspace:* version: link:../module-system/graph + '@we/module-identity': + specifier: workspace:* + version: link:../module-system/identity '@we/module-notes': specifier: workspace:* version: link:../module-system/notes @@ -541,6 +547,9 @@ importers: lib0: specifier: ^0.2.117 version: 0.2.117 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 solid-js: specifier: ^1.9.15 version: 1.9.15 @@ -560,6 +569,9 @@ importers: '@types/node': specifier: ^26.3.0 version: 26.3.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/three': specifier: ^0.185.4 version: 0.185.4 @@ -580,13 +592,13 @@ importers: version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) vite: specifier: ^8.2.2 - version: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0) + version: 8.2.2(@types/node@26.3.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.14 - version: 2.11.14(solid-js@1.9.15)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 2.11.14(solid-js@1.9.15)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@26.3.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)) packages/backend-system/ad4m: dependencies: @@ -1385,6 +1397,24 @@ importers: specifier: ^4.1.11 version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)) + packages/module-system/identity: + devDependencies: + '@we/cli': + specifier: workspace:* + version: link:../../cli + '@we/module-shared': + specifier: workspace:* + version: link:../shared + '@we/schema-shared': + specifier: workspace:* + version: link:../../schema-system/shared + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + packages/module-system/notes: dependencies: '@we/backend-shared': @@ -2749,6 +2779,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/binding-android-arm-eabi@1.2.5': resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3162,6 +3197,9 @@ packages: '@types/node@26.3.0': resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} @@ -3699,6 +3737,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001809: resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} @@ -3776,6 +3818,9 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3969,6 +4014,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -4020,6 +4069,9 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -4379,6 +4431,10 @@ packages: resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} engines: {node: '>=4.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4451,6 +4507,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5069,6 +5130,10 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5430,10 +5495,18 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -5446,6 +5519,10 @@ packages: resolution: {integrity: sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==} engines: {node: '>=18'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5551,6 +5628,11 @@ packages: engines: {node: '>=20'} hasBin: true + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -5559,6 +5641,10 @@ packages: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-cli@11.0.1: resolution: {integrity: sha512-0UnkNPSayHKRe/tc2YGW6XnSqqOA9eqpiRMgRlV1S6HdGi16vwJBx7lviARzbV1HpQHqLLRH3o8vTcB0cLc+5g==} engines: {node: '>=18'} @@ -5759,6 +5845,11 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -5824,6 +5915,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -6599,6 +6693,9 @@ packages: resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} engines: {node: ^22.14.0 || >=24.0.0} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -6630,6 +6727,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -6688,6 +6789,9 @@ packages: peerDependencies: yjs: ^13.0.0 + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6707,6 +6811,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -6715,6 +6823,10 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.3: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} @@ -7833,6 +7945,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@rolldown/binding-android-arm-eabi@1.2.5': optional: true @@ -8132,6 +8248,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 26.3.0 + '@types/responselike@1.0.3': dependencies: '@types/node': 26.3.0 @@ -8273,7 +8393,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@26.3.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -8856,6 +8976,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001809: {} canvas-renderer@2.2.1: @@ -8934,6 +9056,12 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.2.0 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -9107,6 +9235,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} decompress-response@6.0.0: @@ -9150,6 +9280,8 @@ snapshots: detect-node@2.1.0: optional: true + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -9633,6 +9765,11 @@ snapshots: dependencies: array-back: 3.1.0 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -9715,6 +9852,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10354,6 +10494,10 @@ snapshots: load-tsconfig@0.2.5: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -10721,10 +10865,18 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -10735,6 +10887,8 @@ snapshots: p-map@7.0.5: {} + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} pako@3.0.1: {} @@ -10824,6 +10978,12 @@ snapshots: playwright-core@1.62.1: {} + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.15 @@ -10836,6 +10996,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@5.0.0: {} + postcss-cli@11.0.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12): dependencies: chokidar: 3.6.0 @@ -11043,6 +11205,12 @@ snapshots: dependencies: hookified: 2.2.0 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -11111,6 +11279,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -11838,6 +12008,19 @@ snapshots: vary@1.1.2: {} + vite-plugin-solid@2.11.14(solid-js@1.9.15)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + '@babel/core': 7.29.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.15(@babel/core@7.29.7)(solid-js@1.9.15) + merge-anything: 5.1.7 + solid-js: 1.9.15 + solid-refresh: 0.6.3(solid-js@1.9.15) + vite: 8.2.2(@types/node@26.3.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.2.2(@types/node@26.3.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + vite-plugin-solid@2.11.14(solid-js@1.9.15)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7 @@ -11891,6 +12074,10 @@ snapshots: tsx: 4.23.12 yaml: 2.9.0 + vitefu@1.1.3(vite@8.2.2(@types/node@26.3.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)): + optionalDependencies: + vite: 8.2.2(@types/node@26.3.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0) + vitefu@1.1.3(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0)): optionalDependencies: vite: 8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(tsx@4.23.12)(yaml@2.9.0) @@ -12036,6 +12223,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + which-module@2.0.1: {} + which@1.3.1: dependencies: isexe: 2.0.0 @@ -12063,6 +12252,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -12109,6 +12304,8 @@ snapshots: lib0: 0.2.117 yjs: 13.6.32 + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -12119,10 +12316,29 @@ snapshots: yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.3: dependencies: cliui: 8.0.1 diff --git a/we-seed.json b/we-seed.json index cf46f2b1d..953f40af0 100644 --- a/we-seed.json +++ b/we-seed.json @@ -8,7 +8,7 @@ "features": { "useQueryIR": true }, - "modules": ["globe", "graph", "notes", "pocket", "call", "transcribe"], + "modules": ["globe", "graph", "notes", "pocket", "call", "transcribe", "identity"], "templates": ["default", "discord", "twitter", "instagram", "youtube", "kanban", "events", "workshop"], "views": ["about", "cards", "graph", "globe", "tasks", "calendar"], "ad4m": {