diff --git a/apps/mobile/src/components/agents/model-selector-badges.test.ts b/apps/mobile/src/components/agents/model-selector-badges.test.ts new file mode 100644 index 0000000000..f7ffc75137 --- /dev/null +++ b/apps/mobile/src/components/agents/model-selector-badges.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { modelSelectorBadges } from './model-selector-badges'; + +function cliCatalogOption(overrides: { hasUserByokAvailable?: boolean } = {}) { + return { id: 'remote-model-0', showGatewayMetadata: false, ...overrides }; +} + +function gatewayOption(overrides: { hasUserByokAvailable?: boolean; isFree?: boolean } = {}) { + return { id: 'anthropic/claude', showGatewayMetadata: true, ...overrides }; +} + +describe('modelSelectorBadges', () => { + it('shows BYOK for a CLI-catalog option with user BYOK available', () => { + const badges = modelSelectorBadges(cliCatalogOption({ hasUserByokAvailable: true })); + expect(badges.byok).toBe(true); + expect(badges.free).toBe(false); + expect(badges.collectsData).toBe(false); + }); + + it('hides BYOK for a CLI-catalog option without the flag', () => { + const badges = modelSelectorBadges(cliCatalogOption()); + expect(badges.byok).toBe(false); + expect(badges.free).toBe(false); + expect(badges.collectsData).toBe(false); + }); + + it('keeps free and data-collection suppressed for CLI-catalog options with the flag', () => { + const badges = modelSelectorBadges({ + id: 'remote-model-0', + showGatewayMetadata: false, + isFree: true, + mayTrainOnYourPrompts: true, + hasUserByokAvailable: true, + }); + expect(badges.byok).toBe(true); + expect(badges.free).toBe(false); + expect(badges.collectsData).toBe(false); + }); + + it('keeps BYOK gating for gateway options with the flag', () => { + const badges = modelSelectorBadges(gatewayOption({ hasUserByokAvailable: true })); + expect(badges.byok).toBe(true); + expect(badges.free).toBe(false); + }); + + it('keeps free gating for gateway options without the BYOK flag', () => { + const badges = modelSelectorBadges(gatewayOption({ isFree: true })); + expect(badges.free).toBe(true); + expect(badges.byok).toBe(false); + }); + + it('shows no badges for an unavailable option without the flag', () => { + const badges = modelSelectorBadges({ + id: 'remote-unavailable-model', + showGatewayMetadata: false, + }); + expect(badges.byok).toBe(false); + expect(badges.free).toBe(false); + expect(badges.collectsData).toBe(false); + }); + + it('shows no badges for an undefined option', () => { + const badges = modelSelectorBadges(undefined); + expect(badges.byok).toBe(false); + expect(badges.free).toBe(false); + expect(badges.collectsData).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/agents/model-selector-badges.ts b/apps/mobile/src/components/agents/model-selector-badges.ts new file mode 100644 index 0000000000..52558ee39f --- /dev/null +++ b/apps/mobile/src/components/agents/model-selector-badges.ts @@ -0,0 +1,30 @@ +import { + hasUserByokAvailable, + isFreeModelOption, + mayTrainOnYourPrompts, + type ModelDataDisclosure, +} from '@/lib/free-model-data-disclosure'; + +type ModelBadgeOption = ModelDataDisclosure & { + showGatewayMetadata: boolean; +}; + +/** + * Badge predicates for the model selector pill and picker rows. + * Input contract: a post-normalization SessionModelOption (both call sites + * pass one: ModelSelector maps options through toSessionModelOption in + * model-selector.tsx, and the picker bridge carries SessionModelOption). + * BYOK is per-user account state, not gateway metadata: the CLI passes the + * backend's hasUserByokAvailable through the v1 wire catalog, so the badge + * must render for CLI-catalog options too. Free/data-collection stay gated + * on showGatewayMetadata because CLI-catalog options do not carry Kilo + * gateway pricing or data-policy semantics. + */ +export function modelSelectorBadges(option: ModelBadgeOption | undefined) { + const showGatewayMetadata = option?.showGatewayMetadata === true; + return { + byok: hasUserByokAvailable(option), + free: showGatewayMetadata && isFreeModelOption(option), + collectsData: showGatewayMetadata && mayTrainOnYourPrompts(option), + }; +} diff --git a/apps/mobile/src/components/agents/model-selector.mounted.test.tsx b/apps/mobile/src/components/agents/model-selector.mounted.test.tsx new file mode 100644 index 0000000000..ff90a5c1a9 --- /dev/null +++ b/apps/mobile/src/components/agents/model-selector.mounted.test.tsx @@ -0,0 +1,118 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/components/consent/consent-card.mounted.test.tsx) */ +import { createElement } from 'react'; +import TestRenderer from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { + BYOK_MODEL_LABEL, + FREE_MODEL_DATA_LABEL, + FREE_MODEL_FREE_LABEL, +} from '@/lib/free-model-data-disclosure'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; + +import { ModelPickerOptionRow } from './model-selector'; + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + ScrollView: 'ScrollView', + View: 'View', +})); +vi.mock('expo-haptics', () => ({ + selectionAsync: vi.fn(), +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock('lucide-react-native', () => ({ + BookOpenCheck: 'BookOpenCheck', + Brain: 'Brain', + Check: 'Check', + ChevronDown: 'ChevronDown', + Star: 'Star', +})); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + warn: '#9F6612', + mutedForeground: '#6F6A61', + primary: '#4F5A10', + }), +})); +vi.mock('@/lib/hooks/use-available-models', () => ({ + thinkingEffortLabel: (variant: string) => variant, +})); +vi.mock('@/lib/picker-bridge', () => ({ + setModelPickerBridge: vi.fn(), +})); +vi.mock('@/lib/utils', () => ({ + cn: (...parts: unknown[]) => parts.filter(Boolean).join(' '), +})); + +function cliCatalogOption(overrides: Partial = {}): SessionModelOption { + return { + id: 'remote-model-0', + name: 'Minimax M2.5', + displayId: 'minimax/minimax-m2.5', + variants: [], + isPreferred: false, + showGatewayMetadata: false, + ...overrides, + }; +} + +function renderRow(option: SessionModelOption): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + TestRenderer.act(() => { + ref.current = TestRenderer.create( + createElement(ModelPickerOptionRow, { + option, + selected: false, + selectedVariant: '', + isFavorite: false, + onSelectModel: vi.fn<(option: SessionModelOption) => void>(), + onSelectVariant: vi.fn<(variant: string) => void>(), + onToggleFavorite: vi.fn<(option: SessionModelOption) => void>(), + }) + ); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function textStrings(root: TestRenderer.ReactTestInstance): string[] { + return root + .findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'Text' && + typeof node.props.children === 'string' + ) + .map(node => node.props.children as string); +} + +function countWithAccessibilityLabel(root: TestRenderer.ReactTestInstance, label: string): number { + return root.findAll(node => (node.props.accessibilityLabel as string | undefined) === label) + .length; +} + +describe('ModelPickerOptionRow BYOK badge', () => { + it('renders the BYOK badge for a CLI-catalog option with user BYOK available', () => { + const renderer = renderRow(cliCatalogOption({ hasUserByokAvailable: true })); + expect(textStrings(renderer.root)).toContain(BYOK_MODEL_LABEL); + }); + + it('renders no BYOK badge for a CLI-catalog option without the flag', () => { + const renderer = renderRow(cliCatalogOption()); + expect(textStrings(renderer.root)).not.toContain(BYOK_MODEL_LABEL); + }); + + it('renders no Free or data-collection indicators for a CLI-catalog option', () => { + const renderer = renderRow(cliCatalogOption({ isFree: true, mayTrainOnYourPrompts: true })); + expect(textStrings(renderer.root)).not.toContain(FREE_MODEL_FREE_LABEL); + expect(countWithAccessibilityLabel(renderer.root, FREE_MODEL_DATA_LABEL)).toBe(0); + }); +}); diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx index 294afdea29..14701f7bae 100644 --- a/apps/mobile/src/components/agents/model-selector.tsx +++ b/apps/mobile/src/components/agents/model-selector.tsx @@ -12,9 +12,6 @@ import { FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataAccessibilityLabel, - hasUserByokAvailable, - isFreeModelOption, - mayTrainOnYourPrompts, } from '@/lib/free-model-data-disclosure'; import { type ModelOption, thinkingEffortLabel } from '@/lib/hooks/use-available-models'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; @@ -27,6 +24,8 @@ import { } from '@/lib/picker-bridge'; import { cn } from '@/lib/utils'; +import { modelSelectorBadges } from './model-selector-badges'; + type ModelSelectorProps = { value: string; variant: string; @@ -143,10 +142,8 @@ export function ModelSelector({ const providerAware = pickerOptions.some( option => option.modelRef !== undefined || !option.showGatewayMetadata ); - const showGatewayMetadata = selectedModel?.showGatewayMetadata ?? false; const label = selectedModel?.name ?? (!providerAware && value ? value : 'Model'); - const byok = showGatewayMetadata && hasUserByokAvailable(selectedModel); - const collectsData = showGatewayMetadata && mayTrainOnYourPrompts(selectedModel); + const { byok, collectsData } = modelSelectorBadges(selectedModel); const hasVariants = selectedModel ? selectedModel.variants.length > 1 : false; const variantLabel = variant ? thinkingEffortLabel(variant) : ''; const compactVariantLabel = variant ? compactThinkingEffortLabel(variant) : ''; @@ -227,9 +224,7 @@ export function ModelPickerOptionRow({ onToggleFavorite: (option: SessionModelOption) => void; }>) { const colors = useThemeColors(); - const free = option.showGatewayMetadata && isFreeModelOption(option); - const byok = option.showGatewayMetadata && hasUserByokAvailable(option); - const collectsData = option.showGatewayMetadata && mayTrainOnYourPrompts(option); + const { free, byok, collectsData } = modelSelectorBadges(option); const costLabel = modelPickerCostLabel(option); const accessibilityLabel = [ option.provider?.name, diff --git a/apps/mobile/src/lib/free-model-data-disclosure.ts b/apps/mobile/src/lib/free-model-data-disclosure.ts index 850e66dfb7..f33242efa9 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.ts @@ -2,7 +2,7 @@ export const BYOK_MODEL_LABEL = 'BYOK'; export const FREE_MODEL_DATA_LABEL = 'Data collected'; export const FREE_MODEL_FREE_LABEL = 'Free'; -type ModelDataDisclosure = { +export type ModelDataDisclosure = { id: string; isFree?: boolean; mayTrainOnYourPrompts?: boolean; diff --git a/dev/seed/app/byok-e2e-fixture.ts b/dev/seed/app/byok-e2e-fixture.ts new file mode 100644 index 0000000000..90da094e07 --- /dev/null +++ b/dev/seed/app/byok-e2e-fixture.ts @@ -0,0 +1,160 @@ +import { byok_api_keys, modelsByProvider } from '@kilocode/db/schema'; +import { StoredModelSchema } from '@kilocode/db/schema-types'; +import { and, desc, eq, sql } from 'drizzle-orm'; +import { z } from 'zod'; + +import { encryptCredential, requireEncryptionKey } from '../lib/byok'; +import { getSeedDb } from '../lib/db'; +import { isValidEmail, resolveSeedUserId } from '../lib/users'; +import type { SeedResult } from '../index'; + +export const usage = ' '; + +const ALLOWED_PROVIDERS = ['minimax', 'moonshotai']; +const KEY_PREFIX = 'dev-seed:byok-e2e'; +const MARKER_TAG = 'dev-seed:byok-e2e'; + +function printUsage(): void { + console.log(`Usage: pnpm dev:seed app:byok-e2e-fixture ${usage}`); + console.log(''); + console.log('Seeds a personal BYOK key and one Vercel metadata snapshot entry so the'); + console.log('catalog flags exactly the given model id for the user.'); + console.log(''); + console.log(`Allowed providers: ${ALLOWED_PROVIDERS.join(', ')}`); + console.log('The key is a placeholder the upstream provider rejects on purpose; the'); + console.log('rejection still writes the is_user_byok usage row. The encrypted key and'); + console.log('plaintext are never printed or returned.'); + console.log(''); + console.log('Examples:'); + console.log(' pnpm dev:seed app:byok-e2e-fixture ada@example.com minimax minimax/minimax-m2.5'); +} + +export async function run(...args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + const [rawEmail, rawProvider, rawModelId, ...rest] = args; + const email = rawEmail?.trim(); + if (!email) { + printUsage(); + throw new Error('email is required'); + } + if (!isValidEmail(email)) { + throw new Error(`email is not a valid address: ${email}`); + } + const provider = rawProvider?.trim(); + if (!provider) { + printUsage(); + throw new Error('provider is required'); + } + if (!ALLOWED_PROVIDERS.includes(provider)) { + throw new Error(`provider must be one of: ${ALLOWED_PROVIDERS.join(', ')}`); + } + const modelId = rawModelId?.trim(); + if (!modelId) { + printUsage(); + throw new Error('model-id is required'); + } + if (rest.length > 0) { + throw new Error(`Unknown arguments: ${rest.join(' ')}`); + } + + const userId = await resolveSeedUserId(email); + const db = getSeedDb(); + + // Reset and replace this topic's own data in one transaction: key deletion, key + // insertion, marker cleanup, metadata validation, and snapshot insertion commit + // together, so a validation or insert failure rolls back and never leaves the user + // without the previous key or a half-cleaned snapshot. + const byokKeyId = await db.transaction(async tx => { + // Reset only this topic's key data: the dedicated test account's personal key for + // this provider. Re-running with the same user+provider is idempotent. + await tx + .delete(byok_api_keys) + .where(and(eq(byok_api_keys.kilo_user_id, userId), eq(byok_api_keys.provider_id, provider))); + + const [insertedKey] = await tx + .insert(byok_api_keys) + .values({ + organization_id: null, + kilo_user_id: userId, + provider_id: provider, + encrypted_api_key: encryptCredential(`${KEY_PREFIX}:${provider}`, requireEncryptionKey()), + management_source: 'user', + created_by: userId, + is_enabled: true, + } satisfies typeof byok_api_keys.$inferInsert) + .returning({ id: byok_api_keys.id }); + if (!insertedKey) { + throw new Error('Failed to create the fixture BYOK key'); + } + + // Remove every models_by_provider row this topic ever wrote, across all models. + // The marker tag never occurs in real snapshots, so a synced row is never deleted. + await tx.delete(modelsByProvider).where( + sql`EXISTS ( + SELECT 1 FROM jsonb_each(${modelsByProvider.vercel}) AS e(k, v) + WHERE e.v -> 'endpoints' @> ${JSON.stringify([{ tag: MARKER_TAG }])}::jsonb + )` + ); + + // Merge the fixture entry into a copy of the newest remaining snapshot so a real + // synced snapshot keeps its other models (same-provider models then stay flagged). + const [latest] = await tx + .select({ + data: modelsByProvider.data, + openrouter: modelsByProvider.openrouter, + vercel: modelsByProvider.vercel, + }) + .from(modelsByProvider) + .orderBy(desc(modelsByProvider.id)) + .limit(1); + + const mergedVercel = { + ...(latest?.vercel ?? {}), + [modelId]: { + id: modelId, + name: modelId, + type: 'language', + endpoints: [{ provider_name: provider, tag: MARKER_TAG }], + }, + }; + + // Prove the merged map parses the way the catalog endpoint later reads it. + const parsed = z.record(z.string(), StoredModelSchema).safeParse(mergedVercel); + if (!parsed.success) { + throw new Error( + `Merged vercel map failed StoredModelSchema validation: ${parsed.error.message}` + ); + } + + await tx.insert(modelsByProvider).values({ + data: latest?.data ?? { + providers: [], + total_providers: 0, + total_models: 0, + generated_at: new Date().toISOString(), + }, + openrouter: latest?.openrouter ?? null, + vercel: parsed.data, + } satisfies typeof modelsByProvider.$inferInsert); + + return insertedKey.id; + }); + + console.log(''); + console.log('This fixture represents a user who holds a personal BYOK key for the'); + console.log('provider and a catalog snapshot flagging exactly the given model id.'); + console.log('The placeholder key is rejected upstream; the usage row still records'); + console.log('is_user_byok = true for the turn.'); + + return { + userId, + byokKeyId, + providerId: provider, + modelId, + enabled: true, + }; +} diff --git a/dev/seed/app/usage-evidence.ts b/dev/seed/app/usage-evidence.ts new file mode 100644 index 0000000000..a4f7cdcc4e --- /dev/null +++ b/dev/seed/app/usage-evidence.ts @@ -0,0 +1,129 @@ +import { microdollar_usage, microdollar_usage_metadata } from '@kilocode/db/schema'; +import { and, desc, eq, gt } from 'drizzle-orm'; + +import { getSeedDb } from '../lib/db'; +import { isValidEmail, resolveSeedUserId } from '../lib/users'; +import type { SeedResult } from '../index'; + +export const usage = ' [--since ]'; + +function printUsage(): void { + console.log(`Usage: pnpm dev:seed app:usage-evidence ${usage}`); + console.log(''); + console.log('Reads microdollar usage rows for the user, newest first, capped at 100.'); + console.log('Left-joins usage metadata and reports BYOK evidence as flat primitives.'); + console.log('Read-only; never writes.'); + console.log(''); + console.log('Options:'); + console.log(' --since Only rows created after this instant.'); + console.log(''); + console.log('Examples:'); + console.log( + ' pnpm -s dev:seed app:usage-evidence ada@example.com --json | jq -r .byokLatestModel' + ); + console.log( + ' pnpm -s dev:seed app:usage-evidence ada@example.com --since 2026-08-07T12:00:00Z --json' + ); +} + +type UsageEvidenceOptions = { + email: string; + since: string | null; +}; + +function parseArgs(args: string[]): UsageEvidenceOptions { + const email = args[0]?.trim(); + if (!email) { + printUsage(); + throw new Error('email is required'); + } + if (!isValidEmail(email)) { + throw new Error(`email is not a valid address: ${email}`); + } + + let since: string | null = null; + let index = 1; + while (index < args.length) { + const arg = args[index]; + if (arg === '--since') { + const value = args[index + 1]; + if (!value) { + throw new Error('--since requires an ISO-8601 timestamp value'); + } + if (Number.isNaN(Date.parse(value))) { + throw new Error(`--since is not a valid ISO-8601 timestamp: ${value}`); + } + since = new Date(value).toISOString(); + index += 2; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + return { email, since }; +} + +const dedupeJoined = (values: Array): string => + [...new Set(values.filter(v => v !== null).map(String))].join(','); + +export async function run(...args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + const { email, since } = parseArgs(args); + const userId = await resolveSeedUserId(email); + const db = getSeedDb(); + + // Filter first, then cap: a --since window never discards an in-window row. + const conditions = [eq(microdollar_usage.kilo_user_id, userId)]; + if (since) { + conditions.push(gt(microdollar_usage.created_at, since)); + } + + const rows = await db + .select({ + createdAt: microdollar_usage.created_at, + model: microdollar_usage.model, + requestedModel: microdollar_usage.requested_model, + provider: microdollar_usage.provider, + isUserByok: microdollar_usage_metadata.is_user_byok, + statusCode: microdollar_usage_metadata.status_code, + sessionId: microdollar_usage_metadata.session_id, + }) + .from(microdollar_usage) + .leftJoin(microdollar_usage_metadata, eq(microdollar_usage_metadata.id, microdollar_usage.id)) + .where(and(...conditions)) + .orderBy(desc(microdollar_usage.created_at)) + .limit(100); + + // A row's model falls back to requested_model for upstream-rejected requests. + const effectiveModel = (row: (typeof rows)[number]): string | null => + row.model ?? row.requestedModel; + const byokRows = rows.filter(row => row.isUserByok === true); + const latest = rows[0]; + const byokLatest = byokRows[0]; + + return { + userId, + rows: rows.length, + byokRows: byokRows.length, + nonByokRows: rows.length - byokRows.length, + latestCreatedAt: latest ? new Date(latest.createdAt).toISOString() : null, + latestModel: latest ? effectiveModel(latest) : null, + latestProvider: latest?.provider ?? null, + latestIsUserByok: latest?.isUserByok ?? null, + latestStatusCode: latest?.statusCode ?? null, + latestSessionId: latest?.sessionId ?? null, + byokLatestCreatedAt: byokLatest ? new Date(byokLatest.createdAt).toISOString() : null, + byokLatestModel: byokLatest ? effectiveModel(byokLatest) : null, + byokLatestProvider: byokLatest?.provider ?? null, + byokLatestSessionId: byokLatest?.sessionId ?? null, + byokSessionIds: dedupeJoined(byokRows.map(row => row.sessionId)), + byokStatusCodes: dedupeJoined(byokRows.map(row => row.statusCode)), + nonByokSessionIds: dedupeJoined( + rows.filter(row => row.isUserByok !== true).map(row => row.sessionId) + ), + }; +} diff --git a/dev/seed/coding-plans/occupied-minimax-byok.ts b/dev/seed/coding-plans/occupied-minimax-byok.ts index aebd0aa068..448929dcaf 100644 --- a/dev/seed/coding-plans/occupied-minimax-byok.ts +++ b/dev/seed/coding-plans/occupied-minimax-byok.ts @@ -1,9 +1,7 @@ -import { createCipheriv, randomBytes } from 'node:crypto'; - import { byok_api_keys } from '@kilocode/db/schema'; -import type { EncryptedData } from '@kilocode/db/schema-types'; import { and, eq } from 'drizzle-orm'; +import { encryptCredential, requireEncryptionKey } from '../lib/byok'; import { getSeedDb } from '../lib/db'; import type { SeedResult } from '../index'; @@ -19,29 +17,6 @@ function printUsage(): void { console.log('The placeholder key supports subscription precondition UI testing only.'); } -function requireEncryptionKey(): Buffer { - const keyBase64 = process.env.BYOK_ENCRYPTION_KEY; - if (!keyBase64) { - throw new Error('BYOK_ENCRYPTION_KEY is not configured'); - } - const key = Buffer.from(keyBase64, 'base64'); - if (key.length !== 32) { - throw new Error('BYOK_ENCRYPTION_KEY must decode to 32 bytes'); - } - return key; -} - -function encryptCredential(plaintext: string, key: Buffer): EncryptedData { - const iv = randomBytes(12); - const cipher = createCipheriv('aes-256-gcm', key, iv); - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); - return { - iv: iv.toString('base64'), - data: encrypted.toString('base64'), - authTag: cipher.getAuthTag().toString('base64'), - }; -} - function requireScenario(value: string | undefined): string { const scenario = value?.trim(); if (!scenario || !/^[a-zA-Z0-9_-]{1,64}$/.test(scenario)) { diff --git a/dev/seed/lib/byok.ts b/dev/seed/lib/byok.ts new file mode 100644 index 0000000000..99270e07e7 --- /dev/null +++ b/dev/seed/lib/byok.ts @@ -0,0 +1,26 @@ +import { createCipheriv, randomBytes } from 'node:crypto'; + +import type { EncryptedData } from '@kilocode/db/schema-types'; + +export function requireEncryptionKey(): Buffer { + const keyBase64 = process.env.BYOK_ENCRYPTION_KEY; + if (!keyBase64) { + throw new Error('BYOK_ENCRYPTION_KEY is not configured'); + } + const key = Buffer.from(keyBase64, 'base64'); + if (key.length !== 32) { + throw new Error('BYOK_ENCRYPTION_KEY must decode to 32 bytes'); + } + return key; +} + +export function encryptCredential(plaintext: string, key: Buffer): EncryptedData { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return { + iv: iv.toString('base64'), + data: encrypted.toString('base64'), + authTag: cipher.getAuthTag().toString('base64'), + }; +} diff --git a/dev/seed/lib/users.ts b/dev/seed/lib/users.ts new file mode 100644 index 0000000000..3af3fae644 --- /dev/null +++ b/dev/seed/lib/users.ts @@ -0,0 +1,41 @@ +import { kilocode_users } from '@kilocode/db/schema'; +import { eq, or } from 'drizzle-orm'; + +import { getSeedDb } from './db'; +import { normalizeSeedEmail } from './email'; + +export function isValidEmail(email: string): boolean { + // Intentionally permissive; we only guard against obvious nonsense in dev. + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +export async function resolveSeedUserId(email: string): Promise { + const normalizedEmail = normalizeSeedEmail(email); + const db = getSeedDb(); + const matches = await db + .select({ + userId: kilocode_users.id, + email: kilocode_users.google_user_email, + }) + .from(kilocode_users) + .where( + or( + eq(kilocode_users.google_user_email, email), + eq(kilocode_users.normalized_email, normalizedEmail) + ) + ); + + if (matches.length === 0) { + throw new Error(`No user found for email ${email}`); + } + + const exactMatches = matches.filter(match => match.email === email); + const resolvedMatches = exactMatches.length > 0 ? exactMatches : matches; + if (resolvedMatches.length > 1) { + const matchList = resolvedMatches.map(match => `${match.email} (${match.userId})`).join(', '); + throw new Error(`Multiple users matched ${email}: ${matchList}`); + } + + const [user] = resolvedMatches; + return user.userId; +}