Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/docs/frontend-assets.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"src/core/card-kind.js",
"src/core/collection-loader.js",
"src/core/dates.js",
"src/core/guarded-fetch.js",
"src/core/hovercard.js",
"src/core/html.js",
"src/core/icons.js",
Expand Down
3 changes: 2 additions & 1 deletion backend/src/docs/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { RUNTIME_ROOT } from '../runtimePaths';
import type { LambdaEvent, LambdaResponse } from '../types';
import { isAnonymousDocumentReviewAsset, isAnonymousDocumentReviewRequest } from '../documents/reviews';
import { isAnonymousSponsorDocAsset, isAnonymousSponsorDocRequest } from '../routes/sponsorDocs';
import { isClientErrorReport } from '../routes/clientErrors';
import { isAnonymousSponsorContractAsset } from '../routes/sponsorContracts';
import {
isAnonymousNewsletterPreviewAsset,
Expand Down Expand Up @@ -312,7 +313,7 @@ export async function handlePortal(event: LambdaEvent, client: DynamoDBDocumentC
const fe = serveFrontend(event, method, path);
if (fe) return { response: fe, authorized: false };
}
if (isAnonymousDocumentReviewRequest(method, path) || isAnonymousSponsorDocRequest(method, path) || isAnonymousNewsletterPreviewRequest(method, path)) {
if (isAnonymousDocumentReviewRequest(method, path) || isAnonymousSponsorDocRequest(method, path) || isClientErrorReport(method, path) || isAnonymousNewsletterPreviewRequest(method, path)) {
return { authorized: false };
}
// Existing non-browser bearer clients continue through the router's own
Expand Down
2 changes: 1 addition & 1 deletion backend/src/documents/reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function isAnonymousDocumentReviewRequest(method: string, path: string):
}
export function isAnonymousDocumentReviewAsset(method: string, path: string): boolean {
return method === 'GET' && (/^\/document-review\/[^/]+\/?$/.test(path)
|| ['/src/document-review-page.js', '/src/surfaces/document-reviews.js'].includes(path));
|| ['/src/document-review-page.js', '/src/core/guarded-fetch.js', '/src/surfaces/document-reviews.js'].includes(path));
}
// The document loader is provided by the run-document routes. All callers must
// check existence here; a deleted document never remains accessible by a link.
Expand Down
1 change: 1 addition & 0 deletions backend/src/newsletter/markdownPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function isAnonymousNewsletterPreviewAsset(method: string, path: string):
if (/^\/newsletter-issues\/[^/]+\/preview\/?$/.test(path)) return true;
return (
path === '/src/newsletter-preview-page.js'
|| path === '/src/core/guarded-fetch.js'
|| path === '/src/surfaces/operations/newsletter-preview.js'
|| path === '/src/styles/newsletter-preview.css'
|| path === '/src/styles/sponsor-docs.css'
Expand Down
4 changes: 4 additions & 0 deletions backend/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { handleKnowledgePublisher } from './auth/knowledgePublisher';
import { isScopedTokenRequestAllowed } from './auth/tokenScopes';
import { getApiToken, projectApiToken } from './db/apiTokens';
import { handleSponsorDocRoutes, isAnonymousSponsorDocRequest } from './routes/sponsorDocs';
import { handleClientErrorRoute, isClientErrorReport } from './routes/clientErrors';
import { handleEmailTemplateRoutes } from './routes/emailTemplates';
import { handleApiTokenRoutes } from './routes/apiTokens';
import { handleSponsorContractRoutes, isAnonymousSponsorContractRequest } from './routes/sponsorContracts';
Expand All @@ -86,6 +87,7 @@ const AUTH_EXEMPT_PATHS = new Set([
function isAuthExempt(method: string, path: string): boolean {
if (isAnonymousDocumentReviewRequest(method, path)) return true;
if (isAnonymousSponsorDocRequest(method, path)) return true;
if (isClientErrorReport(method, path)) return true;
if (isAnonymousSponsorContractRequest(method, path)) return true;
if (isAnonymousNewsletterPreviewRequest(method, path)) return true;
if (AUTH_EXEMPT_PATHS.has(path)) return true;
Expand Down Expand Up @@ -434,6 +436,8 @@ async function route(event: LambdaEvent, client: DynamoDBDocumentClient): Promis
if (result) return result;
}

if (isClientErrorReport(method, reqPath)) return handleClientErrorRoute(event);

if (reqPath.startsWith('/api/sponsor-docs')) {
const result = await handleSponsorDocRoutes(reqPath, method, event, client);
if (result) return result;
Expand Down
66 changes: 66 additions & 0 deletions backend/src/routes/clientErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { headerValue } from '../httpHeaders';
import { noStoreJson } from '../http';
import type { LambdaEvent, LambdaResponse } from '../types';

/**
* Browser-reported failures: a page's access check or API call failed or timed
* out and the visitor was shown an error. The page cannot be trusted to tell
* anyone itself, so it posts here and the failure lands in the server log as
* one structured ERROR line, where the same alerting as `api_request_failed`
* can pick it up.
*
* Anonymous (sponsor and guest pages have no session), so the payload is
* strictly bounded: fixed fields only, truncated, query strings stripped
* (they can carry access codes), and a per-IP budget per Lambda instance.
*/

export const CLIENT_CHECK_FAILED_EVENT = 'client_check_failed';

const MAX_FIELD_LENGTH = 300;
const BUDGET_PER_WINDOW = 10;
const WINDOW_MS = 60_000;

const budgets = new Map<string, { windowStart: number; count: number }>();

export function isClientErrorReport(method: string, path: string): boolean {
return method.toUpperCase() === 'POST' && /^\/api\/client-errors\/?$/.test(path);
}

function field(value: unknown): string {
return typeof value === 'string' ? value.slice(0, MAX_FIELD_LENGTH) : '';
}

function withinBudget(ip: string, now: number): boolean {
const entry = budgets.get(ip);
if (!entry || now - entry.windowStart >= WINDOW_MS) {
if (budgets.size > 1000) budgets.clear();
budgets.set(ip, { windowStart: now, count: 1 });
return true;
}
entry.count += 1;
return entry.count <= BUDGET_PER_WINDOW;
}

export function handleClientErrorRoute(event: LambdaEvent): LambdaResponse {
let body: Record<string, unknown> = {};
try {
const parsed = JSON.parse(event.body || '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) body = parsed as Record<string, unknown>;
} catch {
return noStoreJson(400, { error: 'Invalid JSON body' });
}
const ip = headerValue(event.headers, 'x-forwarded-for').split(',')[0].trim() || 'unknown';
if (!withinBudget(ip, Date.now())) return noStoreJson(429, { error: 'Too many reports' });
const status = Number(body.status);
console.error(JSON.stringify({
event: CLIENT_CHECK_FAILED_EVENT,
level: 'ERROR',
surface: field(body.surface),
check: field(body.check),
status: Number.isInteger(status) ? status : null,
message: field(body.message),
page: field(body.page).split(/[?#]/)[0],
userAgent: headerValue(event.headers, 'user-agent').slice(0, MAX_FIELD_LENGTH),
}));
return noStoreJson(204, {});
}
2 changes: 1 addition & 1 deletion backend/src/routes/sponsorContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,5 +495,5 @@ async function handleSigningRequest(
export function isAnonymousSponsorContractAsset(method: string, path: string): boolean {
if (method.toUpperCase() !== 'GET') return false;
if (/^\x2Fcontracts\x2F[^\x2F]+\x2F?$/.test(path)) return true;
return path === '/src/contract-sign-page.js' || path === '/src/surfaces/operations/contract-sign.js';
return path === '/src/contract-sign-page.js' || path === '/src/core/guarded-fetch.js' || path === '/src/core/html.js' || path === '/src/surfaces/operations/contract-sign.js';
}
3 changes: 3 additions & 0 deletions backend/src/routes/sponsorDocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,15 @@ export function isAnonymousSponsorDocAsset(method: string, path: string): boolea
if (/^\/(sponsor-docs|podcast-docs|guest-docs|webinar-docs|workshop-docs)\/[^/]+\/?$/.test(path)) return true;
return (
path === '/src/sponsor-doc-page.js'
|| path === '/src/core/guarded-fetch.js'
|| path === '/src/surfaces/operations/sponsor-docs.js'
|| path === '/src/surfaces/operations/sponsor-doc-gate.js'
|| path === '/src/surfaces/operations/sponsor-doc-select.js'
|| path === '/src/surfaces/operations/sponsor-doc-radio.js'
|| path === '/src/surfaces/operations/sponsor-doc-bullets.js'
|| path === '/src/surfaces/operations/sponsor-doc-email-card.js'
|| path === '/src/surfaces/operations/sponsor-doc-event-time.js'
|| path === '/src/surfaces/operations/sponsor-doc-guest-schedule.js'
|| path === '/src/styles/sponsor-docs.css'
|| path.startsWith('/src/dakit/')
);
Expand Down
55 changes: 55 additions & 0 deletions backend/tests/anonymous-page-assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';

import { isAnonymousDocumentReviewAsset } from '../src/documents/reviews';
import { isAnonymousNewsletterPreviewAsset } from '../src/newsletter/markdownPreview';
import { isAnonymousSponsorContractAsset } from '../src/routes/sponsorContracts';
import { isAnonymousSponsorDocAsset } from '../src/routes/sponsorDocs';

const FRONTEND_ROOT = resolve(__dirname, '../../frontend');

function importGraph(entry: string): Set<string> {
const seen = new Set<string>();
const queue = [join(FRONTEND_ROOT, entry)];
while (queue.length) {
const file = queue.pop() as string;
if (seen.has(file)) continue;
seen.add(file);
const source = readFileSync(file, 'utf8');
for (const match of source.matchAll(/(?:import|export)\s[^'"]*?from\s*["'](\.[^"']+)["']|import\s*["'](\.[^"']+)["']/g)) {
queue.push(resolve(dirname(file), match[1] || match[2]));
}
}
return seen;
}

const PAGES: Array<[string, (method: string, path: string) => boolean]> = [
['src/sponsor-doc-page.js', isAnonymousSponsorDocAsset],
['src/contract-sign-page.js', isAnonymousSponsorContractAsset],
['src/newsletter-preview-page.js', isAnonymousNewsletterPreviewAsset],
['src/document-review-page.js', isAnonymousDocumentReviewAsset],
];

describe('anonymous page assets', () => {
for (const [entry, isAllowed] of PAGES) {
it(`lets an anonymous visitor load every module ${entry} imports`, () => {
const blocked = [...importGraph(entry)]
.map((file) => `/${relative(FRONTEND_ROOT, file)}`)
.filter((path) => !isAllowed('GET', path));
assert.deepStrictEqual(blocked, []);
});
}
});

describe('anonymous page scripts', () => {
it('parse as modules', () => {
for (const [entry] of PAGES) {
for (const file of importGraph(entry)) {
const result = require('node:child_process').spawnSync(process.execPath, ['--input-type=module', '--check'], { input: readFileSync(file, 'utf8') });
assert.strictEqual(result.status, 0, `${relative(FRONTEND_ROOT, file)}: ${result.stderr}`);
}
}
});
});
45 changes: 45 additions & 0 deletions backend/tests/client-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';

import { handleClientErrorRoute, isClientErrorReport } from '../src/routes/clientErrors';
import type { LambdaEvent } from '../src/types';

function event(body: unknown, ip = '203.0.113.9'): LambdaEvent {
return { headers: { 'x-forwarded-for': ip, 'user-agent': 'test-agent' }, body: JSON.stringify(body) } as unknown as LambdaEvent;
}

function captureErrors(run: () => void): string[] {
const lines: string[] = [];
const original = console.error;
console.error = (line: string) => { lines.push(line); };
try { run(); } finally { console.error = original; }
return lines;
}

describe('client error reports', () => {
it('matches only POST /api/client-errors', () => {
assert.ok(isClientErrorReport('POST', '/api/client-errors'));
assert.ok(!isClientErrorReport('GET', '/api/client-errors'));
});

it('logs one structured ERROR line without query strings', () => {
let status = 0;
const lines = captureErrors(() => {
status = handleClientErrorRoute(event({ surface: 'sponsor-doc', check: 'GET /api/sponsor-docs', status: 503, message: 'x', page: '/sponsor-docs/sdoc-1?code=SECRET' }, '198.51.100.1')).statusCode;
});
assert.equal(status, 204);
assert.equal(lines.length, 1);
const entry = JSON.parse(lines[0]);
assert.equal(entry.event, 'client_check_failed');
assert.equal(entry.level, 'ERROR');
assert.equal(entry.page, '/sponsor-docs/sdoc-1');
assert.ok(!lines[0].includes('SECRET'));
});

it('throttles one address and rejects invalid JSON', () => {
const codes: number[] = [];
captureErrors(() => { for (let i = 0; i < 12; i += 1) codes.push(handleClientErrorRoute(event({ surface: 's' }, '198.51.100.2')).statusCode); });
assert.equal(codes.filter((c) => c === 429).length, 2);
assert.equal(handleClientErrorRoute({ headers: {}, body: '{' } as unknown as LambdaEvent).statusCode, 400);
});
});
21 changes: 13 additions & 8 deletions frontend/src/contract-sign-page.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { guardedFetchJson } from './core/guarded-fetch.js';
import { createContractSignPage } from './surfaces/operations/contract-sign.js';

async function request(path, options = {}) {
const response = await fetch(/work, {
...options,
cache: 'no-store',
headers: {
'content-type': 'application/json',
...(options.headers || {}),
const { response, data } = await guardedFetchJson(
path,
{
...options,
cache: 'no-store',
headers: {
'content-type': 'application/json',
...(options.headers || {}),
},
},
});
const data = response.status === 204 ? {} : await response.json().catch(() => ({}));
{ surface: 'contract-sign', check: `${options.method || 'GET'} /api/sponsor-contracts`, what: 'load the signing page' },
);
if (!response.ok) {
const error = new Error(data.error || 'Signing request failed');
error.status = response.status;
Expand All @@ -25,4 +29,5 @@ const page = createContractSignPage({
request,
locationRef: location,
});
document.documentElement.dataset.pageReady = 'true';
page.start();
16 changes: 16 additions & 0 deletions frontend/src/contract-sign.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,21 @@
</main>
<div id="dk-live" class="dk-visually-hidden" aria-live="polite"></div>
<script type="module" src="/src/contract-sign-page.js"></script>
<script>
// If the module graph fails to load the page script never runs; say so and report it instead of waiting forever.
setTimeout(() => {
if (document.documentElement.dataset.pageReady) return;
const identity = document.getElementById('contract-sign-identity');
if (identity) identity.textContent = 'Could not load this page. Reload to try again. The DataTalks.Club team has been notified.';
try {
fetch('/api/client-errors', {
method: 'POST',
headers: { 'content-type': 'application/json' },
keepalive: true,
body: JSON.stringify({ surface: 'contract-sign', check: 'page-script-load', message: 'page script did not start within 10s', page: location.pathname }),
}).catch(() => {});
} catch {}
}, 10000);
</script>
</body>
</html>
54 changes: 54 additions & 0 deletions frontend/src/core/guarded-fetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Shared rule: a failed or timed-out request must show the visitor what failed
// and what to do, and must be reported to the server so the team notices
// without anyone having to tell us. Expected outcomes (4xx) are not reported.

export const REQUEST_TIMEOUT_MS = 20000;

export function reportClientFailure(report, { fetchImpl = globalThis.fetch, locationRef = globalThis.location } = {}) {
try {
const result = fetchImpl("/api/client-errors", {
method: "POST",
headers: { "content-type": "application/json" },
keepalive: true,
body: JSON.stringify({ ...report, page: locationRef?.pathname || "" }),
});
result?.catch?.(() => {});
} catch {
// Reporting is best effort; the visitor-facing error is already shown.
}
}

export function failureMessage(what, status) {
const cause = status === "timeout"
? "it took too long to respond"
: status === "network"
? "the connection failed"
: `the server returned an error (${status})`;
return `Could not ${what}: ${cause}. Reload the page to try again. The DataTalks.Club team has been notified.`;
}

/**
* fetch + JSON with a timeout. Resolves to the parsed body, or throws an Error
* whose `status` is the HTTP status, "timeout" or "network". Timeouts, network
* failures and 5xx are reported to the server and carry a ready-to-show message.
*/
export async function guardedFetchJson(path, options = {}, { surface, check, what, timeoutMs = REQUEST_TIMEOUT_MS, fetchImpl = globalThis.fetch, locationRef } = {}) {
let response;
try {
response = await fetchImpl(path, { ...options, signal: AbortSignal.timeout(timeoutMs) });
} catch (cause) {
const status = cause?.name === "TimeoutError" || cause?.name === "AbortError" ? "timeout" : "network";
const error = new Error(failureMessage(what, status));
error.status = status;
reportClientFailure({ surface, check, status: null, message: status }, { fetchImpl, locationRef });
throw error;
}
const data = response.status === 204 ? {} : await response.json().catch(() => ({}));
if (response.status >= 500) {
const error = new Error(failureMessage(what, response.status));
error.status = response.status;
reportClientFailure({ surface, check, status: response.status, message: String(data.error || "") }, { fetchImpl, locationRef });
throw error;
}
return { response, data };
}
Loading