Files
worldmonitor/tests/deploy-config.test.mjs
Elie Habib 4853645d53 fix(brief): switch carousel to @vercel/og on edge runtime (#3210)
* fix(brief): switch carousel to @vercel/og on edge runtime

Every attempt to ship the Phase 8 Telegram carousel on Vercel's
Node serverless runtime has failed at cold start:

- PR #3174 direct satori + @resvg/resvg-wasm: Vercel edge bundler
  refused the `?url` asset import required by resvg-wasm.
- PR #3174 (fix) direct satori + @resvg/resvg-js native binding:
  Node runtime accepted it, but Vercel's nft tracer does not follow
  @resvg/resvg-js/js-binding.js's conditional
  `require('@resvg/resvg-js-<platform>-<arch>-<libc>')` pattern,
  so the linux-x64-gnu peer package was never bundled. Cold start
  threw MODULE_NOT_FOUND, isolate crashed,
  FUNCTION_INVOCATION_FAILED on every request including OPTIONS,
  and Telegram reported WEBPAGE_CURL_FAILED with no other signal.
- PR #3204 added `vercel.json` `functions.includeFiles` to force
  the binding in, but (a) the initial key was a literal path that
  Vercel micromatch read as a character class (PR #3206 fixed),
  (b) even with the corrected `api/brief/carousel/**` wildcard, the
  function still 500'd across the board. The `functions.includeFiles`
  path appears honored in the deployment manifest but not at runtime
  for this particular native-binding pattern.

Fix: swap the renderer to @vercel/og's ImageResponse, which is
Vercel's first-party wrapper around satori + resvg-wasm with
Vercel-native bundling. Runs on Edge runtime — matches every other
API route in the project. No native binding, no includeFiles, no
nft tracing surprises. Cold start ~300ms, warm ~30ms.

Changes:
- server/_shared/brief-carousel-render.ts: replace renderCarouselPng
  (Uint8Array) with renderCarouselImageResponse (ImageResponse).
  Drop ensureLibs + satori + @resvg/resvg-js dynamic-import dance.
  Keep layout builders (buildCover/buildThreads/buildStory) and
  font loading unchanged — the Satori object trees are
  wire-compatible with ImageResponse.
- api/brief/carousel/[userId]/[issueDate]/[page].ts: flip
  `runtime: 'nodejs'` -> `runtime: 'edge'`. Delegate rendering to
  the renderer's ImageResponse and return it directly; error path
  still 503 no-store so CDN + Telegram don't pin a bad render.
- vercel.json: drop the now-useless `functions.includeFiles` block.
- package.json: drop direct `@resvg/resvg-js` and `satori` deps
  (both now bundled inside @vercel/og).
- tests/deploy-config.test.mjs: replace the native-binding
  regression guards with an assertion that no `functions` block
  exists (with a comment pointing at the skill documenting the
  micromatch gotcha for future routes).
- tests/brief-carousel.test.mjs: updated comment references.

Verified:
- typecheck + typecheck:api clean
- test:data 5814/5814 pass
- node -e test: @vercel/og imports cleanly in Node (tests that
  reach through the renderer file no longer depend on native
  bindings)

Post-deploy validation:
  curl -I -H "User-Agent: TelegramBot (like TwitterBot)" \
    "https://www.worldmonitor.app/api/brief/carousel/<uid>/<slot>/0"
  # Expect: HTTP/2 403 (no token) or 200 (valid token)
  # NOT:    HTTP/2 500 FUNCTION_INVOCATION_FAILED

Then tail Railway digest logs on the next tick; the
`[digest] Telegram carousel 400 ... WEBPAGE_CURL_FAILED` line
should stop appearing, and the 3-image preview should actually land
on Telegram.

* Add renderer smoke test + fix Cache-Control duplication

Reviewer flagged residual risk: no dedicated carousel-route smoke
test for the @vercel/og path. Adds one, and catches a real bug in
the process.

Findings during test-writing:

1. @vercel/og's ImageResponse runs CLEANLY in Node via tsx — the
   comment in brief-carousel.test.mjs saying "we can't test the
   render in Node" was true for direct satori + @resvg/resvg-wasm
   but no longer holds after PR #3210. Pure Node render works
   end-to-end: satori tree-parse, jsdelivr font fetch, resvg-wasm
   init, PNG output. ~850ms first call, ~20ms warm.

2. ImageResponse sets its own default
   `Cache-Control: public, immutable, no-transform, max-age=31536000`.
   Passing Cache-Control via the constructor's headers option
   APPENDS rather than overrides, producing a duplicated
   comma-joined value like
   `public, immutable, no-transform, max-age=31536000, public, max-age=60`
   on the Response. The route handler was doing exactly this via
   extraHeaders. Fix: drop our Cache-Control override and rely on
   @vercel/og's 1-year immutable default — envelope is only
   immutable for its 7d Redis TTL so the effective ceiling is 7d
   anyway (after that the route 404s before render).

Changes:

- tests/brief-carousel.test.mjs: 6 new assertions under
  `renderCarouselImageResponse`:
    * renders cover / threads / story pages, each returning a
      valid PNG (magic bytes + size range)
    * rejects a structurally empty envelope
    * threads non-cache extraHeaders onto the Response
    * pins @vercel/og's Cache-Control default so it survives
      caller-supplied Cache-Control overrides (regression guard
      for the bug fixed in this commit)
- api/brief/carousel/[userId]/[issueDate]/[page].ts: remove the
  stacked Cache-Control; lean on @vercel/og default. Drop the now-
  unused `PAGE_CACHE_TTL` constant. Comment explains why.

Verified:
- test:data 5820/5820 pass (was 5814, +6 smoke)
- typecheck + typecheck:api clean
- Render smoke: cover 825ms / threads 23ms / story 16ms first run
  (wasm init dominates first render)
2026-04-19 15:18:12 +04:00

310 lines
14 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const vercelConfig = JSON.parse(readFileSync(resolve(__dirname, '../vercel.json'), 'utf-8'));
const viteConfigSource = readFileSync(resolve(__dirname, '../vite.config.ts'), 'utf-8');
const getCacheHeaderValue = (sourcePath) => {
const rule = vercelConfig.headers.find((entry) => entry.source === sourcePath);
const header = rule?.headers?.find((item) => item.key.toLowerCase() === 'cache-control');
return header?.value ?? null;
};
describe('deploy/cache configuration guardrails', () => {
it('disables caching for HTML entry routes on Vercel', () => {
const spaNoCache = getCacheHeaderValue('/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known|wm-widget-sandbox\\.html).*)');
assert.equal(spaNoCache, 'no-cache, no-store, must-revalidate');
});
it('keeps immutable caching for hashed static assets', () => {
assert.equal(
getCacheHeaderValue('/assets/(.*)'),
'public, max-age=31536000, immutable'
);
});
it('keeps PWA precache glob free of HTML files', () => {
assert.match(
viteConfigSource,
/globPatterns:\s*\['\*\*\/\*\.\{js,css,ico,png,svg,woff2\}'\]/
);
assert.doesNotMatch(viteConfigSource, /globPatterns:\s*\['\*\*\/\*\.\{js,css,html/);
});
it('explicitly disables navigateFallback when HTML is not precached', () => {
assert.match(viteConfigSource, /navigateFallback:\s*null/);
assert.doesNotMatch(viteConfigSource, /navigateFallbackDenylist:\s*\[/);
});
it('uses network-only runtime caching for navigation requests', () => {
assert.match(viteConfigSource, /request\.mode === 'navigate'/);
assert.match(viteConfigSource, /handler:\s*'NetworkOnly'/);
});
it('contains variant-specific metadata fields used by html replacement and manifest', () => {
const variantMetaSource = readFileSync(resolve(__dirname, '../src/config/variant-meta.ts'), 'utf-8');
assert.match(variantMetaSource, /shortName:\s*'/);
assert.match(variantMetaSource, /subject:\s*'/);
assert.match(variantMetaSource, /classification:\s*'/);
assert.match(variantMetaSource, /categories:\s*\[/);
assert.match(
viteConfigSource,
/\.replace\(\/<meta name="subject" content="\.\*\?" \\\/>\/,\s*`<meta name="subject"/
);
assert.match(
viteConfigSource,
/\.replace\(\/<meta name="classification" content="\.\*\?" \\\/>\/,\s*`<meta name="classification"/
);
});
});
const getSecurityHeaders = () => {
const rule = vercelConfig.headers.find((entry) => entry.source === '/((?!docs).*)');
return rule?.headers ?? [];
};
const getHeaderValue = (key) => {
const headers = getSecurityHeaders();
const header = headers.find((h) => h.key.toLowerCase() === key.toLowerCase());
return header?.value ?? null;
};
describe('security header guardrails', () => {
it('includes all 5 required security headers on catch-all route', () => {
const required = [
'X-Content-Type-Options',
'Strict-Transport-Security',
'Referrer-Policy',
'Permissions-Policy',
'Content-Security-Policy',
];
const headerKeys = getSecurityHeaders().map((h) => h.key);
for (const name of required) {
assert.ok(headerKeys.includes(name), `Missing security header: ${name}`);
}
});
it('Permissions-Policy disables all expected browser APIs', () => {
const policy = getHeaderValue('Permissions-Policy');
const expectedDisabled = [
'camera=()',
'microphone=()',
'accelerometer=()',
'bluetooth=()',
'display-capture=()',
'gyroscope=()',
'hid=()',
'idle-detection=()',
'magnetometer=()',
'midi=()',
'payment=(self "https://checkout.dodopayments.com" "https://test.checkout.dodopayments.com" "https://pay.google.com" "https://hooks.stripe.com" "https://js.stripe.com")',
'screen-wake-lock=()',
'serial=()',
'usb=()',
'xr-spatial-tracking=("https://challenges.cloudflare.com")',
];
for (const directive of expectedDisabled) {
assert.ok(policy.includes(directive), `Permissions-Policy missing: ${directive}`);
}
});
it('Permissions-Policy delegates media APIs to allowed origins', () => {
const policy = getHeaderValue('Permissions-Policy');
// autoplay and encrypted-media delegate to self + YouTube
for (const api of ['autoplay', 'encrypted-media']) {
assert.match(
policy,
new RegExp(`${api}=\\(self "https://www\\.youtube\\.com" "https://www\\.youtube-nocookie\\.com"\\)`),
`Permissions-Policy should delegate ${api} to YouTube origins`
);
}
// geolocation delegates to self (used by user-location.ts)
assert.ok(
policy.includes('geolocation=(self)'),
'Permissions-Policy should delegate geolocation to self'
);
// picture-in-picture delegates to self + YouTube + Turnstile
assert.match(
policy,
/picture-in-picture=\(self "https:\/\/www\.youtube\.com" "https:\/\/www\.youtube-nocookie\.com" "https:\/\/challenges\.cloudflare\.com"\)/,
'Permissions-Policy should delegate picture-in-picture to YouTube + Turnstile origins'
);
});
it('CSP connect-src does not allow unencrypted WebSocket (ws:)', () => {
const csp = getHeaderValue('Content-Security-Policy');
const connectSrc = csp.match(/connect-src\s+([^;]+)/)?.[1] ?? '';
assert.ok(!connectSrc.includes(' ws:'), 'CSP connect-src must not contain ws: (unencrypted WebSocket)');
assert.ok(connectSrc.includes('wss:'), 'CSP connect-src should keep wss: for secure WebSocket');
});
it('CSP connect-src https: scheme is consistent between header and meta tag', () => {
const indexHtml = readFileSync(resolve(__dirname, '../index.html'), 'utf-8');
const headerCsp = getHeaderValue('Content-Security-Policy');
const metaMatch = indexHtml.match(/http-equiv="Content-Security-Policy"\s+content="([^"]*)"/i);
assert.ok(metaMatch, 'index.html must have a CSP meta tag');
const headerConnectSrc = headerCsp.match(/connect-src\s+([^;]+)/)?.[1] ?? '';
const metaConnectSrc = metaMatch[1].match(/connect-src\s+([^;]+)/)?.[1] ?? '';
const headerHasHttps = /\bhttps:\b/.test(headerConnectSrc);
const metaHasHttps = /\bhttps:\b/.test(metaConnectSrc);
// The CSP violation listener suppresses HTTPS connect-src violations when the meta tag
// contains https: in connect-src. If the header is tightened without the meta tag,
// real violations would be silently suppressed. Both must stay in sync.
assert.equal(headerHasHttps, metaHasHttps,
`connect-src https: scheme mismatch: header=${headerHasHttps}, meta=${metaHasHttps}. ` +
'If removing https: from connect-src, update the CSP violation listener in main.ts too.');
});
it('CSP connect-src does not contain localhost in production', () => {
const csp = getHeaderValue('Content-Security-Policy');
const connectSrc = csp.match(/connect-src\s+([^;]+)/)?.[1] ?? '';
assert.ok(!connectSrc.includes('http://localhost'), 'CSP connect-src must not contain http://localhost in production');
});
it('CSP script-src includes wasm-unsafe-eval for WebAssembly support', () => {
const csp = getHeaderValue('Content-Security-Policy');
const scriptSrc = csp.match(/script-src\s+([^;]+)/)?.[1] ?? '';
assert.ok(scriptSrc.includes("'wasm-unsafe-eval'"), 'CSP script-src must include wasm-unsafe-eval for WASM support');
assert.ok(scriptSrc.includes("'self'"), 'CSP script-src must include self');
});
it('CSP script-src includes Clerk origin for auth UI', () => {
const csp = getHeaderValue('Content-Security-Policy');
const scriptSrc = csp.match(/script-src\s+([^;]+)/)?.[1] ?? '';
assert.ok(
scriptSrc.includes('clerk.accounts.dev') || scriptSrc.includes('clerk.worldmonitor.app'),
'CSP script-src must include Clerk origin for auth UI to load'
);
});
it('CSP frame-src includes Clerk origin for auth modals', () => {
const csp = getHeaderValue('Content-Security-Policy');
const frameSrc = csp.match(/frame-src\s+([^;]+)/)?.[1] ?? '';
assert.ok(
frameSrc.includes('clerk.accounts.dev') || frameSrc.includes('clerk.worldmonitor.app'),
'CSP frame-src must include Clerk origin for sign-in modal'
);
});
it('CSP script-src hashes are in sync between vercel.json header and index.html meta tag', () => {
const indexHtml = readFileSync(resolve(__dirname, '../index.html'), 'utf-8');
const headerCsp = getHeaderValue('Content-Security-Policy');
const metaMatch = indexHtml.match(/http-equiv="Content-Security-Policy"\s+content="([^"]*)"/i);
assert.ok(metaMatch, 'index.html must have a CSP meta tag');
const metaCsp = metaMatch[1];
const extractHashes = (csp) => {
const scriptSrc = csp.match(/script-src\s+([^;]+)/)?.[1] ?? '';
return new Set(scriptSrc.match(/'sha256-[A-Za-z0-9+/=]+'/g) ?? []);
};
const headerHashes = extractHashes(headerCsp);
const metaHashes = extractHashes(metaCsp);
const onlyHeader = [...headerHashes].filter(h => !metaHashes.has(h));
const onlyMeta = [...metaHashes].filter(h => !headerHashes.has(h));
assert.deepEqual(onlyHeader, [],
`script-src hashes in vercel.json but missing from index.html: ${onlyHeader.join(', ')}. ` +
'Dual CSP enforces both; mismatched hashes block scripts.');
assert.deepEqual(onlyMeta, [],
`script-src hashes in index.html but missing from vercel.json: ${onlyMeta.join(', ')}. ` +
'Dual CSP enforces both; mismatched hashes block scripts.');
});
it('security.txt exists in public/.well-known/', () => {
const secTxt = readFileSync(resolve(__dirname, '../public/.well-known/security.txt'), 'utf-8');
assert.match(secTxt, /^Contact:/m, 'security.txt must have a Contact field');
assert.match(secTxt, /^Expires:/m, 'security.txt must have an Expires field');
});
});
// Per-route CSP override for the hosted brief magazine. The renderer
// emits an inline <script> (swipe/arrow/wheel/touch nav IIFE) whose
// hash is NOT on the global script-src allowlist, so the catch-all
// CSP silently blocks it. This rule relaxes script-src to
// 'unsafe-inline' for /api/brief/* only. All Redis-sourced content
// flows through escapeHtml() in brief-render.js before interpolation,
// so unsafe-inline doesn't open an XSS surface.
const getBriefSecurityHeaders = () => {
const rule = vercelConfig.headers.find((entry) => entry.source === '/api/brief/(.*)');
return rule?.headers ?? [];
};
const getBriefCspValue = () => {
const headers = getBriefSecurityHeaders();
const header = headers.find((h) => h.key.toLowerCase() === 'content-security-policy');
return header?.value ?? null;
};
describe('brief magazine CSP override', () => {
it('rule exists for /api/brief/(.*) with a Content-Security-Policy header', () => {
const csp = getBriefCspValue();
assert.ok(csp, 'Missing per-route CSP override for /api/brief/(.*) — the magazine nav IIFE will be blocked');
});
it('script-src includes unsafe-inline so the nav IIFE can execute', () => {
const csp = getBriefCspValue();
const scriptSrc = csp.match(/script-src\s+([^;]+)/)?.[1] ?? '';
assert.ok(
scriptSrc.includes("'unsafe-inline'"),
"brief CSP script-src must include 'unsafe-inline' — without it swipe/arrow nav is silently blocked",
);
});
it('connect-src allows Cloudflare Insights analytics beacon to POST', () => {
const csp = getBriefCspValue();
const connectSrc = csp.match(/connect-src\s+([^;]+)/)?.[1] ?? '';
assert.ok(
connectSrc.includes('https://cloudflareinsights.com'),
'brief CSP connect-src must allow cloudflareinsights.com so the CF beacon can POST to /cdn-cgi/rum',
);
});
it('keeps tight defaults for non-script directives', () => {
const csp = getBriefCspValue();
for (const directive of [
"default-src 'self'",
"object-src 'none'",
"form-action 'none'",
"base-uri 'self'",
]) {
assert.ok(csp.includes(directive), `brief CSP missing tight directive: ${directive}`);
}
});
});
// PR history: #3204 / #3206 forced the resvg linux-x64-gnu native
// binding into the carousel function via vercel.json
// `functions.includeFiles`. That entire workaround became unnecessary
// once the route moved to @vercel/og on Edge runtime (see
// api/brief/carousel/...), which bundles satori + resvg-wasm with
// Vercel-native support. The `functions` block was removed.
//
// If any future route ever needs a Vercel `functions` config, keep
// in mind: the keys are micromatch globs, NOT literal paths.
// `[userId]` is a character class (match one of u/s/e/r/I/d), not a
// dynamic segment placeholder. Use `api/foo/**` for routes with
// dynamic brackets. See skill `vercel-native-binding-peer-dep-missing`
// for the full story.
describe('vercel.json functions config (none expected after carousel moved to edge)', () => {
it('does not define any `functions` block (carousel now uses @vercel/og on edge)', () => {
assert.equal(
vercelConfig.functions,
undefined,
'No routes currently require a functions config. If adding one, ' +
'remember Vercel treats the key as a micromatch glob — ' +
'`[userId]` will silently match one of {u,s,e,r,I,d} and your ' +
'rule will apply to nothing. See skill ' +
'vercel-native-binding-peer-dep-missing for the gotcha.',
);
});
});