Skip to content
Open
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
5 changes: 5 additions & 0 deletions .github/workflows/cloudflare-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,13 @@ jobs:
# and deployed as is (--no-bundle), so production runs the tested bundle. No token in this job.
run: |
npm ci --prefix deploy
# site.request's release blob (telemetry.v1): the bundle names the commit it was built from. The committed
# server/release.js says unknown, which is what tests and local runs record.
printf "export const RELEASE = '%s';\n" "${GITHUB_SHA::7}" > server/release.js
grep -Eq "^export const RELEASE = '[0-9a-f]{7}';$" server/release.js
deploy/node_modules/.bin/wrangler pages functions build --outdir dist/_worker.js --compatibility-date 2026-10-01
test -s dist/_worker.js/index.js
grep -Fq "RELEASE = \"${GITHUB_SHA::7}\"" dist/_worker.js/index.js
grep -Fq '"/admin/*"' dist/_routes.json
if grep -Fq '"/*"' dist/_routes.json; then echo 'routes too wide'; exit 1; fi
if grep -rqF 'GOCSPX-' dist; then echo 'Google client secret in dist'; exit 1; fi
Expand Down
17 changes: 17 additions & 0 deletions server/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ and `helpers.js` (`makeEnv`, `seedRole`, `seedUser`, `seedSession`, `signIn`, `c
| views.test.js | views (Packet 2) | 22, escaping of names, nav filtered by perms |
| changes.test.js | Packet 3 | 3, 12 (code + escaping) |
| people.test.js, roles.test.js, guards.test.js | Packet 4 | 13, 14, 19, 20, 21, 23 |
| telemetry.test.js | site.request (RFC 0004) | telemetry.v1 conformance |

## Contract changes

Expand Down Expand Up @@ -341,3 +342,19 @@ introduced by this seam. App local stores remain authoritative; uploads and vali
explicit user actions. Tests: account-api.test.js includes owner isolation, CORS/CSRF, quotas,
retries, input bounds, disabled/expired sessions, account-switch races in both directions and
fail-closed storage errors.

## site.request counts (telemetry.v1, 2026-10-31)

MasterMind RFC 0004 and `docs/contracts/telemetry-v1.md`. `handle()` writes one Analytics Engine data point per
response through `env.EVENTS`, after the response is final, inside a try and never awaited: `indexes
['site.request']`, `blobs [event, release, route, method, status, code, app]`, `doubles [1, ms]`. Only the
production configuration declares the binding (`wrangler.toml`; preview declares none). `telemetry/events.json`
is the allowlist: **telemetry.js** `sitePoint(fields)` builds the point from it or returns null, and
`tag(res, code)` / `codeOf(res)` keep an error code beside a Response (`fail()`, the 403 page, `unavailable()`,
the account API's `failure()`), read before `secure()` copies it. `route` is `routes.template(table, pathname)`
or `account-api.accountRoute(pathname)`, else `unmatched`; `app` is the request Origin against `APP_ORIGINS`
and `ORIGIN`, else `none`; `release` is **release.js** `RELEASE`, `unknown` as committed and the short sha in
the bundle CI builds. No user, session or save id, address, User-Agent, raw path or query string is written.
Tests: telemetry.test.js holds responses byte-identical with the binding absent, present, throwing and
malformed, checks every point against the allowlist and its closed sets, and pushes input text into every
request field.
10 changes: 9 additions & 1 deletion server/account-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,23 @@
import { loadSession } from './session.js';
import { newId } from './crypto.js';
import { identityName } from './authz.js';
import { tag } from './telemetry.js';

export const ACCOUNT_BASE = '/admin/api/v1';
export const APP_ORIGINS = { omni: 'https://omni.syberlabs.io', rise: 'https://rise.syberlabs.io', sketch: 'https://sketch.syberlabs.io' };
export const isAccountApi = pathname => pathname === ACCOUNT_BASE || pathname.startsWith(ACCOUNT_BASE + '/');
// site.request's route for an API path: one of these three templates, never the path, which can carry a save id.
export function accountRoute(pathname) {
const rest = isAccountApi(pathname) ? pathname.slice(ACCOUNT_BASE.length) : null;
if (rest === '/account' || rest === '/saves') return ACCOUNT_BASE + rest;
return /^\/saves\/[^/]+$/.test(rest) ? `${ACCOUNT_BASE}/saves/:id` : null;
}
const MAX_BYTES = 1048576;
const ID = /^[a-zA-Z0-9_-]{8,80}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const json = (data, status = 200) => Response.json({ version: 1, ...data }, { status });
const failure = (error, status) => json({ error }, status);
// The error is also kept beside the response for site.request's code (telemetry.js tag).
const failure = (error, status) => tag(json({ error }, status), error);
const metadata = row => ({ id: row.id, app: row.app, name: row.name, createdAt: row.created_at, bytes: row.bytes });

export function accountCors(res, request, env) {
Expand Down
65 changes: 54 additions & 11 deletions server/app.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// The Function's front door. Order matters: ORIGIN -> canonical host -> config -> method rules -> /admin
// -> CSRF -> session -> route -> access -> form -> handler. Every response leaves through secure().
// -> CSRF -> session -> route -> access -> form -> handler. Every response leaves through secure(), and is
// then counted once as a site.request data point (telemetry.v1) that cannot change it.
import { CSP, CSP_WITH_GOOGLE, HttpError, checkCsrf, clearCookie, html, readForm, redirect, secure } from './http.js';
import { secretKeyBytes } from './crypto.js';
import { PUBLIC, SIGNED_IN } from './permissions.js';
import { TABLE, match, ownerPage, tableFor } from './routes.js';
import { TABLE, match, ownerPage, tableFor, template } from './routes.js';
import { googleEnabled } from './providers.js';
import { SESSION_COOKIE, loadSession } from './session.js';
import { accountApi, accountCors, isAccountApi } from './account-api.js';
import { APP_ORIGINS, accountApi, accountCors, accountRoute, isAccountApi } from './account-api.js';
import { codeOf, sitePoint, tag } from './telemetry.js';
import { RELEASE } from './release.js';
import { errorHtml, forbiddenHtml, notFoundHtml, unavailableHtml } from './views/forbidden.js';

const REQUIRED = ['GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET', 'APP_SECRET'];
Expand All @@ -17,14 +20,18 @@ const configured = env => REQUIRED.every(k => typeof env[k] === 'string' && env[
const isAdminPath = p => p === '/admin' || p.startsWith('/admin/');
const noHead = p => p.startsWith('/auth/start/') || p.startsWith('/auth/callback/');

// RFC-0002 2.1, 3.4: rendered in place on the path that failed, so a reload retries.
const unavailable = pathname => html(unavailableHtml(pathname), { status: 503 });
// RFC-0002 2.1, 3.4: rendered in place on the path that failed, so a reload retries. The account API answers
// the same failure in its own JSON shape. Each keeps its code beside it for site.request (telemetry.js tag).
const unavailable = pathname => tag(html(unavailableHtml(pathname), { status: 503 }), 'unavailable');
const down = pathname => (isAccountApi(pathname)
? tag(Response.json({ version: 1, error: 'unavailable' }, { status: 503 }), 'unavailable')
: unavailable(pathname));

// Every error is an HTML page (RFC-0002 R1-8: no /api/*, so no JSON error shape).
function fail(ctx, code) {
const err = code instanceof HttpError ? code : new HttpError(code);
if (err.code === 'not_found') return html(notFoundHtml(ctx), { status: 404 });
return html(errorHtml(ctx, err), { status: err.status });
if (err.code === 'not_found') return tag(html(notFoundHtml(ctx), { status: 404 }), 'not_found');
return tag(html(errorHtml(ctx, err), { status: err.status }), err.code);
}

function methodNotAllowed(ctx, allow) {
Expand Down Expand Up @@ -72,7 +79,7 @@ async function route(request, env, now) {
const { access } = found.route;
if (access !== PUBLIC) {
if (!ctx.user) return signinRequired(ctx, session.stale);
if (access !== SIGNED_IN && !ctx.perms.has(access)) return html(forbiddenHtml(ctx, access), { status: 403 });
if (access !== SIGNED_IN && !ctx.perms.has(access)) return tag(html(forbiddenHtml(ctx, access), { status: 403 }), 'forbidden');
}
try {
if (method === 'POST') ctx.form = await readForm(request);
Expand All @@ -84,10 +91,11 @@ async function route(request, env, now) {
}

export async function handle(request, env, now = Date.now()) {
const started = Date.now();
const url = new URL(request.url);
let res;
if (!env || typeof env.ORIGIN !== 'string' || !env.ORIGIN) {
res = isAccountApi(url.pathname) ? Response.json({ version: 1, error: 'unavailable' }, { status: 503 }) : unavailable(url.pathname);
res = down(url.pathname);
} else if (url.origin !== env.ORIGIN) {
// pages.dev and preview hosts never serve the app; same path on the canonical origin.
res = redirect(env.ORIGIN + url.pathname + url.search, { status: 308 });
Expand All @@ -97,11 +105,46 @@ export async function handle(request, env, now = Date.now()) {
} catch (err) {
// Fail closed (D1 down, a bug). The message may hold data, so only its class is logged.
console.error(`staff: unhandled ${err && err.name ? err.name : 'error'}`);
res = isAccountApi(url.pathname) ? Response.json({ version: 1, error: 'unavailable' }, { status: 503 }) : unavailable(url.pathname);
res = down(url.pathname);
}
}
const code = codeOf(res);
res = secure(res, googleEnabled(env) ? CSP_WITH_GOOGLE : CSP);
if (isAccountApi(url.pathname) && url.origin === env?.ORIGIN) res = accountCors(res, request, env);
if (request.method === 'HEAD') return new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers });
if (request.method === 'HEAD') res = new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers });
count(request, env, url, res, code, started);
return res;
}

const METHODS = ['GET', 'HEAD', 'POST', 'OPTIONS'];

// The request's Origin against the account API's allowlist. Browsers send no Origin on a same-origin GET (the
// site's own account check among them), so `site` counts the site's own form posts, and `none` the rest.
function appOf(request, env) {
const origin = request.headers.get('Origin');
if (origin && origin === env.ORIGIN) return 'site';
return Object.keys(APP_ORIGINS).find(app => APP_ORIGINS[app] === origin) || 'none';
}

// One site.request data point per response, written once the response is final (telemetry.v1 producer rules
// 1 and 2): never awaited and inside a try, so an absent, broken or throwing binding cannot change a byte of it.
// Only production declares the EVENTS binding (wrangler.toml): preview deployments skip all of this, and
// wrangler's local binding discards what it is given.
function count(request, env, url, res, code, started) {
if (!env?.EVENTS) return;
try {
const route = (isAccountApi(url.pathname) ? accountRoute(url.pathname) : template(tableFor(env), url.pathname)) || 'unmatched';
const point = sitePoint({
release: RELEASE,
route,
method: METHODS.includes(request.method) ? request.method : 'other',
status: `${Math.floor(res.status / 100)}xx`,
code,
app: appOf(request, env),
ms: Math.max(0, Date.now() - started),
});
if (point) env.EVENTS.writeDataPoint(point);
} catch {
// A failed write costs one data point, never a response.
}
}
3 changes: 3 additions & 0 deletions server/release.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// The deployed commit, for site.request's release blob (telemetry.v1). CI's Function build rewrites this file
// with the short sha just before it bundles (.github/workflows/cloudflare-pages.yml); anywhere else it is unknown.
export const RELEASE = 'unknown';
7 changes: 7 additions & 0 deletions server/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ export function match(table, method, pathname) {
return methods.size ? { methods: [...methods].sort() } : null;
}

// The pattern a path matches, whatever the method, or null. site.request records this template, never the
// raw path, which can carry an id.
export function template(table, pathname) {
const parts = pathname.split('/');
return table.find(route => matchPath(route.segments, parts))?.pattern || null;
}

// For a signed-out POST: the longest parameterless GET route that owns the form's path (RFC-0002 3.6).
export function ownerPage(table, pathname) {
let best = '/admin/';
Expand Down
41 changes: 41 additions & 0 deletions server/telemetry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// telemetry.v1 (MasterMind docs/contracts/telemetry-v1.md, RFC 0004): the site.request data point, built only
// from what telemetry/events.json lists. A field the allowlist does not name is never written, and a value
// outside a field's list drops the whole point. Nothing here sees a user, session or save id, an address, a
// User-Agent, a raw path or a query string: app.js passes a route template, a status class and its own
// constants. No imports but the allowlist, so app.js and account-api.js can both use it without an import cycle.
import events from '../telemetry/events.json' with { type: 'json' };

export const EVENT = 'site.request';
const SPEC = events.events[EVENT];

// The error code a response was built with (an HttpError's code, the account API's error), kept beside the
// Response rather than read back from its body. app.js reads it before secure() copies the response.
const codes = new WeakMap();
export const tag = (res, code) => { codes.set(res, code); return res; };
export const codeOf = res => codes.get(res) || 'none';

// release, route and code have no values list: each comes from a closed set the code owns (the short sha CI
// writes into release.js, a route-table template, an error-code literal). These checks refuse any shape no
// member of those sets has, and a blob added to the allowlist without a check here writes nothing.
const CLOSED = {
release: value => /^(?:[0-9a-f]{7}|unknown)$/.test(value),
route: value => value === 'unmatched' || /^\/[a-z0-9/:-]*$/.test(value),
code: value => /^[a-z][a-z_]{0,39}$/.test(value),
};

// { indexes: [event], blobs: [event, ...the allowlist's blobs in order], doubles: [1, ...its numbers] }, or null.
export function sitePoint(fields) {
const blobs = [EVENT];
for (const { name, values } of SPEC.blobs) {
const value = fields[name];
if (typeof value !== 'string' || !(values ? values.includes(value) : CLOSED[name]?.(value))) return null;
blobs.push(value);
}
const doubles = [];
for (const name of SPEC.doubles) {
const value = name === 'count' ? 1 : fields[name];
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null;
doubles.push(value);
}
return { indexes: [EVENT], blobs, doubles };
}
Loading
Loading