diff --git a/AGENTS.md b/AGENTS.md index 6c34323b..5b0cd7c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,9 @@ See [docs/architecture/powersync-account-devices.md](docs/architecture/powersync ## CORS and API headers -When adding new custom headers to API requests (e.g. `X-Device-ID`, `X-Device-Name`), update `backend/src/config/settings.ts` so `corsAllowHeaders` includes them. Otherwise CORS preflight will fail and requests from the browser will be blocked. +Both the main API (`backend/src/index.ts`) and the PostHog proxy route (`backend/src/posthog/routes.ts`) use `cors({ allowedHeaders: true })`, which echoes back whatever the browser requests in `Access-Control-Request-Headers`. This is required by the universal proxy at `/v1/proxy`, which forwards arbitrary upstream headers as `X-Proxy-Passthrough-*` (LLM SDKs add `x-api-key`, `x-stainless-*`, `openai-organization`, etc. — a static allowlist would break preflight whenever a new provider header appears). Adding a new custom header to any request requires no CORS-config change. + +If you ever need a browser-readable response header in cross-origin code, you must add it to `corsExposeHeaders` in `backend/src/config/settings.ts` — browsers expose only the headers listed there to `Response.headers` cross-origin. ## Responsive Sizing diff --git a/backend/.env.example b/backend/.env.example index 2638ff91..dfc6201e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -62,8 +62,12 @@ SAML_CERT= CORS_ORIGINS=http://localhost:1420,tauri://localhost,http://tauri.localhost CORS_ALLOW_CREDENTIALS=true CORS_ALLOW_METHODS=GET,POST,PUT,DELETE,PATCH,OPTIONS -CORS_ALLOW_HEADERS=Content-Type,Authorization,Accept,Accept-Encoding,Accept-Language,Cache-Control,User-Agent,X-Requested-With,X-Client-Platform,X-Device-ID,X-Device-Name,X-Challenge-Token,X-Mcp-Target-Url,Mcp-Authorization,Mcp-Session-Id,Mcp-Protocol-Version -CORS_EXPOSE_HEADERS=mcp-session-id,set-auth-token,ratelimit-limit,ratelimit-remaining,ratelimit-reset,retry-after +# CORS_ALLOW_HEADERS is intentionally unset: the main backend uses +# `cors({ allowedHeaders: true })`, which echoes the request's +# Access-Control-Request-Headers. Only override if you mount a route group +# that pins a static allowlist (e.g. the PostHog proxy). +# Defaults to protocol-required X-Proxy-* + set-auth-token; override only if you need additional expose headers. +# CORS_EXPOSE_HEADERS=set-auth-token,X-Proxy-Final-Url,X-Proxy-Passthrough-Content-Type,X-Proxy-Passthrough-Mcp-Session-Id,X-Proxy-Passthrough-Mcp-Protocol-Version,X-Proxy-Passthrough-Location,X-Proxy-Passthrough-Anthropic-Version # === Feature flags === # E2E encryption — set to "true" to require device trust flow before sync. diff --git a/backend/eslint.config.js b/backend/eslint.config.js index 5a7457d8..dea00253 100644 --- a/backend/eslint.config.js +++ b/backend/eslint.config.js @@ -22,8 +22,11 @@ export default [ ...globals.node, ...globals.es2022, BodyInit: 'readonly', + HeadersInit: 'readonly', RequestInfo: 'readonly', RequestInit: 'readonly', + // Bun runtime globals + Bun: 'readonly', }, }, plugins: { diff --git a/backend/package.json b/backend/package.json index 70336aae..4fb0d00b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "description": "Thunderbolt Backend rewritten with TypeScript + Elysia", "type": "module", "scripts": { - "dev": "bun run --watch src/index.ts", + "dev": "./scripts/dev.sh", "build": "bun build --compile --minify-whitespace --minify-syntax --target bun --outfile server src/cluster.ts", "start": "./server", "test": "bun test", diff --git a/backend/src/api/preview.e2e.test.ts b/backend/src/api/preview.e2e.test.ts new file mode 100644 index 00000000..d813df82 --- /dev/null +++ b/backend/src/api/preview.e2e.test.ts @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { afterEach, describe, expect, it } from 'bun:test' + +import { + authHeaders, + createTestApp, + createTestUpstream, + createUpstreamRouter, + type TestAppHandle, +} from '@/test-utils/e2e' + +const buildHtml = (body: string) => `
${body}` + +describe('GET /v1/preview — e2e', () => { + let handle: TestAppHandle + + afterEach(async () => { + if (handle) { + await handle.cleanup() + } + }) + + it('returns OG metadata with HTTPS-upgraded image, title, summary, siteName', async () => { + const upstream = createTestUpstream( + 'preview.test', + () => + new Response( + buildHtml(` + + + + + `), + { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } }, + ), + ) + handle = await createTestApp({ fetchFn: createUpstreamRouter({ 'preview.test': upstream }) }) + + const res = await handle.app.handle( + new Request(`http://localhost/v1/preview`, { + method: 'POST', + headers: { ...authHeaders(handle.bearerToken), 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://preview.test/article' }), + }), + ) + expect(res.status).toBe(200) + // Italo's review: per-user 10 min cache; no shared/CDN cache (`private`). + expect(res.headers.get('cache-control')).toBe('private, max-age=600') + const data = (await res.json()) as Record