diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx index 24b10ed0c3..330257c03f 100644 --- a/apps/expo/app/(app)/ai-chat.tsx +++ b/apps/expo/app/(app)/ai-chat.tsx @@ -90,8 +90,14 @@ export default function AIChat() { const locationRef = React.useRef(context.location); locationRef.current = context.location; - const { data: _authSession } = authClient.useSession(); - const token = _authSession?.session?.token ?? null; + // We deliberately don't read `useSession()` data into the transport + // closure. On first render `data?.session?.token` is null, the transport + // builds with `Authorization: Bearer null`, and the very first send hits + // /api/chat unauthenticated — the API responds 401 and useChat shows the + // generic "something went wrong" UI. Reading the token lazily via + // `authClient.getSession()` at each request (below) avoids that race + // entirely; getSession is cached after the first call so this is cheap. + authClient.useSession(); const [input, setInput] = React.useState(''); const [lastUserMessage, setLastUserMessage] = React.useState(''); const [previousMessages, setPreviousMessages] = React.useState([]); @@ -133,8 +139,12 @@ export default function AIChat() { return new DefaultChatTransport({ fetch: expoFetch as unknown as typeof globalThis.fetch, api: `${clientEnvs.EXPO_PUBLIC_API_URL}/api/chat`, - headers: { - Authorization: `Bearer ${token}`, + headers: async () => { + const { data } = await authClient.getSession(); + const token = data?.session?.token ?? ''; + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; }, body: () => ({ contextType: contextRef.current.contextType, @@ -144,7 +154,7 @@ export default function AIChat() { date: new Date().toLocaleString(), }), }); - }, [aiMode, isLocalReady, token, tools]); + }, [aiMode, isLocalReady, tools]); const { messages, setMessages, error, sendMessage, stop, status } = useChat({ transport, diff --git a/apps/expo/playwright/playwright.config.ts b/apps/expo/playwright/playwright.config.ts index 30049dc98e..1cfefc8faa 100644 --- a/apps/expo/playwright/playwright.config.ts +++ b/apps/expo/playwright/playwright.config.ts @@ -7,23 +7,44 @@ export default defineConfig({ globalSetup: './tests/globalSetup.ts', timeout: 30_000, expect: { timeout: 10_000 }, - fullyParallel: false, + // Tests create their own data (timestamped names) and otherwise read shared + // catalog/profile data, so parallel runs are safe. Override with + // PW_WORKERS=1 if you suspect a flake is contention-related. + fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: 1, + workers: Number(process.env.PW_WORKERS ?? (process.env.CI ? 2 : 4)), reporter: [['list'], ['html', { open: 'never' }]], use: { baseURL: BASE_URL, trace: 'on-first-retry', video: 'on-first-retry', - headless: true, + // Headless by default everywhere. Opt into a visible browser with + // `PWHEADED=1` — never have the run pop windows on the dev's desktop + // unless explicitly requested. + headless: process.env.PWHEADED !== '1', }, projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + // `channel: 'chrome'` uses the locally installed Google Chrome — + // Playwright's bundled chromium has no Ubuntu 26.04 build yet. + // Playwright already isolates each context with an ephemeral user-data + // dir, but `--incognito` makes that explicit. + use: { + ...devices['Desktop Chrome'], + channel: 'chrome', + launchOptions: { + args: [ + '--incognito', + '--no-default-browser-check', + '--no-first-run', + '--password-store=basic', + ], + }, + }, }, ], }); diff --git a/apps/expo/playwright/tests/core.spec.ts b/apps/expo/playwright/tests/core.spec.ts index 56dec8bd5a..41ba5baa3c 100644 --- a/apps/expo/playwright/tests/core.spec.ts +++ b/apps/expo/playwright/tests/core.spec.ts @@ -239,7 +239,8 @@ test('settings screen loads', async ({ authedPage: page }) => { await page.goto(`${BASE_URL}/settings`); await expect(page.getByText('AI Models')).toBeVisible(); await expect(page.getByText('Danger Zone')).toBeVisible(); - await expect(page.getByText(/PackRat v/i)).toBeVisible(); + // Dev/preview builds prepend an environment tag, e.g. "PackRat (Dev) v2.0.26" + await expect(page.getByText(/PackRat(?: \([^)]+\))? v\d/i)).toBeVisible(); }); // ─── AI Chat ────────────────────────────────────────────────────────────────── diff --git a/apps/expo/playwright/tests/globalSetup.ts b/apps/expo/playwright/tests/globalSetup.ts index 6373f7e635..028822e68f 100644 --- a/apps/expo/playwright/tests/globalSetup.ts +++ b/apps/expo/playwright/tests/globalSetup.ts @@ -15,7 +15,12 @@ export default async function setup() { fs.mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true }); - const browser = await chromium.launch(); + // Headless by default; opt into a visible browser with PWHEADED=1. + const browser = await chromium.launch({ + channel: 'chrome', + headless: process.env.PWHEADED !== '1', + args: ['--incognito', '--no-default-browser-check', '--no-first-run', '--password-store=basic'], + }); const context = await browser.newContext(); const page = await context.newPage(); diff --git a/bun.lock b/bun.lock index e4ff1e67e4..adac6c09f9 100644 --- a/bun.lock +++ b/bun.lock @@ -643,7 +643,7 @@ "name": "@packrat/ui", "version": "2.0.25", "dependencies": { - "@packrat-ai/nativewindui": "^2.0.5", + "@packrat-ai/nativewindui": "^2.0.6", }, }, "packages/units": { diff --git a/packages/api/docker-compose.test.yml b/packages/api/docker-compose.test.yml index dad926c705..3e7fc32e07 100644 --- a/packages/api/docker-compose.test.yml +++ b/packages/api/docker-compose.test.yml @@ -9,7 +9,7 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_HOST_AUTH_METHOD: trust ports: - - "5433:5432" + - "${POSTGRES_TEST_HOST_PORT:-5433}:5432" volumes: - postgres_test_data:/var/lib/postgresql/data healthcheck: @@ -23,8 +23,8 @@ services: -c log_duration=on -c max_connections=100 - # Neon-compatible WebSocket proxy so tests use the same @neondatabase/serverless - # driver as production — eliminates pg-cloudflare / cloudflare:sockets workarounds. + # Neon-compatible WebSocket proxy used by the vitest integration suite + # (`packages/api/test/setup.ts`) — speaks WebSocket only. wsproxy: image: ghcr.io/neondatabase/wsproxy:latest environment: @@ -32,7 +32,23 @@ services: ALLOW_ADDR_REGEX: ".*" LOG_CONN_INFO: "true" ports: - - "5434:80" + - "${WSPROXY_HOST_PORT:-5434}:80" + depends_on: + postgres-test: + condition: service_healthy + + # Official Neon HTTP+WS local proxy + # (https://neon.com/guides/local-development-with-neon). Lets the + # `@neondatabase/serverless` HTTP driver (neon(url)) and WebSocket Pool + # talk to local Postgres on a single port (4444), so wrangler dev hits the + # exact same code paths as prod — no per-request WebSocket lifetime issues. + # Used by the Playwright E2E stack. + neon-proxy: + image: ghcr.io/timowilhelm/local-neon-http-proxy:main + environment: + PG_CONNECTION_STRING: postgres://test_user:test_password@postgres-test:5432/packrat_test + ports: + - "${NEON_PROXY_HOST_PORT:-4444}:4444" depends_on: postgres-test: condition: service_healthy diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index 7c488119c4..a0f0621d9c 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -199,7 +199,14 @@ export async function getAuth(env: ValidatedEnv): Promise { storage: 'secondary-storage', }, - trustedOrigins: [env.BETTER_AUTH_URL, 'packrat://'], + trustedOrigins: [ + env.BETTER_AUTH_URL, + 'packrat://', + // Local web dev — accept any localhost port so parallel agents on + // bumped ports (e.g. 18082) don't need an allowlist update. Gated on + // the API URL pointing at localhost so prod never widens trust. + ...(env.BETTER_AUTH_URL.startsWith('http://localhost') ? ['http://localhost:*'] : []), + ], }); authCache.set(env as object, auth); diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts index 28c5f0ba8b..5daf38ddf8 100644 --- a/packages/api/src/db/index.ts +++ b/packages/api/src/db/index.ts @@ -13,8 +13,16 @@ const isStandardPostgresUrl = (url: string) => { const host = u.hostname.toLowerCase(); const isNeonTech = host === 'neon.tech' || host.endsWith('.neon.tech'); const isNeonCom = host === 'neon.com' || host.endsWith('.neon.com'); + // `db.localtest.me` is the hostname the local Neon HTTP proxy uses (see + // packages/api/docker-compose.test.yml). The URL looks like raw Postgres + // but the proxy fronts the real connection and speaks Neon's HTTP/WS + // wire format, so we route through the neon driver — same path as prod. + const isLocalNeonProxy = host === 'db.localtest.me'; return ( - (u.protocol === 'postgres:' || u.protocol === 'postgresql:') && !isNeonTech && !isNeonCom + (u.protocol === 'postgres:' || u.protocol === 'postgresql:') && + !isNeonTech && + !isNeonCom && + !isLocalNeonProxy ); } catch { return false; diff --git a/packages/api/src/db/seed-e2e-catalog.ts b/packages/api/src/db/seed-e2e-catalog.ts new file mode 100644 index 0000000000..574509c013 --- /dev/null +++ b/packages/api/src/db/seed-e2e-catalog.ts @@ -0,0 +1,214 @@ +/** + * Seed a handful of catalog items so the catalog-tab and catalog-search + * Playwright tests have data to render and filter against. Idempotent on + * SKU — re-running won't duplicate rows. + * + * Usage: + * NEON_DATABASE_URL= OPENAI_API_KEY= \ + * bun run packages/api/src/db/seed-e2e-catalog.ts + * + * Why this script exists: in production the `catalog_items` table is + * populated by the ETL workflow scraping product pages. Local dev DBs + * (docker-compose.test.yml) start empty, so anything that scrolls the + * catalog tab or runs a similarity search has nothing to find. + */ + +import { neon, neonConfig } from '@neondatabase/serverless'; +import { nodeEnv } from '@packrat/env/node'; +import { drizzle, type NeonHttpDatabase } from 'drizzle-orm/neon-http'; +import { drizzle as drizzlePg, type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { Client } from 'pg'; +import WebSocket from 'ws'; +import * as schema from './schema'; + +neonConfig.webSocketConstructor = WebSocket; + +const isStandardPostgresUrl = (url: string) => { + try { + const u = new URL(url); + const host = u.hostname.toLowerCase(); + const isNeonTech = host === 'neon.tech' || host.endsWith('.neon.tech'); + const isNeonCom = host === 'neon.com' || host.endsWith('.neon.com'); + return u.protocol === 'postgres:' && !isNeonTech && !isNeonCom; + } catch { + return false; + } +}; + +type SeedItem = { + sku: string; + name: string; + description: string; + weight: number; + weightUnit: 'g' | 'oz'; + categories: string[]; + brand?: string; +}; + +// A small, hand-picked set covering distinct gear categories so the catalog +// search test ("sleeping bag") and the add-from-catalog test (just needs at +// least one item) both have meaningful hits. +const ITEMS: SeedItem[] = [ + { + sku: 'e2e-sleeping-bag-20f', + name: 'Mountain Loft Down Sleeping Bag 20°F', + description: 'Lightweight down sleeping bag rated to 20°F for 3-season backpacking.', + weight: 980, + weightUnit: 'g', + categories: ['Sleep System'], + brand: 'TrailLab', + }, + { + sku: 'e2e-sleeping-bag-30f', + name: 'Sierra Lite Sleeping Bag 30°F', + description: 'Compact synthetic sleeping bag for summer hiking.', + weight: 720, + weightUnit: 'g', + categories: ['Sleep System'], + brand: 'PackRat Gear', + }, + { + sku: 'e2e-tent-2p', + name: 'Cascade 2P Backpacking Tent', + description: 'Two-person freestanding tent, 1.6 kg packed weight, double-wall.', + weight: 1600, + weightUnit: 'g', + categories: ['Shelter'], + brand: 'TrailLab', + }, + { + sku: 'e2e-backpack-55l', + name: '55L Hiking Backpack', + description: 'Internal frame pack with hydration sleeve and rain cover.', + weight: 1500, + weightUnit: 'g', + categories: ['Packs'], + brand: 'PackRat Gear', + }, + { + sku: 'e2e-stove-canister', + name: 'Spark Mini Canister Stove', + description: 'Pocket-sized canister stove, boils 500ml in 3:30.', + weight: 78, + weightUnit: 'g', + categories: ['Cooking'], + }, + { + sku: 'e2e-headlamp', + name: 'Beam 350 Headlamp', + description: 'Rechargeable 350-lumen headlamp with red-light mode.', + weight: 68, + weightUnit: 'g', + categories: ['Lighting'], + }, + { + sku: 'e2e-water-filter', + name: 'Squeeze Water Filter', + description: 'Hollow-fiber filter that removes 99.99% of bacteria and protozoa.', + weight: 90, + weightUnit: 'g', + categories: ['Water'], + }, + { + sku: 'e2e-rain-jacket', + name: 'Stormshield Rain Jacket', + description: 'Three-layer waterproof breathable shell with pit zips.', + weight: 320, + weightUnit: 'g', + categories: ['Apparel'], + }, + { + sku: 'e2e-puffy', + name: 'Featherlite 800-Fill Down Jacket', + description: 'Insulated puffy jacket for cold belays and camp wear.', + weight: 290, + weightUnit: 'g', + categories: ['Apparel'], + brand: 'PackRat Gear', + }, + { + sku: 'e2e-sleeping-pad', + name: 'Aircell Insulated Sleeping Pad', + description: 'R-value 4.2 inflatable sleeping pad, 460 g packed.', + weight: 460, + weightUnit: 'g', + categories: ['Sleep System'], + }, +]; + +async function embedAll(values: string[], openAiKey: string): Promise { + const res = await fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${openAiKey}`, + }, + body: JSON.stringify({ model: 'text-embedding-3-small', input: values }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`OpenAI embeddings failed ${res.status}: ${body.slice(0, 200)}`); + } + const json = (await res.json()) as { data: Array<{ embedding: number[] }> }; + return json.data.map((d) => d.embedding); +} + +async function seedCatalog() { + const dbUrl = nodeEnv.NEON_DATABASE_URL; + const openAiKey = nodeEnv.OPENAI_API_KEY; + if (!dbUrl) throw new Error('NEON_DATABASE_URL is required'); + if (!openAiKey) throw new Error('OPENAI_API_KEY is required'); + + type SeedDatabase = NodePgDatabase | NeonHttpDatabase; + let db: SeedDatabase; + let pgClient: Client | undefined; + + if (isStandardPostgresUrl(dbUrl)) { + pgClient = new Client({ connectionString: dbUrl }); + await pgClient.connect(); + db = drizzlePg(pgClient, { schema }); + } else { + db = drizzle(neon(dbUrl), { schema }); + } + + try { + const existing = await db.select({ sku: schema.catalogItems.sku }).from(schema.catalogItems); + const knownSkus = new Set(existing.map((r) => r.sku)); + const newItems = ITEMS.filter((i) => !knownSkus.has(i.sku)); + if (newItems.length === 0) { + console.log(`Catalog already seeded (${existing.length} rows).`); + return; + } + + console.log(`Generating ${newItems.length} embeddings via OpenAI...`); + const embeddings = await embedAll( + newItems.map((i) => `${i.name}. ${i.description}`), + openAiKey, + ); + + for (let i = 0; i < newItems.length; i++) { + const item = newItems[i]; + const embedding = embeddings[i]; + if (!item || !embedding) continue; + await db.insert(schema.catalogItems).values({ + name: item.name, + productUrl: `https://example.com/${item.sku}`, + sku: item.sku, + weight: item.weight, + weightUnit: item.weightUnit, + description: item.description, + categories: item.categories, + brand: item.brand ?? null, + embedding, + }); + } + console.log(`Seeded ${newItems.length} catalog items.`); + } finally { + await pgClient?.end(); + } +} + +seedCatalog().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/api/src/db/seed-e2e-user.ts b/packages/api/src/db/seed-e2e-user.ts index 0a7a23dcbe..65feab5b68 100644 --- a/packages/api/src/db/seed-e2e-user.ts +++ b/packages/api/src/db/seed-e2e-user.ts @@ -11,7 +11,7 @@ import { neon, neonConfig } from '@neondatabase/serverless'; import { nodeEnv } from '@packrat/env/node'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { drizzle, type NeonHttpDatabase } from 'drizzle-orm/neon-http'; import { drizzle as drizzlePg, type NodePgDatabase } from 'drizzle-orm/node-postgres'; import { Client } from 'pg'; @@ -64,13 +64,15 @@ async function seedE2EUser() { .where(eq(schema.users.email, normalizedEmail)) .limit(1); + let userId: string; const existingUser = existing[0]; if (existingUser) { + userId = existingUser.id; await db .update(schema.users) .set({ passwordHash, emailVerified: true, updatedAt: new Date() }) - .where(eq(schema.users.id, existingUser.id)); - console.log(`E2E user refreshed: ${normalizedEmail} (id=${existingUser.id})`); + .where(eq(schema.users.id, userId)); + console.log(`E2E user refreshed: ${normalizedEmail} (id=${userId})`); } else { const [inserted] = await db .insert(schema.users) @@ -85,7 +87,37 @@ async function seedE2EUser() { role: 'USER', }) .returning(); - console.log(`E2E user created: ${normalizedEmail} (id=${inserted?.id})`); + if (!inserted) throw new Error('users insert returned no row'); + userId = inserted.id; + console.log(`E2E user created: ${normalizedEmail} (id=${userId})`); + } + + // Better Auth's email/password sign-in reads the password from the + // `account` table (providerId='credential'), not `users.password_hash`. + // The 0042 data-migration only copies users → account at migrate time, so + // any user created after that migration (this seed, fresh dev DBs) needs + // an explicit credential row to be sign-in-able. + const existingAccount = await db + .select({ id: schema.account.id }) + .from(schema.account) + .where(and(eq(schema.account.userId, userId), eq(schema.account.providerId, 'credential'))) + .limit(1); + + if (existingAccount[0]) { + await db + .update(schema.account) + .set({ password: passwordHash, updatedAt: new Date() }) + .where(eq(schema.account.id, existingAccount[0].id)); + console.log(`Credential account refreshed for ${normalizedEmail}`); + } else { + await db.insert(schema.account).values({ + id: crypto.randomUUID(), + accountId: userId, + providerId: 'credential', + userId, + password: passwordHash, + }); + console.log(`Credential account created for ${normalizedEmail}`); } } finally { await pgClient?.end(); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 0ff3fa9edd..f871f2cd63 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,6 +8,7 @@ import type { MessageBatch } from '@cloudflare/workers-types'; import { cors } from '@elysiajs/cors'; +import { neonConfig } from '@neondatabase/serverless'; import { getAuth } from '@packrat/api/auth'; import { AppContainer } from '@packrat/api/containers'; import { routes } from '@packrat/api/routes'; @@ -20,6 +21,35 @@ import { Elysia } from 'elysia'; import { CloudflareAdapter } from 'elysia/adapter/cloudflare-worker'; import type { CatalogETLMessage } from './services/etl/types'; +// Local-dev hook: route `@neondatabase/serverless` through Neon's official +// local proxy (`ghcr.io/timowilhelm/local-neon-http-proxy`, recommended by +// https://neon.com/guides/local-development-with-neon) when NEON_DATABASE_URL +// points at the local `db.localtest.me` host. The proxy serves both the HTTP +// /sql API (used by neon-http, which is what auth/index.ts uses) and the +// WebSocket /v2 endpoint (used by neon-serverless Pool) — so prod and local +// share the exact same driver code paths with no adapter switch. +let neonLocalConfigured = false; +function maybeConfigureLocalNeon(databaseUrl: string, proxyPortOverride?: string): void { + if (neonLocalConfigured) return; + try { + const u = new URL(databaseUrl); + const host = u.hostname.toLowerCase(); + const isNeon = + host === 'neon.tech' || + host.endsWith('.neon.tech') || + host === 'neon.com' || + host.endsWith('.neon.com'); + if (isNeon) return; + const proxyPort = proxyPortOverride ?? '4444'; + neonConfig.fetchEndpoint = (h) => + h === 'db.localtest.me' ? `http://${h}:${proxyPort}/sql` : `https://${h}/sql`; + neonConfig.wsProxy = (h) => (h === 'db.localtest.me' ? `${h}:${proxyPort}/v2` : `${h}/v2`); + neonConfig.useSecureWebSocket = host !== 'db.localtest.me'; + } finally { + neonLocalConfigured = true; + } +} + export const app = new Elysia({ adapter: CloudflareAdapter }) .use( cors({ @@ -92,13 +122,62 @@ export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const e = enrichEnv(env); setWorkerEnv(e as unknown as Record); // safe-cast: setWorkerEnv accepts Record; ValidatedEnv has no index signature by design + maybeConfigureLocalNeon(e.NEON_DATABASE_URL, e.NEON_LOCAL_PROXY_PORT); // Route /api/auth/** to Better Auth before Elysia sees it. const url = new URL(request.url); if (url.pathname.startsWith('/api/auth')) { + // Better Auth does not implement CORS preflight (OPTIONS) responses, so + // we mirror the Elysia CORS allowlist here. Without this, browser-based + // sign-in calls from the web app (a different origin than the API) fail + // the preflight and never reach Better Auth. + const origin = request.headers.get('Origin'); + const isAllowedOrigin = + !!origin && + [ + /^https:\/\/(www\.)?packrat\.world$/, + /^https:\/\/[\w-]+\.packrat\.world$/, + /^https:\/\/[\w-]+\.packratai\.com$/, + /^https?:\/\/[\w-]+\.workers\.dev$/, + /^http:\/\/localhost:\d+$/, + /^exp:\/\//, + ].some((re) => re.test(origin)); + + const corsHeaders: Record = isAllowedOrigin + ? { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Credentials': 'true', + Vary: 'Origin', + } + : {}; + + if (request.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + ...corsHeaders, + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': + request.headers.get('Access-Control-Request-Headers') ?? + 'Content-Type, Authorization, X-API-Key', + 'Access-Control-Max-Age': '86400', + }, + }); + } + const validatedEnv = getEnv(); const auth = await getAuth(validatedEnv); - return auth.handler(request); + const authResponse = await auth.handler(request); + if (!isAllowedOrigin) return authResponse; + // Copy Better Auth's response and append CORS headers so cookies/JSON + // payloads reach the cross-origin caller. + const headers = new Headers(authResponse.headers); + for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v); + return new Response(authResponse.body, { + status: authResponse.status, + statusText: authResponse.statusText, + headers, + }); } return (app.fetch as unknown as CfFetchFn)(request, e, ctx); // safe-cast: Elysia's fetch has Cloudflare-specific env/ctx params not in the standard type diff --git a/packages/api/src/utils/env-validation.ts b/packages/api/src/utils/env-validation.ts index 93b8cba0d0..9c2028d65b 100644 --- a/packages/api/src/utils/env-validation.ts +++ b/packages/api/src/utils/env-validation.ts @@ -15,6 +15,10 @@ export const apiEnvSchema = z.object({ // Optional: trail routes return 503 when absent. For Cloudflare Workers, // set to env.OSM_HYPERDRIVE.connectionString (Hyperdrive binding). OSM_DATABASE_URL: z.string().url().optional(), + // Local-only override for the host port of the local-neon-http-proxy + // (docker-compose.test.yml). Worker entry routes the neon driver to this + // port when NEON_DATABASE_URL points at db.localtest.me. Defaults to 4444. + NEON_LOCAL_PROXY_PORT: z.string().regex(/^\d+$/).optional(), // Better Auth BETTER_AUTH_SECRET: z.string().min(32), diff --git a/packages/env/src/node.ts b/packages/env/src/node.ts index b7747c773c..de87be390d 100644 --- a/packages/env/src/node.ts +++ b/packages/env/src/node.ts @@ -72,6 +72,9 @@ export const nodeEnvSchema = z.object({ // ── E2E test credentials ────────────────────────────────────────── E2E_TEST_EMAIL: z.string().email().optional(), E2E_TEST_PASSWORD: z.string().min(1).optional(), + + // ── OpenAI (packages/api/src/db/seed-e2e-catalog.ts) ────────────── + OPENAI_API_KEY: z.string().min(1).optional(), }); export type NodeEnv = z.infer; @@ -107,4 +110,5 @@ export const nodeEnv = nodeEnvSchema.parse({ DEBUG: process.env.DEBUG, E2E_TEST_EMAIL: process.env.E2E_TEST_EMAIL, E2E_TEST_PASSWORD: process.env.E2E_TEST_PASSWORD, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, }); diff --git a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch b/patches/@packrat-ai%2Fnativewindui@2.0.3.patch index d4837e9502..515c4c1ac1 100644 --- a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch +++ b/patches/@packrat-ai%2Fnativewindui@2.0.3.patch @@ -17,7 +17,7 @@ index 786b62c4a216c360beb193b96092186319a634cb..741aefba1ddf080c103cfca27b14b10c import type MaterialIcons from '@expo/vector-icons/MaterialIcons'; import type { SymbolViewProps } from 'expo-symbols'; diff --git a/src/components/LargeTitleHeader/LargeTitleHeader.tsx b/src/components/LargeTitleHeader/LargeTitleHeader.tsx -index 95c66bbdece307542084c2e510c847543134f63e..d9bf7337f45a9b49f69863a16a24672363a3b8af 100644 +index 95c66bbdece307542084c2e510c847543134f63e..1d51bc059291f852567c8123ee93ef5f433a9dfb 100644 --- a/src/components/LargeTitleHeader/LargeTitleHeader.tsx +++ b/src/components/LargeTitleHeader/LargeTitleHeader.tsx @@ -1,3 +1,4 @@ @@ -25,3 +25,11 @@ index 95c66bbdece307542084c2e510c847543134f63e..d9bf7337f45a9b49f69863a16a246723 import { useRoute } from '@react-navigation/native'; import { useAugmentedRef } from '@rn-primitives/hooks'; import { Portal } from '@rn-primitives/portal'; +@@ -157,6 +158,7 @@ export function LargeTitleHeader(props: LargeTitleHeaderProps) { + + {!!props.searchBar && ( +