diff --git a/404.html b/404.html
index 4206597..2ca5fd4 100644
--- a/404.html
+++ b/404.html
@@ -9,7 +9,7 @@
-
+

SyberLabs
diff --git a/scripts/qa.mjs b/scripts/qa.mjs
index 46d6ea7..a7a7958 100644
--- a/scripts/qa.mjs
+++ b/scripts/qa.mjs
@@ -12,13 +12,12 @@
// - sitemap.xml URLs with no page, and pages (except 404.html and the kit demo) missing from the sitemap
// - files the CI workflow tests for (`test -s dist/...`) that are missing
// Exits non-zero on broken internal links or missing files; everything else is a warning.
-import { readFile, readdir, stat } from 'node:fs/promises';
+import { readFile, readdir } from 'node:fs/promises';
import { existsSync, statSync } from 'node:fs';
import { resolve, join, relative, posix } from 'node:path';
const root = resolve(process.argv[2] || 'dist');
const ORIGIN = 'https://syberlabs.io';
-const SITEMAP_EXEMPT = new Set(['404.html', 'kit/v2/demo.html']);
async function walk(dir) {
const out = [];
@@ -175,7 +174,7 @@ if (existsSync(sitemapFile)) {
const locs = [...sitemap.matchAll(/
([^<]+)<\/loc>/g)].map(m => m[1].trim());
for (const loc of locs) if (!pageUrls.has(loc)) report('sitemap.xml', `sitemap URL has no page: ${loc}`, true);
for (const [rel] of parsed) {
- if (SITEMAP_EXEMPT.has(rel) || !rel.endsWith('index.html')) continue;
+ if (!rel.endsWith('index.html')) continue;
const url = `${ORIGIN}/${rel.slice(0, -'index.html'.length)}`;
if (!locs.includes(url)) report('sitemap.xml', `page missing from sitemap: ${url}`);
}
diff --git a/server/CONTRACT.md b/server/CONTRACT.md
index a65fbaa..1ad8ae9 100644
--- a/server/CONTRACT.md
+++ b/server/CONTRACT.md
@@ -53,7 +53,7 @@ dependencies: Web Crypto, `fetch`, `URL` and D1 `prepare/bind/first/all/run/batc
R1-14; cached; rejects unless 32 bytes), `sealJson(key, value, aad)` / `openJson(key, sealed, aad)` -> value|null (AES-GCM, random 96-bit IV, `b64url(iv||ct)`; open never throws).
**http.js** (foundation): `esc(v)` (& < > " ', null -> ''), `CSP`, `SECURITY_HEADERS`, `secure(res)` (copy with
-every header set and `Access-Control-Allow-Origin` removed), `html(body, init?)`, `text(body, init?)`,
+every header set and `Access-Control-Allow-Origin` removed), `html(body, init?)`,
`redirect(location, init?)` (303 default, empty body); `init = {status, headers, cookies: string[]}`. `ERRORS`
(`[status, message]` per code), `class HttpError(code, message?)` (`.status` from `ERRORS`),
`class AuthError(code, detail?)` (codes: cancelled, expired, provider, disabled). `cookie(name, value, maxAgeSeconds)` (`Path=/; Secure; HttpOnly; SameSite=Lax`, never Domain),
@@ -196,8 +196,7 @@ and `helpers.js` (`makeEnv`, `seedRole`, `seedUser`, `seedSession`, `signIn`, `c
the no-keys empty state says "Ask an admin" (no names in the repo).
- `changes.js` hard-deletes (RFC 5.1; the audit row keeps the old row) and writes only the RFC 8.1 columns, so it works
on either migration; `deleted_at`, `created_by`, `updated_*` stay unused. Rows use `staff-change__*` (RFC 2.6), not
- `.home-latest*`. It also exports `validateEntry(input)` -> `{entry, errors}`, shared with `scripts/changes-import.mjs`
- (`` or `--file `; first line `-- source , N rows`; one bad entry exits 1 with no SQL).
+ `.home-latest*`. It also exports `validateEntry(input)` -> `{entry, errors}`.
- `staff.css` sits at the repo root and is linked as `/staff.css?v=1`: **`scripts/assemble-dist.sh` must copy it into
`dist/`** (owner of that file). It aliases `sy-btn--primary` and `sy-h3` (not kit classes) for people/roles.
- `privacy/index.html` copy describes what this build does (GitHub and Google, no-scope GitHub, user agent and IP
@@ -290,7 +289,7 @@ and `helpers.js` (`makeEnv`, `seedRole`, `seedUser`, `seedSession`, `signIn`, `c
takes `errorHtml` for the Add person lookup message, which carries a link.
- `projects/latest.js`, the homepage `Latest()` section and its `.home-latest*` CSS are deleted;
`scripts/assemble-dist.sh` no longer removes `dist/projects/latest.js`, so the CI `test ! -e` guard can fail, and
- CI greps `dist/` for the canary title. `scripts/changes-import.mjs ` still reads the file from git history.
+ CI greps `dist/` for the canary title.
## Deviations from RFC
diff --git a/server/account-api.js b/server/account-api.js
index fadda24..48f558f 100644
--- a/server/account-api.js
+++ b/server/account-api.js
@@ -3,7 +3,7 @@ import { loadSession } from './session.js';
import { newId } from './crypto.js';
import { identityName } from './authz.js';
-export const ACCOUNT_BASE = '/admin/api/v1';
+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 + '/');
const MAX_BYTES = 1048576;
diff --git a/server/http.js b/server/http.js
index d442e47..f937ca1 100644
--- a/server/http.js
+++ b/server/http.js
@@ -8,7 +8,7 @@ export const esc = value => (value == null ? '' : String(value)).replace(/[&<>"'
// No script-src or connect-src: default-src 'none' blocks every script on staff pages (RFC-0002 8.2).
// Browsers apply form-action to the 303 after POST /auth/start, so each enabled provider is named; Google only
// while it is on (oauth.js enabledProviders), so with it off no response mentions it.
-export const cspFor = ({ google = false } = {}) => [
+const cspFor = ({ google = false } = {}) => [
"default-src 'none'",
// 'unsafe-inline' is for styles only: the shared footer writes style="--sy-accent:…". No remote style and
// no font source: staff pages render in the kit's fallback stacks (RFC-0002 2.6).
@@ -55,7 +55,6 @@ function build(body, { status = 200, headers = {}, cookies = [] } = {}, type) {
}
export const html = (body, init) => build(body, init, 'text/html; charset=utf-8');
-export const text = (body, init) => build(body, init, 'text/plain; charset=utf-8');
// Empty-body redirect, 303 unless told otherwise.
export function redirect(location, { status = 303, headers = {}, cookies = [] } = {}) {
diff --git a/server/oauth.js b/server/oauth.js
index 94513e8..1a90037 100644
--- a/server/oauth.js
+++ b/server/oauth.js
@@ -10,7 +10,7 @@ import * as github from './github.js';
import * as google from './google.js';
import { enabledProviders, googleEnabled } from './providers.js';
-export { PROVIDERS, enabledProviders, googleEnabled } from './providers.js';
+export { googleEnabled } from './providers.js';
export const OAUTH_COOKIE = '__Host-sl_oauth';
export const STATE_TTL_MS = 600000; // GitHub's code lifetime is 10 minutes
diff --git a/server/providers.js b/server/providers.js
index 78ae436..84ec0e5 100644
--- a/server/providers.js
+++ b/server/providers.js
@@ -1,7 +1,7 @@
// Which sign-in providers are on. GitHub always; Google only when both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET
// are set, and otherwise nothing Google renders and its routes are 404 (RFC-0002 2.1, Packet 5). No imports, so
// views and oauth.js can both read it without an import cycle (oauth.js re-exports it).
-export const PROVIDERS = ['github', 'google'];
+const PROVIDERS = ['github', 'google'];
export const enabledProviders = env =>
PROVIDERS.filter(p => p === 'github' || Boolean(env && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET));
diff --git a/server/test/account-api.test.js b/server/test/account-api.test.js
index 43b95a8..0b74ddf 100644
--- a/server/test/account-api.test.js
+++ b/server/test/account-api.test.js
@@ -15,12 +15,12 @@ async function signIn(...args) {
return user;
}
const snapshot = (overrides = {}) => ({ app: 'omni', name: 'Private workspace', payload: { documents: ['hello'] }, requestId: randomUUID(), ...overrides });
-function request(env, path, { method = 'GET', cookie, origin, body, headers = {}, now, expectedUserId = accountForCookie.get(cookie) } = {}) {
+function request(env, path, { method = 'GET', cookie, origin, body, headers = {}, expectedUserId = accountForCookie.get(cookie) } = {}) {
const h = new Headers(headers);
if (cookie) h.set('Cookie', cookie);
if (path.startsWith(`${ROOT}/saves`) && expectedUserId) h.set('X-SyberLabs-Expected-User', expectedUserId);
if (origin !== undefined) h.set('Origin', origin);
- return handle(new Request(new URL(path, ORIGIN), { method, headers: h, body }), env, now);
+ return handle(new Request(new URL(path, ORIGIN), { method, headers: h, body }), env);
}
function save(env, cookie, data = snapshot(), opts = {}) {
return request(env, `${ROOT}/saves`, { method: 'POST', cookie, origin: OMNI,
diff --git a/server/test/board.test.js b/server/test/board.test.js
index dd2bd7d..cff6253 100644
--- a/server/test/board.test.js
+++ b/server/test/board.test.js
@@ -34,17 +34,17 @@ function production(req) {
}
// GET /admin/board with fetch stubbed; returns the response, its text and every request the checks made.
-async function render(env, cookie, { fetch = production, now = NOW } = {}) {
+async function render(env, cookie, { fetch = production } = {}) {
const stub = stubFetch(fetch);
try {
- const res = await call(env, '/admin/board', { cookie, now });
+ const res = await call(env, '/admin/board', { cookie, now: NOW });
return { res, body: await res.text(), calls: stub.calls };
} finally {
stub.restore();
}
}
-const adminEnv = async (overrides = {}) => {
+const adminEnv = async overrides => {
const env = makeEnv(overrides);
const admin = await signIn(env.DB, { roles: ['role_admin'], now: NOW });
return { env, cookie: admin.cookie };
diff --git a/server/test/d1-shim.js b/server/test/d1-shim.js
index e1d2cbd..31094a6 100644
--- a/server/test/d1-shim.js
+++ b/server/test/d1-shim.js
@@ -1,5 +1,5 @@
// A D1 binding over node:sqlite, so tests run the real migrations/*.sql. Only the surface server/ may
-// use: prepare(sql).bind(...).first(col?) / all() / run() / raw(), batch([...]) and exec(sql).
+// use: prepare(sql).bind(...).first(col?) / all() / run() and batch([...]).
// Like D1: every call is async, binding undefined throws, booleans bind as 1/0, batch is one transaction.
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, readdirSync } from 'node:fs';
@@ -52,12 +52,6 @@ class Statement {
async all() { return this.exec(); }
async run() { return this.exec(); }
-
- async raw() {
- const stmt = this.db.sqlite.prepare(this.sql);
- stmt.setReturnArrays(true);
- return stmt.all(...this.params);
- }
}
export class D1Shim {
@@ -79,11 +73,6 @@ export class D1Shim {
}
}
- async exec(sql) {
- this.sqlite.exec(sql);
- return { count: 1, duration: 0 };
- }
-
// Test helpers (not part of D1): rows written so far, including trigger and cascade changes (and rows
// a rolled-back batch wrote, so take a fresh baseline after a failure).
totalChanges() {
@@ -124,6 +113,6 @@ export function freshDb({ upTo = MIGRATION_FILES.length } = {}) {
// A binding whose every query rejects, for the "D1 throw -> 503" tests.
export function brokenDb() {
const fail = async () => { throw new Error('D1_ERROR: simulated outage'); };
- const stmt = { bind: () => stmt, first: fail, all: fail, run: fail, raw: fail };
- return { prepare: () => stmt, batch: fail, exec: fail };
+ const stmt = { bind: () => stmt, first: fail, all: fail, run: fail };
+ return { prepare: () => stmt, batch: fail };
}
diff --git a/server/test/google.test.js b/server/test/google.test.js
index 6f76c71..3f3ad5b 100644
--- a/server/test/google.test.js
+++ b/server/test/google.test.js
@@ -15,9 +15,9 @@ const claims = (changes = {}) => ({ iss: 'https://accounts.google.com', aud: 'go
sub: 'google-sub-test', nonce: 'nonce-test', exp: NOW / 1000 + 3600, iat: NOW / 1000,
email: 'display@example.test', email_verified: true, ...changes });
const token = c => `${b64urlEncode(JSON.stringify({ alg: 'RS256' }))}.${b64urlEncode(JSON.stringify(c))}.signature`;
-function ctx(env, path, { cookie = '', form = {}, user = null, now = NOW } = {}) {
+function ctx(env, path, { cookie = '', form = {}, user = null } = {}) {
const url = new URL(path, ORIGIN);
- return { env, url, now, user, params: { provider: 'google' }, form: new URLSearchParams(form),
+ return { env, url, now: NOW, user, params: { provider: 'google' }, form: new URLSearchParams(form),
request: new Request(url, { headers: cookie ? { Cookie: cookie } : {} }) };
}
const cookieValue = res => res.headers.getSetCookie().find(c => c.startsWith(`${OAUTH_COOKIE}=`)).split(';')[0];
@@ -26,15 +26,15 @@ async function start(env, opts = {}) {
const url = new URL(res.headers.get('Location'));
return { res, url, state: url.searchParams.get('state'), nonce: url.searchParams.get('nonce'), cookie: cookieValue(res) };
}
-async function add(env, u, sessionToken, opts = {}) {
+async function add(env, sessionToken) {
const cookie = `${SESSION_COOKIE}=${sessionToken}`;
const session = await loadSession(env, ctx(env, '/admin/account', { cookie }).request, NOW);
- const res = await addGoogle(ctx(env, '/admin/account/add-google', { cookie, user: session.user, ...opts }));
+ const res = await addGoogle(ctx(env, '/admin/account/add-google', { cookie, user: session.user }));
const url = new URL(res.headers.get('Location'), ORIGIN);
return { res, url, state: url.searchParams.get('state'), nonce: url.searchParams.get('nonce'), cookie: `${cookie}; ${cookieValue(res)}` };
}
-async function finish(env, flow, changes = {}, handler) {
- const stub = stubFetch(req => handler ? handler(req) : { id_token: token(claims({ nonce: flow.nonce, ...changes })) });
+async function finish(env, flow) {
+ const stub = stubFetch(() => ({ id_token: token(claims({ nonce: flow.nonce })) }));
try {
const res = await callback(ctx(env, `/auth/callback/google?code=google-code&state=${flow.state}`, { cookie: flow.cookie }));
return { res, calls: stub.calls };
@@ -153,7 +153,7 @@ test('Add Google seals the signed-in user and uses chooser; callback inserts met
const env = envOf();
const u = seedUser(env.DB);
const sessionToken = await seedSession(env.DB, { ...u, now: NOW });
- const flow = await add(env, u, sessionToken);
+ const flow = await add(env, sessionToken);
assert.equal(flow.url.searchParams.get('prompt'), 'select_account');
const st = await openState(env, 'google', flow.cookie.split(`${OAUTH_COOKIE}=`)[1], NOW);
assert.equal(st.l, u.userId);
@@ -169,7 +169,7 @@ test('Add Google rejects ended sessions, switched users, and a subject already b
const env = envOf();
const u = seedUser(env.DB);
const sessionToken = await seedSession(env.DB, { ...u, now: NOW });
- const flow = await add(env, u, sessionToken);
+ const flow = await add(env, sessionToken);
if (reason === 'ended') env.DB.sqlite.prepare('DELETE FROM sessions').run();
if (reason === 'switched') {
const other = seedUser(env.DB);
@@ -189,7 +189,7 @@ test('Add Google transaction rechecks session revocation and concurrent Google a
const env = envOf();
const u = seedUser(env.DB);
const sessionToken = await seedSession(env.DB, { ...u, now: NOW });
- const flow = await add(env, u, sessionToken);
+ const flow = await add(env, sessionToken);
const batch = env.DB.batch.bind(env.DB);
env.DB.batch = async stmts => {
if (race === 'revoked') env.DB.sqlite.prepare('DELETE FROM sessions WHERE id_hash = ?').run(await sha256Hex(sessionToken));
@@ -212,7 +212,7 @@ test('two concurrent Google callbacks for one person attach only one method and
const env = envOf();
const u = seedUser(env.DB);
const sessionToken = await seedSession(env.DB, { ...u, now: NOW });
- const flows = [await add(env, u, sessionToken), await add(env, u, sessionToken)];
+ const flows = [await add(env, sessionToken), await add(env, sessionToken)];
let release;
const ready = new Promise(resolve => { release = resolve; });
const batch = env.DB.batch.bind(env.DB);
@@ -243,7 +243,7 @@ test('two people concurrently claiming one Google subject produce one link and o
const flows = [];
for (const u of users) {
const sessionToken = await seedSession(env.DB, { ...u, now: NOW });
- flows.push(await add(env, u, sessionToken));
+ flows.push(await add(env, sessionToken));
}
let release;
const ready = new Promise(resolve => { release = resolve; });
@@ -272,7 +272,7 @@ test('signout while Google callback awaits its transaction prevents linking and
const env = envOf();
const u = seedUser(env.DB);
const sessionToken = await seedSession(env.DB, { ...u, now: NOW });
- const flow = await add(env, u, sessionToken);
+ const flow = await add(env, sessionToken);
let atBatch;
const entered = new Promise(resolve => { atBatch = resolve; });
let resume;
diff --git a/server/test/helpers.js b/server/test/helpers.js
index 0c069b9..f9c18eb 100644
--- a/server/test/helpers.js
+++ b/server/test/helpers.js
@@ -6,7 +6,6 @@ import { newId, randomToken, sha256Hex } from '../crypto.js';
export const ORIGIN = 'https://syberlabs.io';
export const SESSION_COOKIE = '__Host-sl_session';
-export const OAUTH_COOKIE = '__Host-sl_oauth';
export const HOUR = 3600 * 1000;
export function makeEnv(overrides = {}) {
diff --git a/server/test/migration.test.js b/server/test/migration.test.js
index 15b6cbf..d38b645 100644
--- a/server/test/migration.test.js
+++ b/server/test/migration.test.js
@@ -275,7 +275,6 @@ test('d1 shim: D1 result shapes, binding rules, atomic batch, broken binding', a
{ results: [{ id: 'u1', display_name: 'A' }], success: true, meta: { changes: 0, last_row_id: 0, changed_db: false, rows_read: 1 } });
assert.equal(await db.prepare('SELECT display_name FROM users WHERE id = ?').bind('u1').first('display_name'), 'A');
assert.equal(await db.prepare('SELECT * FROM users WHERE id = ?').bind('none').first(), null);
- assert.deepEqual(await db.prepare('SELECT id FROM users').raw(), [['u1']]);
assert.equal(await db.prepare('SELECT ?1 AS t, ?1 + ?2 AS s').bind(true, 2).first('s'), 3);
assert.throws(() => db.prepare('SELECT ?').bind(undefined), /D1_TYPE_ERROR/);
const ret = await db.prepare("UPDATE users SET display_name = 'B' WHERE id = 'u1' RETURNING id").all();
diff --git a/server/test/oauth.test.js b/server/test/oauth.test.js
index 39e2d3c..1d4b89a 100644
--- a/server/test/oauth.test.js
+++ b/server/test/oauth.test.js
@@ -3,7 +3,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeEnv, seedUser, seedSession, stubFetch, ORIGIN, HOUR } from './helpers.js';
-import { b64urlDecode, b64urlEncode, stateKey, openJson, randomToken, sha256Hex, newId } from '../crypto.js';
+import { b64urlDecode, b64urlEncode, stateKey, openJson, randomToken, sha256Hex } from '../crypto.js';
import { HttpError } from '../http.js';
import { OAUTH_COOKIE, STATE_TTL_MS, sealState, openState, startLogin, callback } from '../oauth.js';
@@ -34,8 +34,8 @@ function assertHostCookie(setCookie, name, maxAge) {
assert.ok(!/domain/i.test(setCookie));
}
-async function start(env, form = {}, { provider = 'github', now = NOW } = {}) {
- const res = await startLogin(ctxOf(env, { method: 'POST', path: `/auth/start/${provider}`, form, provider, now }));
+async function start(env, form = {}, { now = NOW } = {}) {
+ const res = await startLogin(ctxOf(env, { method: 'POST', path: '/auth/start/github', form, now }));
const loc = new URL(res.headers.get('Location'));
const sealed = cookieValue(res, OAUTH_COOKIE);
return { res, loc, sealed, state: loc.searchParams.get('state'), cookie: `${OAUTH_COOKIE}=${sealed}` };
diff --git a/server/test/people.test.js b/server/test/people.test.js
index f3e1a31..61de433 100644
--- a/server/test/people.test.js
+++ b/server/test/people.test.js
@@ -2,7 +2,6 @@
// and Add person (20), which replaced invites (RFC-0002 R3-4).
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { sha256Hex } from '../crypto.js';
import { call, makeEnv, seedSession, signIn, stubFetch } from './helpers.js';
const q = (db, sql, ...a) => db.sqlite.prepare(sql).get(...a);
@@ -327,8 +326,8 @@ test('add person end to end: the added id signs in through case a after a rename
const admin = await signIn(env.DB, { roles: ['role_admin'] });
await add(env, admin.cookie, { role: 'role_viewer', account: '9191', confirm: '1' });
const { startLogin, callback, OAUTH_COOKIE } = await import('../oauth.js');
- const ctx = (path, extra) => ({ env, url: new URL(path, env.ORIGIN), now: Date.now(), params: { provider: 'github' },
- form: new URLSearchParams(), user: null, perms: new Set(), ...extra });
+ const ctx = path => ({ env, url: new URL(path, env.ORIGIN), now: Date.now(), params: { provider: 'github' },
+ form: new URLSearchParams(), user: null, perms: new Set() });
const started = await startLogin({ ...ctx('/auth/start/github'), request: new Request(`${env.ORIGIN}/auth/start/github`, { method: 'POST' }) });
const sealed = started.headers.getSetCookie()[0].split(';')[0];
const state = new URL(started.headers.get('Location')).searchParams.get('state');
diff --git a/server/test/session.test.js b/server/test/session.test.js
index 4e66898..a83b5bf 100644
--- a/server/test/session.test.js
+++ b/server/test/session.test.js
@@ -11,16 +11,16 @@ import {
const NOW = Date.UTC(2026, 9, 9, 14, 2);
-function req(path = '/admin/', { cookie, headers = {}, method = 'GET' } = {}) {
+function req(path, { cookie, headers = {}, method = 'GET' } = {}) {
const h = new Headers(headers);
if (cookie) h.set('Cookie', cookie);
return new Request(new URL(path, ORIGIN), { method, headers: h });
}
-async function ctxFor(env, cookieHeader, { form = {}, now = NOW } = {}) {
+async function ctxFor(env, cookieHeader, { form = {} } = {}) {
const request = req('/auth/signout', { cookie: cookieHeader, method: 'POST' });
- const { user, perms } = await loadSession(env, request, now);
- return { request, env, url: new URL(request.url), now, params: {},
+ const { user, perms } = await loadSession(env, request, NOW);
+ return { request, env, url: new URL(request.url), now: NOW, params: {},
form: new URLSearchParams(form), user, perms };
}
diff --git a/server/test/views.test.js b/server/test/views.test.js
index 922b62e..bcb184c 100644
--- a/server/test/views.test.js
+++ b/server/test/views.test.js
@@ -1,13 +1,8 @@
// Views: every page renders inside the shared chrome with no script, every value is escaped, the nav
// follows permissions, the sign-in page reads no D1 and never redirects, the changes form and href
-// rule (case 17) and the import script round-trips.
+// rule (case 17).
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { execFileSync, spawnSync } from 'node:child_process';
-import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { fileURLToPath } from 'node:url';
import { makeEnv, signIn, call, seedUser, HOUR } from './helpers.js';
import { freshDb } from './d1-shim.js';
import { page, navItems, alert, field } from '../views/layout.js';
@@ -19,14 +14,13 @@ import { HttpError } from '../http.js';
import { newId } from '../crypto.js';
const XSS = `'">
`;
-const ROOT = fileURLToPath(new URL('../../', import.meta.url));
const ALL = ['site:changes.read', 'site:changes.write', 'id:users.read', 'id:audit.read'];
// A ctx for calling views directly.
-function ctxFor({ perms = [], user = {}, path = '/admin/', method = 'GET' } = {}) {
- const url = new URL(path, 'https://syberlabs.io');
+function ctxFor({ perms = [], user = {} } = {}) {
+ const url = new URL('/admin/', 'https://syberlabs.io');
return {
- env: { ORIGIN: 'https://syberlabs.io' }, url, now: Date.now(), request: new Request(url, { method }),
+ env: { ORIGIN: 'https://syberlabs.io' }, url, now: Date.now(), request: new Request(url),
form: new URLSearchParams(), perms: new Set(perms),
user: { id: 'u1', identityId: 'i1', sessionHash: 'h', displayName: 'Test user', provider: 'github', login: 'test-user', email: null, expiresAt: Date.now() + HOUR, ...user },
};
@@ -485,40 +479,6 @@ test('What changed: reader sees rows escaped, no form; writer gets the form; 422
assert.equal(db.totalChanges(), before);
});
-// ---- the import script ------------------------------------------------------------------------
-
-test('changes-import.mjs: SQL with quotes round-trips; one bad entry refuses the run', async () => {
- const dir = mkdtempSync(join(tmpdir(), 'views-test-'));
- try {
- const good = join(dir, 'good.js');
- writeFileSync(good, `export const latest = [
- { date: '2026-10-08', project: 'RISE', state: 'merged', title: "Reader's title", text: "It's done; DROP TABLE x; --", href: 'https://github.com/SyberLabs/x/pull/1' },
- { date: '2026-10-07', project: 'Site', state: 'in progress', title: 'Second', text: '', href: '/plus/' },
- ];\n`);
- const sql = execFileSync(process.execPath, ['scripts/changes-import.mjs', '--file', good], { cwd: ROOT, encoding: 'utf8' });
- assert.equal(sql.split('\n')[0], '-- source good.js, 2 rows');
- const db = freshDb();
- db.sqlite.exec(sql);
- const rows = db.sqlite.prepare('SELECT title, text, state FROM change_entries ORDER BY date DESC, created_at DESC').all();
- assert.deepEqual(rows.map(r => ({ ...r })), [
- { title: "Reader's title", text: "It's done; DROP TABLE x; --", state: 'merged' },
- { title: 'Second', text: '', state: 'in progress' },
- ]);
-
- const bad = join(dir, 'bad.js');
- writeFileSync(bad, `export const latest = [
- { date: '2026-10-08', project: 'RISE', state: 'merged', title: 'ok', text: '', href: '/plus/' },
- { date: '2026-10-08', project: 'RISE', state: 'merged', title: 'bad', text: '', href: 'javascript:alert(1)' },
- ];\n`);
- const out = spawnSync(process.execPath, ['scripts/changes-import.mjs', '--file', bad], { cwd: ROOT, encoding: 'utf8' });
- assert.notEqual(out.status, 0);
- assert.equal(out.stdout, '');
- assert.match(out.stderr, /entry 2/);
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
// Packet 5: display and freshness agree with the Add Google route, including the exact ten-minute boundary.
test('Google account UI: fresh sessions can add it, older sessions reauthenticate, existing methods show success', async () => {
const env = makeEnv({ GOOGLE_CLIENT_ID: 'google-test', GOOGLE_CLIENT_SECRET: 'google-secret' });
diff --git a/server/views/account.js b/server/views/account.js
index 52f332e..9d5de0f 100644
--- a/server/views/account.js
+++ b/server/views/account.js
@@ -9,13 +9,13 @@ import { GITHUB_MARK } from './chrome.js';
import { contact } from './signin.js';
// RFC-0002 3.4: the codes the Add Google callback sends back here (oauth.js), with the account page's copy.
-export const ADD_GOOGLE_COPY = {
+const ADD_GOOGLE_COPY = {
cancelled: ['neutral', () => 'Adding Google was cancelled.'],
expired: ['warning', () => "Adding Google couldn't be finished. Try again."],
provider: ['danger', () => `Google didn't finish the sign-in. Try again, or ${contact('email SyberLabs')}.`],
google_taken: ['warning', () => 'That Google account is already on another SyberLabs account. Choose a different one.'],
};
-export const GOOGLE_ADDED = 'Google is added. You can now sign in with GitHub or Google.';
+const GOOGLE_ADDED = 'Google is added. You can now sign in with GitHub or Google.';
// The notice from ?added=google or ?e=. "added" shows only when Google really is among the methods, so a
// crafted link cannot show it (RFC-0002 3.3).
diff --git a/server/views/audit.js b/server/views/audit.js
index 556c54c..e667389 100644
--- a/server/views/audit.js
+++ b/server/views/audit.js
@@ -10,7 +10,7 @@ const LIMIT = 200;
// Every action the app writes (and session.revoke, which older rows hold), with the verb phrase the page
// shows: "turn off", never "disable" (RFC-0002 3.5). t = the target's name, role = a role name, title = an
// entry title, all already escaped; provider = "Google" (a fixed label) for identity.add.
-export const ACTION_SENTENCES = {
+const ACTION_SENTENCES = {
'signin.ok': () => 'signed in',
'user.add': v => `added ${v.t}${v.role ? ` as ${v.role}` : ''}`,
bootstrap: () => 'bootstrapped the first accounts',
diff --git a/server/views/changes.js b/server/views/changes.js
index 7cdd1a4..c2c3a7a 100644
--- a/server/views/changes.js
+++ b/server/views/changes.js
@@ -7,7 +7,7 @@ import { auditStmt } from '../authz.js';
import { page, head, alert, field, errorSummary, fixPrefix, postButton } from './layout.js';
import { confirmHtml, confirmed } from './confirm.js';
-export const STATES = ['deployed', 'merged', 'decided', 'recorded', 'in progress'];
+const STATES = ['deployed', 'merged', 'decided', 'recorded', 'in progress'];
const LIMIT = 100;
const COLUMNS = 'id, date, project, state, title, text, href';
@@ -29,8 +29,8 @@ export function validHref(raw) {
const realDate = s => /^\d{4}-\d{2}-\d{2}$/.test(s) && new Date(`${s}T00:00:00Z`).toISOString().slice(0, 10) === s;
-export const LABELS = { date: 'Date', project: 'Project', state: 'State', title: 'Title', text: 'Text', href: 'Link' };
-export const MESSAGES = {
+const LABELS = { date: 'Date', project: 'Project', state: 'State', title: 'Title', text: 'Text', href: 'Link' };
+const MESSAGES = {
date: 'Use a date like 2026-10-09.',
project: 'Projects are 1 to 60 characters.',
state: 'Choose a state.',
@@ -39,7 +39,7 @@ export const MESSAGES = {
href: 'Links must start with https:// or a single /.',
};
-// Validates one entry (form or import). Returns {entry, errors}; errors maps field -> message.
+// Validates one entry (form). Returns {entry, errors}; errors maps field -> message.
export function validateEntry(input) {
const get = k => (typeof input[k] === 'string' ? input[k].trim() : input[k] == null ? '' : String(input[k]).trim());
const entry = { date: get('date'), project: get('project'), state: get('state'), title: get('title'), text: get('text'), href: get('href') };
diff --git a/server/views/error.js b/server/views/error.js
index d2370ea..6ba43a3 100644
--- a/server/views/error.js
+++ b/server/views/error.js
@@ -30,7 +30,7 @@ export function errorHtml(ctx, err) {
return page(ctx, { title, body });
}
-export const STAFF_UNAVAILABLE_HTML = `Staff pages are unavailable right now. Reload in a minute, or ${contact('email SyberLabs')}.`;
+const STAFF_UNAVAILABLE_HTML = `Staff pages are unavailable right now. Reload in a minute, or ${contact('email SyberLabs')}.`;
// No ctx: this renders when config or D1 is missing, so it touches neither (RFC-0002 2.1, 3.4).
// Under /admin: "Staff pages are unavailable", with the lockup and Sign out but no account line or nav,
diff --git a/server/views/people.js b/server/views/people.js
index 6312f44..d86e3ba 100644
--- a/server/views/people.js
+++ b/server/views/people.js
@@ -110,7 +110,7 @@ function addForm(ctx, data, values, errors) {
}
// The page, with an optional notice (trusted HTML) and the Add person form's values and errors.
-export async function renderPeople(ctx, { status = 200, notice = '', values = {}, errors = {} } = {}) {
+async function renderPeople(ctx, { status = 200, notice = '', values = {}, errors = {} } = {}) {
const data = await loadPeople(ctx.env.DB);
const manage = can(ctx, 'id:users.manage');
const ok = NOTICES[ctx.url.searchParams.get('ok')];
diff --git a/server/views/roles.js b/server/views/roles.js
index d62f2aa..df544f6 100644
--- a/server/views/roles.js
+++ b/server/views/roles.js
@@ -40,7 +40,7 @@ function keyBoxes(selected) {
`;
}
-export const NAME_COPY = 'Role names are 2 to 40 lowercase letters, digits or hyphens, starting with a letter, like editor.';
+const NAME_COPY = 'Role names are 2 to 40 lowercase letters, digits or hyphens, starting with a letter, like editor.';
function roleForm(editing, values, errors) {
const selected = new Set(values.perms || (editing ? editing.keys : []));
@@ -61,7 +61,7 @@ function roleForm(editing, values, errors) {
}
// A list of roles, each with its description, keys and holders (RFC-0002 2.6; no tables on staff pages).
-export async function renderRoles(ctx, { status = 200, notice = '', values = {}, errors = {}, editId } = {}) {
+async function renderRoles(ctx, { status = 200, notice = '', values = {}, errors = {}, editId } = {}) {
const roles = await loadRoles(ctx.env.DB);
const manage = can(ctx, 'id:roles.manage');
const editing = manage ? roles.find(r => r.id === (editId ?? ctx.url.searchParams.get('edit')) && r.is_system !== 1) : null;
diff --git a/server/views/signin.js b/server/views/signin.js
index e76236d..3182bf0 100644
--- a/server/views/signin.js
+++ b/server/views/signin.js
@@ -33,11 +33,11 @@ export const ERROR_COPY = {
};
// The 503 copy on this page (RFC-0002 3.4): shown in place, with the buttons hidden.
-export const UNAVAILABLE_HTML = `Sign-in is unavailable right now. Reload in a minute, or ${contact('email SyberLabs')}.`;
+const UNAVAILABLE_HTML = `Sign-in is unavailable right now. Reload in a minute, or ${contact('email SyberLabs')}.`;
// The GitHub POST form: full width, 48 px, solid, because it is the only way in (RFC-0002 3.2). switch=1 asks
// GitHub for its account picker.
-export function githubForm({ next, switching } = {}) {
+function githubForm({ next, switching } = {}) {
const fields = hidden([['next', next], ['switch', switching ? '1' : '']]);
const button = `
`;
return `
`;
@@ -45,7 +45,7 @@ export function githubForm({ next, switching } = {}) {
// Google's button, under GitHub's and centred (RFC-0002 3.2). staff.css resets the